diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..e222811 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.19.0 diff --git a/node-packages/eslint-config/CHANGELOG.md b/node-packages/eslint-config/CHANGELOG.md index 0783e5b..7d08709 100644 --- a/node-packages/eslint-config/CHANGELOG.md +++ b/node-packages/eslint-config/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/eslint-config` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed + +- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/node-packages/eslint-config/package.json b/node-packages/eslint-config/package.json index 2625dbc..07cf3ff 100644 --- a/node-packages/eslint-config/package.json +++ b/node-packages/eslint-config/package.json @@ -18,7 +18,7 @@ "access": "restricted" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "main": "index.js", "files": [ diff --git a/node-packages/stylelint-config/CHANGELOG.md b/node-packages/stylelint-config/CHANGELOG.md index a1a6f19..66aef49 100644 --- a/node-packages/stylelint-config/CHANGELOG.md +++ b/node-packages/stylelint-config/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/stylelint-config` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed + +- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/node-packages/stylelint-config/package.json b/node-packages/stylelint-config/package.json index d0b6e55..0a2c707 100644 --- a/node-packages/stylelint-config/package.json +++ b/node-packages/stylelint-config/package.json @@ -18,7 +18,7 @@ "access": "restricted" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "main": "index.js", "files": [ diff --git a/node-packages/tailwind-config/CHANGELOG.md b/node-packages/tailwind-config/CHANGELOG.md index 46e376a..24aeedf 100644 --- a/node-packages/tailwind-config/CHANGELOG.md +++ b/node-packages/tailwind-config/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/tailwind-config` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed + +- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/node-packages/tailwind-config/package.json b/node-packages/tailwind-config/package.json index 66c464e..5fc5a63 100644 --- a/node-packages/tailwind-config/package.json +++ b/node-packages/tailwind-config/package.json @@ -18,7 +18,7 @@ "access": "restricted" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "main": "index.js", "exports": { diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 5e24995..28e5b9e 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -11,7 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `lint/i18n` scaffold — a standalone `phpcs.i18n.xml.dist` running only `WordPress.WP.I18n` against the project's text domain, plus a `lint:i18n` composer script. Standalone because the `text_domain` property is project-specific and the engine never edits an existing `phpcs.xml.dist`. The `text_domain` input is `required` (discovered from `.wp-tooling.json` `textDomain` when present) rather than defaulted, because the sniff silently loses its `MissingArgDomain` / `TextDomainMismatch` checks when the property is wrong or unset. The rendered ruleset deliberately omits `default` from the allowed domains — listing it downgrades a missing domain argument from the `MissingArgDomain` error to the `MissingArgDomainDefault` warning — with a commented opt-in for projects that intentionally reuse core strings. Companion to the i18n lens skill in `rtcamp/wp-devtools`. - `wp-tooling a11y` subcommand + `@rtcamp/wp-tooling/a11y` library (`runA11y()` core, `runCli()` CLI adapter). Runs the consumer-installed `pa11y-ci` — resolved from the nearest local or hoisted `node_modules/pa11y-ci/package.json` and launched through its `bin` entry with Node; no shim execution, npx fallback, or network install — against the URLs in the project's pa11y config (`.pa11yci.json` by default, `--config ` to point elsewhere) and normalises the JSON into a stable report: `summary` counts plus per-URL `violations` carrying `id`, parsed `wcagCriterion`, `impact`, `runner`, `message`, `selector`, `context`, and grep-ready `domHints` (`tagName`, `classList`, `idAttr`, `attrs`). A URL that fails to load is a `scanError` counted in `summary.failedUrls`, never a violation. Exit codes: 0 clean · 1 run failure or unreachable URL · 2 usage/binary missing · 3 violations found. Supports `--output text|json` and `--dry-run`. The `setup/pa11y` scaffold's dependency pin is corrected to the published `pa11y-ci ^4.1.1`, and its config template now runs both engines (`"runners": ["axe", "htmlcs"]`) and scans project-owned URLs — the front page plus configurable page paths (`sample_page`, `search_page`, optional `extra_page`, each appended to `base_url`) — instead of `wp-admin`/`wp-login` (unauthenticated admin scans only ever audit the core-owned login chrome). - `accessibility` Claude Code skill (`skills/accessibility/`) — a find → fix → re-check lens over `wp-tooling a11y`: triages violations by WCAG criterion and impact, maps each one to the theme/plugin source that rendered it via the report's `domHints`, proposes minimal fixes with consent, and re-verifies until clean; core/third-party markup is classified as upstream and reported, never patched. Installed into consumers by `setup/claude-skills` alongside the `scaffold` and `setup` skills. - +- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (launched through its package `bin` entry with Node via the shared `src/a11y/resolve-bin.js` resolver, like `pa11y-ci` — no `.bin` shim or npx fallback; `--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits; it navigates in its own browser, so it still runs (and records or degrades on its own) after a puppeteer navigation failure. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD`, or `EBADCONFIG` for a known field with the wrong type or value, e.g. `"enabled": "false"` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. `engines.node` is `>=22.19` (Lighthouse 13's floor), with a matching root `.nvmrc`. +- `setup/perf` scaffold — renders `.perfrc.json` (project-owned URL slots: `sample_page`/`search_page`/optional `extra_page`, same pattern as `setup/pa11y`) and ships a hardened `server-profile.php` shim (`raw: true`, copied verbatim) plus `web-vitals`/`lighthouse`/`puppeteer` dev-dependency pins and `test:perf` / `profile:server` npm scripts. The shim removes `template_redirect`'s `redirect_canonical` before rendering (a canonical redirect ends the request with `exit()`, which bypasses `finally` and would otherwise kill the process before the profiler stops or the JSON is echoed), profiles with `start()`/`stop()` plus a `register_shutdown_function` fallback that drains open output buffers, copies the target's query string into `$_GET` before `wp()` (`WP::parse_request()` reads `$_GET`, not `REQUEST_URI`), and emits a STDERR route + backend diagnostic. Consumes `rtCamp\WPDevTools\Support\XHProfProfiler` (require-dev `rtcamp/wp-dev-tools`) — never reimplements xhprof; degrades to `[]` + a STDERR note when the class isn't installed. ## [1.0.0] - 2026-07-30 diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index c37a5f5..39a8a96 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -162,6 +162,7 @@ Available via `transform`: - `snake-case`: `qm-export` → `qm_export` - `upper-snake-case`: `wporg-username` → `WPORG_USERNAME` - `json-escape`: `Acme\Blog` → `Acme\\Blog` (embed a PHP namespace in a JSON snippet) +- `shell-escape`: `my plugin` → `'my plugin'` (POSIX-quote one shell argument; safe values like `wp-content/plugins/x` stay bare) Transforms are applied after the value is resolved. Add new transforms in `src/scaffolds/render.js` (`TRANSFORMS` map). diff --git a/node-packages/wp-tooling/package.json b/node-packages/wp-tooling/package.json index 82cdb68..0f4ba2e 100644 --- a/node-packages/wp-tooling/package.json +++ b/node-packages/wp-tooling/package.json @@ -17,7 +17,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "exports": { "./ui": "./src/ui/index.js", @@ -28,6 +28,7 @@ "./hooks": "./src/hooks/index.js", "./ci": "./src/ci/index.js", "./a11y": "./src/a11y/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..c9b1982 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json @@ -0,0 +1,77 @@ +{ + "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. The server command defaults to wp-env; edit `server.command` in .perfrc.json if your WordPress environment runs WP-CLI some other way (Local, DDEV, a remote SSH host, etc.).", + "source": "template", + "files": [ + { + "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, + "transform": "json-escape" + }, + { + "key": "sample_page", + "description": "Path of a post or page to test, appended to base_url (e.g. /hello-world/ or a permalink path).", + "default": "/?p=1", + "transform": "json-escape" + }, + { + "key": "search_page", + "description": "Path of the search-results page to test, appended to base_url.", + "default": "/?s=hello", + "transform": "json-escape" + }, + { + "key": "extra_page", + "description": "Optional path of one more page to test, appended to base_url. Omitted when empty; add further URLs directly in .perfrc.json.", + "default": "", + "transform": "json-escape" + }, + { + "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. Must match where this scaffold just installed server-profile.php — e.g. wp-content/plugins/my-plugin for a plugin, wp-content/themes/my-theme for a theme — or `.` only if this project IS the WordPress root. There's no safe default: guessing wrong means the server layer can't find its own shim.", + "required": true + }, + { + "key": "server_env_cwd_json", + "description": "server_env_cwd, JSON-escaped for .perfrc.json.", + "discover_from": "input:server_env_cwd", + "transform": "json-escape" + }, + { + "key": "server_env_cwd_shell", + "description": "server_env_cwd, shell-quoted for the profile:server script.", + "discover_from": "input:server_env_cwd", + "transform": "shell-escape" + } + ], + "npm_dev_dependencies": { + "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={{server_env_cwd_shell}} -- wp eval-file server-profile.php" + } + } +} diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache new file mode 100644 index 0000000..3664cf6 --- /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", "--no-install", "wp-env", "run", "cli", "--env-cwd={{server_env_cwd_json}}", "--", "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..21d12c7 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php @@ -0,0 +1,130 @@ +] [] + * # 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(); + +// wp()/template-loader.php may open their own nested buffers; unwind to +// the level recorded here rather than assuming only one was opened. +$server_profile_ob_level = ob_get_level(); +ob_start(); +wp(); +if ( ! defined( 'WP_USE_THEMES' ) ) { + define( 'WP_USE_THEMES', true ); +} +require ABSPATH . WPINC . '/template-loader.php'; +while ( ob_get_level() > $server_profile_ob_level ) { + ob_end_clean(); +} + +echo wp_json_encode( $server_profile_profiler->stop( $server_profile_top, 'server-profile' ) ) . PHP_EOL; + +// Route diagnostic (STDERR): makes a silently mis-routed path -- or a missing +// profiling backend -- visible next to the JSON. +$server_profile_query = $GLOBALS['wp_query']; + +$server_profile_route_type = static function ( \WP_Query $query ): string { + if ( $query->is_singular() ) { + return $query->is_page() ? 'page' : 'singular'; + } + if ( $query->is_home() ) { + return 'home'; + } + if ( $query->is_archive() ) { + return 'archive'; + } + if ( $query->is_404() ) { + return '404'; + } + return 'other'; +}; + +fwrite( + STDERR, + sprintf( + '[server-profile] path=%s backend=%s resolved=%s object_id=%d%s', + $server_profile_path, + $server_profile_backend, + $server_profile_route_type( $server_profile_query ), + (int) get_queried_object_id(), + PHP_EOL + ) +); diff --git a/node-packages/wp-tooling/src/a11y/resolve-bin.js b/node-packages/wp-tooling/src/a11y/resolve-bin.js index 0cd9b94..05d1a53 100644 --- a/node-packages/wp-tooling/src/a11y/resolve-bin.js +++ b/node-packages/wp-tooling/src/a11y/resolve-bin.js @@ -1,5 +1,6 @@ /** * Resolve the consumer's installed Node CLI, including workspace-hoisted copies. + * Shared by the a11y (pa11y-ci) and perf (lighthouse) runners. * Launch its package.json bin entry with Node on every platform: npm's .bin * shims are platform-specific and Windows .cmd files cannot use execFileSync. * No npx fallback: a cached/global package is not the consumer's dependency. diff --git a/node-packages/wp-tooling/src/cli/commands/perf.js b/node-packages/wp-tooling/src/cli/commands/perf.js new file mode 100644 index 0000000..65b15f8 --- /dev/null +++ b/node-packages/wp-tooling/src/cli/commands/perf.js @@ -0,0 +1,17 @@ +/** + * perf subcommand registration. + * + * The dispatcher (`src/cli/index.js`) auto-discovers every `*.js` file in + * this directory. Each module must export `{ name, summary, run }`. + * `run` is required lazily so cold-start cost stays close to a single + * subcommand's footprint. + */ + +'use strict'; + +module.exports = { + name: 'perf', + summary: + 'Run web-vitals + Lighthouse (and optional server xhprof) and emit a normalized performance report', + run: (argv) => require('../../perf/run').runCli(argv), +}; diff --git a/node-packages/wp-tooling/src/perf/collect-vitals.js b/node-packages/wp-tooling/src/perf/collect-vitals.js new file mode 100644 index 0000000..230ef58 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/collect-vitals.js @@ -0,0 +1,154 @@ +/** + * 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 (metric) { + window.__wpToolingVitals[metric.name] = { + value: metric.value, + rating: metric.rating, + attribution: { + target: (metric.attribution && metric.attribution.target) || null, + largestShiftTarget: (metric.attribution && metric.attribution.largestShiftTarget) || null, + interactionTarget: (metric.attribution && metric.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); + * a navigation failure specifically rejects with a `RunnerError` coded `ENAVFAIL`. + */ +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}` + ); + try { + await page.goto(url, { + waitUntil: 'networkidle2', + timeout: timeoutMs, + }); + } catch (err) { + const detail = (err && err.message ? err.message : '').toString(); + throw new RunnerError('ENAVFAIL', `navigation failed: ${detail}`, { + detail, + }); + } + await new Promise((resolve) => { + setTimeout(resolve, settleMs); + }); + 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 metric = raw[name]; + metrics[name] = + metric && typeof metric.value === 'number' + ? { value: metric.value, rating: metric.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.target || 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..388ff24 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/config.js @@ -0,0 +1,249 @@ +/** + * 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', + '--no-install', + 'wp-env', + 'run', + 'cli', + '--env-cwd=.', + '--', + 'wp', + ], + shim: 'server-profile.php', + top: 15, + }, + thresholds: { + cwv: 'poor', + lighthousePerformance: 0.5, + }, +}; + +/** Accepted `thresholds.cwv` modes (see `normalize.js` `isCwvIssue`). */ +const CWV_MODES = ['poor', 'needs-improvement', 'never']; + +/** + * Describe why a section field's value is invalid, judged against the type of + * its built-in default. Unknown keys are not checked — they are never read. + * + * @param {string} key Field name. + * @param {*} value Configured value. + * @param {*} def Built-in default for the field. + * @return {string|null} What was expected, or `null` when the value is valid. + */ +function fieldProblem(key, value, def) { + if (Array.isArray(def)) { + return Array.isArray(value) && + value.length > 0 && + value.every((v) => typeof v === 'string' && v.length > 0) + ? null + : 'a non-empty array of strings'; + } + if (typeof def === 'number') { + return Number.isFinite(value) && value >= 0 + ? null + : 'a non-negative number'; + } + if (key === 'cwv') { + return CWV_MODES.includes(value) + ? null + : `one of ${CWV_MODES.map((m) => `"${m}"`).join(', ')}`; + } + return typeof value === typeof def ? null : `a ${typeof def}`; +} + +/** + * Reject a parsed config whose known fields have the wrong type, so a typo + * like `"enabled": "false"` cannot silently invert a layer or surface later + * as a degraded layer instead of a config error. + * + * @param {*} raw Parsed config. + * @param {string} configPath Config path, for the error message. + * @throws {RunnerError} `EBADCONFIG` naming the first invalid field. + */ +function validateConfig(raw, configPath) { + const fail = (field, expected) => { + throw new RunnerError( + 'EBADCONFIG', + `invalid ${configPath}: "${field}" must be ${expected}`, + { configPath } + ); + }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + fail('(root)', 'an object'); + } + if (raw.urls !== undefined && !Array.isArray(raw.urls)) { + fail('urls', 'an array of URL strings'); + } + for (const section of Object.keys(DEFAULTS)) { + const override = raw[section]; + if (section === 'urls' || override === undefined) { + continue; + } + if ( + !override || + typeof override !== 'object' || + Array.isArray(override) + ) { + fail(section, 'an object'); + } + for (const [key, def] of Object.entries(DEFAULTS[section])) { + if (override[key] === undefined) { + continue; + } + const expected = fieldProblem(key, override[key], def); + if (expected) { + fail(`${section}.${key}`, expected); + } + } + } +} + +/** + * Shallow-merge a config section over its defaults. + * + * @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; `EBADCONFIG` when a known field has the wrong type or value; + * `ECONFIGREAD` when the config path exists but could not be read. + */ +function resolveConfig(options = {}) { + const cwd = options.cwd || process.cwd(); + 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 (err.code !== 'ENOENT') { + throw new RunnerError( + 'ECONFIGREAD', + `could not read ${configPath}: ${err.message}`, + { configPath } + ); + } + 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 } + ); + } + validateConfig(raw, 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..219f360 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/errors.js @@ -0,0 +1,36 @@ +/** + * 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 + * EBADCONFIG the perf config parsed, but a known field has the wrong type + * or value (e.g. `"enabled": "false"`) + * ECONFIGREAD the perf config path exists but could not be read — e.g. it + * is a directory, or permission was denied (distinct from a + * simply-absent config, which is not an error) + * ENOURLS no URLs could be resolved from the perf config or --url + * ENAVFAIL a URL failed to load in the browser (per-URL, recorded as + * that URL's `scanError`; never thrown out of the run) + */ + +'use strict'; + +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..e6ab433 --- /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` CLI (resolved and launched by the + * a11y runner's `resolve-bin.js`, exactly like `pa11y-ci`: the package's `bin` + * entry run with Node, no `.bin` shim or npx fallback) restricted to the + * `performance` category, with `--chrome-flags` pointed at Chrome for Testing via + * `CHROME_PATH` so the consumer machine needs no system Chrome install. + * A per-URL failure here is a degrade, not a run failure — `run.js` catches + * `RunnerError`s from this module and continues with `lighthouse: null` for + * 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..d6b7952 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -0,0 +1,401 @@ +/** + * 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).'; + +/** Lighthouse audit score (0-1) below which an audit counts as "failing" and gets surfaced. */ +const FAILING_AUDIT_SCORE_THRESHOLD = 0.9; + +/** + * Rate a metric value against its good/needs-improvement/poor band. Used as + * a fallback when the web-vitals library did not supply its own `rating`. + * + * @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 || value <= band[0]) { + return 'good'; + } + if (value <= band[1]) { + return 'needs-improvement'; + } + return 'poor'; +} + +/** + * Extract and rank an LHR's failing audits (score below the threshold). + * + * @param {Object} audits Raw LHR `audits` map. + * @param {number} topAudits Max entries to return. + * @return {Object[]} Failing audits, ascending by score. + */ +function extractFailingAudits(audits, topAudits) { + return Object.entries(audits) + .filter( + ([, audit]) => + audit && + typeof audit.score === 'number' && + audit.score < FAILING_AUDIT_SCORE_THRESHOLD + ) + .map(([id, audit]) => ({ + id, + title: typeof audit.title === 'string' ? audit.title : id, + score: audit.score, + displayValue: + typeof audit.displayValue === 'string' + ? audit.displayValue + : null, + })) + .sort((a, b) => a.score - b.score) + .slice(0, topAudits); +} + +/** + * Extract Lighthouse category scores + top failing audits from a raw LHR. + * + * @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 : {}; + + return { scores, audits: extractFailingAudits(audits, topAudits) }; +} + +/** + * 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, stat] of Object.entries(data)) { + if (!stat || typeof stat !== 'object' || Array.isArray(stat)) { + continue; + } + top.push({ + fn, + calls: Number(stat.ct) || 0, + wallMs: (Number(stat.wt) || 0) / 1000, + cpuMs: (Number(stat.cpu) || 0) / 1000, + memBytes: Number(stat.mu) || 0, + peakMemBytes: Number(stat.pmu) || 0, + }); + } + } + + 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 metric = metrics[name]; + if (!metric) { + continue; + } + const band = THRESHOLDS[name]; + const unit = name === 'CLS' ? '' : 'ms'; + lines.push( + `${name} ${metric.value}${unit} — ${metric.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; +} + +/** + * Rate one measurable metric's raw vitals reading and produce a worst-issue + * candidate when it isn't "good". Pulled out of `normalizePerf`'s per-URL + * loop so rating, issue-counting and worst-tracking aren't nested three + * levels inside a per-metric branch. + * + * @param {string} name Metric name. + * @param {Object|null} vitalMetric Raw `{value, rating}` reading for this metric, or null/undefined. + * @param {string} url URL this reading belongs to (for the worst-candidate). + * @param {string} cwvMode `thresholds.cwv` mode. + * @return {{metric: ({value: number, rating: string}|null), isIssue: boolean, + * worstCandidate: ({metric: string, url: string, value: number, rating: string, severity: number}|null)}} + * Normalised metric, whether it counts as an issue, and a worst-candidate (or null when not applicable). + */ +function rateMeasurableMetric(name, vitalMetric, url, cwvMode) { + if (!vitalMetric || typeof vitalMetric.value !== 'number') { + return { metric: null, isIssue: false, worstCandidate: null }; + } + + const rating = vitalMetric.rating || rateMetric(name, vitalMetric.value); + let worstCandidate = null; + if (rating !== 'good') { + worstCandidate = { + metric: name, + url, + value: vitalMetric.value, + rating, + severity: vitalMetric.value / THRESHOLDS[name][1], + }; + } + + return { + metric: { value: vitalMetric.value, rating }, + isIssue: isCwvIssue(rating, cwvMode), + worstCandidate, + }; +} + +/** + * Build the per-URL result entry for a browser scan failure — empty metrics + * and no assessment; the lighthouse and server layers (which run + * independently of the puppeteer browser) are kept as captured. + * + * @param {Object} raw Raw per-URL capture with a truthy `scanError`. + * @return {Object} Result entry for `normalizePerf`'s `results[]`. + */ +function buildScanErrorResult(raw) { + return { + url: raw.url, + scanError: raw.scanError, + metrics: Object.fromEntries(METRIC_NAMES.map((name) => [name, null])), + attribution: { lcpElement: null, clsSources: [], inpTarget: null }, + lighthouse: raw.lighthouse || null, + server: normalizeServer(raw.server), + assessment: [], + notes: raw.notes || [], + }; +} + +/** + * Build the per-URL result entry for a page that loaded successfully, and + * fold its issue count / worst-candidate / pass-fail outcome into `totals`. + * + * @param {Object} raw Raw per-URL capture (no `scanError`). + * @param {Object} thresholds Resolved `thresholds` config section. + * @param {Object} totals Running summary totals, mutated in place. + * @return {Object} Result entry for `normalizePerf`'s `results[]`. + */ +function buildUrlResult(raw, thresholds, totals) { + const vitals = raw.vitals || { metrics: {}, attribution: {} }; + const metrics = {}; + let urlIssues = 0; + + for (const name of MEASURABLE_METRIC_NAMES) { + const { metric, isIssue, worstCandidate } = rateMeasurableMetric( + name, + vitals.metrics[name], + raw.url, + thresholds.cwv + ); + metrics[name] = metric; + if (isIssue) { + urlIssues++; + } + if ( + worstCandidate && + (!totals.worst || worstCandidate.severity > totals.worst.severity) + ) { + totals.worst = worstCandidate; + } + } + metrics.INP = null; + + const lighthouse = raw.lighthouse; + if ( + lighthouse && + typeof lighthouse.scores.performance === 'number' && + typeof thresholds.lighthousePerformance === 'number' && + lighthouse.scores.performance < thresholds.lighthousePerformance + ) { + urlIssues++; + } + + totals.issues += urlIssues; + + const notes = raw.notes ? [...raw.notes] : []; + if (raw.vitalsError) { + notes.push(raw.vitalsError); + totals.failedUrls++; + } else if (urlIssues === 0) { + totals.passedUrls++; + } + + return { + url: raw.url, + scanError: null, + metrics, + attribution: vitals.attribution || { + lcpElement: null, + clsSources: [], + inpTarget: null, + }, + lighthouse, + server: normalizeServer(raw.server), + assessment: buildAssessment(metrics, lighthouse, thresholds), + notes, + }; +} + +/** + * Normalise raw per-URL perf capture into the final two-layer report. + * + * `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, + * vitalsError, 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 = []; + const totals = { passedUrls: 0, failedUrls: 0, issues: 0, worst: null }; + + for (const raw of rawResults || []) { + if (raw.scanError) { + totals.failedUrls++; + results.push(buildScanErrorResult(raw)); + continue; + } + results.push(buildUrlResult(raw, thresholds, totals)); + } + + return { + tool: 'web-vitals+lighthouse', + summary: { + urls: results.length, + passedUrls: totals.passedUrls, + failedUrls: totals.failedUrls, + issues: totals.issues, + worst: totals.worst + ? { + metric: totals.worst.metric, + url: totals.worst.url, + value: totals.worst.value, + rating: totals.worst.rating, + } + : null, + }, + results, + }; +} + +module.exports = { + normalizePerf, + rateMetric, + extractLighthouse, + normalizeServer, + THRESHOLDS, + METRIC_NAMES, + MEASURABLE_METRIC_NAMES, + SERVER_FIDELITY_NOTE, +}; 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..9666ad1 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/resolve-module.js @@ -0,0 +1,134 @@ +/** + * 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 + * `../a11y/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 { createRequire } = require('module'); +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; + while (true) { + 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` the consumer-installed `puppeteer`, returning the + * loaded module (never a path) so `run.js` — and its tests — depend only on + * this function's return value, not on Node's real module resolution; tests + * substitute a fake module by mocking this file, no real puppeteer install + * required. + * + * @param {Object} [options] + * @param {string} [options.cwd] Directory to resolve from. + * @return {*} The loaded module, or `null` when not installed. + */ +function requirePuppeteer(options = {}) { + const dir = resolveModuleDir('puppeteer', options); + if (!dir) { + return null; + } + // Literal specifier through Node's resolver, anchored in the consumer's tree. + return createRequire(path.join(dir, 'package.json'))('puppeteer'); +} + +/** + * 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, + requirePuppeteer, + 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..6a5f345 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/run.js @@ -0,0 +1,523 @@ +/** + * 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, + requirePuppeteer, + detectModule, +} = require('./resolve-module'); +const { detectBin, resolveBin } = require('../a11y/resolve-bin'); +const { launchBrowser, collectVitals } = require('./collect-vitals'); +const { runLighthouse, BIN: LIGHTHOUSE_BIN } = require('./lighthouse'); +const { runServerProfile } = require('./server-profile'); +const { + normalizePerf, + extractLighthouse, + MEASURABLE_METRIC_NAMES, +} = require('./normalize'); + +const INSTALL_HINT = 'wp-tooling add setup/perf'; +const WEB_VITALS_DIST = 'dist/web-vitals.attribution.iife.js'; + +/** + * 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 / EBADCONFIG / ENOURLS. + */ +async function runPerf(options = {}) { + const cwd = options.cwd || process.cwd(); + const { config, urls } = resolveConfig({ + configPath: options.configPath, + urls: options.urls, + cwd, + }); + + const puppeteer = requirePuppeteer({ 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. A navigation failure (`ENAVFAIL`) becomes a + * per-URL `scanError` (the caller's run continues); lighthouse and server + * failures degrade to `null` + a note without affecting the overall run. A + * page that loads but whose collector throws or harvests no metric is a + * separate `vitalsError`. Lighthouse navigates independently, so it runs in + * every case. + * + * @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; + let vitalsError = null; + + try { + vitals = await collectVitals( + browser, + scriptSource, + url, + config.webVitals + ); + if (MEASURABLE_METRIC_NAMES.every((name) => !vitals.metrics[name])) { + vitalsError = + 'web-vitals harvest returned no metrics — the injected collector may not have registered (e.g. the page never became interactive, or CSP blocked the injected script).'; + } + } catch (err) { + const detail = (err && err.message ? err.message : '').toString(); + // Only a navigation failure is a scan error; a collector failure after + // a good load keeps the URL's other layers (e.g. Lighthouse) in the report. + if (err instanceof RunnerError && err.code === 'ENAVFAIL') { + scanError = detail; + } else { + vitalsError = `web-vitals collection failed — ${detail}`; + } + } + + let lighthouse = null; + // Lighthouse navigates in its own browser, so it runs even after a + // puppeteer navigation failure (e.g. a networkidle2 timeout on a page + // that did load) and records or degrades independently. + if (config.lighthouse.enabled && lighthouseBin) { + try { + const lhr = runLighthouse(lighthouseBin, url, config.lighthouse, { + cwd, + 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, vitalsError, 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 summary = report.summary; + const failed = + summary.failedUrls > 0 ? `, ${summary.failedUrls} incomplete` : ''; + const lines = [ + `${report.tool}: ${summary.issues} issue(s) across ${summary.urls} URL(s); ${summary.passedUrls} clean${failed}.`, + ]; + if (summary.worst) { + lines.push( + `worst: ${summary.worst.metric} ${summary.worst.value} (${summary.worst.rating}) on ${summary.worst.url}` + ); + } + for (const result of report.results) { + lines.push(''); + if (result.scanError) { + lines.push(`${result.url} — scan failed`); + lines.push(` ${result.scanError}`); + if ( + result.lighthouse && + typeof result.lighthouse.scores.performance === 'number' + ) { + lines.push( + ` lighthouse performance ${result.lighthouse.scores.performance}` + ); + } + } else { + lines.push(`${result.url}`); + for (const line of result.assessment) { + lines.push(` ${line}`); + } + } + if (result.server) { + const top = result.server.top + .slice(0, 3) + .map((entry) => `${entry.fn} (${entry.wallMs.toFixed(1)}ms)`) + .join(', '); + lines.push(` server top: ${top || 'none'}`); + if (result.server.note) { + lines.push(` server note: ${result.server.note}`); + } + if (result.server.error) { + lines.push(` server error: ${result.server.error}`); + } + } + for (const note of result.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) { + // resolveBin only -- dry-run must not spawn lighthouse --version. + const bin = resolveBin(LIGHTHOUSE_BIN, { cwd }); + lines.push( + bin.source === 'missing' + ? ' lighthouse: NOT FOUND' + : ` lighthouse: ${[bin.command, ...bin.args].join(' ')} (${bin.source}, not probed — dry run)` + ); + } 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. + * + * `EBADJSON` here is always `resolveConfig`'s "the project's own perf config + * is malformed" — a usage error. `lighthouse.js` throws the same code for a + * corrupted Lighthouse run, but that one is always caught and degraded + * inside `collectOne`, so it never reaches this function. + * + * @param {Error} err The error. + * @return {number} Exit code: 2 (usage / module or binary missing / bad config), 1 (run failure). + */ +function handleError(err) { + process.stderr.write(`perf: ${err.message}\n`); + if ( + err instanceof RunnerError && + (err.code === 'EBINMISSING' || + err.code === 'ENOURLS' || + err.code === 'EBADJSON' || + err.code === 'EBADCONFIG' || + err.code === 'ECONFIGREAD') + ) { + 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) incomplete (failed to load or measure) — 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..7fc90e4 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -0,0 +1,158 @@ +/** + * 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 --no-install wp-env run cli --env-cwd= -- wp`. The URL's + * origin+path is passed as WP-CLI's `--url` (site context — the path lets a + * subdirectory multisite resolve the right site; also what arms + * `redirect_canonical()` in the shim's render, which is why the shim removes + * that hook) and the path+query is passed positionally (the shim reads it + * into `$_GET` itself). + * + * 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. + * + * POSIX only: the command is spawned without a shell, so on Windows npm's + * `.cmd` shims (including `npx`) will not launch. Point `server.command` at a + * directly executable WP-CLI for other environments. + */ + +'use strict'; + +const { spawnSync } = require('child_process'); + +const MAX_BUFFER = 64 * 1024 * 1024; +const RUN_TIMEOUT_MS = 120000; + +/** + * Split a target URL into WP-CLI's site URL (origin+path, no query) and its + * path+query — the two pieces the shim's contract expects separately + * (`wp eval-file server-profile.php [] [] [--url=]`). The + * path stays in the site URL because WP-CLI resolves a subdirectory + * multisite's site from it; a bare origin would target the main site. + * + * @param {string} url Full target URL. + * @return {{siteUrl: string, pathAndQuery: string}} Split URL. + */ +function splitUrl(url) { + const parsedUrl = new URL(url); + return { + siteUrl: `${parsedUrl.origin}${parsedUrl.pathname}`, + pathAndQuery: `${parsedUrl.pathname}${parsedUrl.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(); + // Find where JSON actually starts -- an array `[` or object `{` -- so a + // leading non-JSON preamble line (e.g. a PHP notice) doesn't break parsing. + // A failed candidate may itself be non-JSON text, so keep searching. + let start = trimmed.search(/[[{]/); + while (start !== -1) { + try { + return JSON.parse(trimmed.slice(start)); + } catch { + const next = trimmed.slice(start + 1).search(/[[{]/); + start = next === -1 ? -1 : start + next + 1; + } + } + return null; +} + +/** + * 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','--no-install','wp-env','run','cli','--env-cwd=...','--','wp']`). + * @param {string} server.shim Shim path, as WP-CLI sees it. + * @param {number} server.top Top-N functions to request. + * @param {string} url Target URL (origin+path used for `--url`; path+query passed positionally). + * @param {Object} [options] + * @param {string} [options.cwd] Working directory. + * @return {{data: (Object|Array|null), diagnostic: (string|null), error: (string|null)}} + * 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 siteUrl; + let pathAndQuery; + let args; + try { + ({ siteUrl, pathAndQuery } = splitUrl(url)); + const [, ...prefix] = server.command; + args = [ + ...prefix, + 'eval-file', + server.shim, + pathAndQuery, + String(server.top), + `--url=${siteUrl}`, + ]; + } catch (err) { + return { data: null, diagnostic: null, error: err.message }; + } + + const command = server.command[0]; + let result; + try { + // spawnSync itself can throw synchronously (e.g. command undefined), + // separately from the return-based result.error handled below. + result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + maxBuffer: MAX_BUFFER, + timeout: RUN_TIMEOUT_MS, + }); + } catch (err) { + return { data: null, diagnostic: null, error: err.message }; + } + + const diagnostic = (result.stderr || '').toString().trim() || null; + + if (result.error) { + return { data: null, diagnostic, error: result.error.message }; + } + + // A non-zero exit can still leave parseable JSON on stdout (e.g. a fatal + // render error), so check status before trusting the payload. + if (result.status !== 0) { + return { + data: null, + diagnostic, + error: `non-zero exit (${result.status === null ? 'null (timed out?)' : result.status})`, + }; + } + + const parsed = tryParse((result.stdout || '').toString()); + if (parsed === null) { + const detail = diagnostic || 'exit code 0'; + 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/src/scaffolds/add.js b/node-packages/wp-tooling/src/scaffolds/add.js index 3410547..9a6535e 100644 --- a/node-packages/wp-tooling/src/scaffolds/add.js +++ b/node-packages/wp-tooling/src/scaffolds/add.js @@ -107,6 +107,16 @@ function printHelp() { ); } +// Render [name, cmd] pairs as a pasteable JSON object body: escaped, comma-separated. +function scriptEntryLines(entries) { + return entries.map( + ([name, cmd], i) => + ` ${JSON.stringify(name)}: ${JSON.stringify(cmd)}${ + i < entries.length - 1 ? ',' : '' + }` + ); +} + function printHumanReport(result) { const { scaffold, engine, developer, ai, warnings } = result; const lines = []; @@ -132,7 +142,12 @@ function printHumanReport(result) { } lines.push(''); const composer = Object.entries(developer.install.composer); + const composerDev = Object.entries(developer.install.composerDev || {}); + const composerSuggest = Object.entries( + developer.install.composerSuggest || {} + ); const npm = Object.entries(developer.install.npm); + const npmDev = Object.entries(developer.install.npmDev || {}); const npmScripts = developer.scripts ? Object.entries(developer.scripts.npm || {}) : []; @@ -141,7 +156,10 @@ function printHumanReport(result) { : []; if ( composer.length || + composerDev.length || + composerSuggest.length || npm.length || + npmDev.length || developer.secrets.length || npmScripts.length || composerScripts.length @@ -153,23 +171,37 @@ function printHumanReport(result) { lines.push(` composer require ${pkg}:${ver}`); } } + if (composerDev.length) { + lines.push(' Install (composer dev):'); + for (const [pkg, ver] of composerDev) { + lines.push(` composer require --dev ${pkg}:${ver}`); + } + } + if (composerSuggest.length) { + lines.push(' Suggest (composer, informational):'); + for (const [pkg, note] of composerSuggest) { + lines.push(` ${pkg}: ${note}`); + } + } if (npm.length) { lines.push(' Install (npm):'); for (const [pkg, ver] of npm) { lines.push(` npm install ${pkg}@${ver}`); } } + if (npmDev.length) { + lines.push(' Install (npm dev):'); + for (const [pkg, ver] of npmDev) { + lines.push(` npm install --save-dev ${pkg}@${ver}`); + } + } if (npmScripts.length) { lines.push(' Add to package.json "scripts":'); - for (const [name, cmd] of npmScripts) { - lines.push(` "${name}": "${cmd}"`); - } + lines.push(...scriptEntryLines(npmScripts)); } if (composerScripts.length) { lines.push(' Add to composer.json "scripts":'); - for (const [name, cmd] of composerScripts) { - lines.push(` "${name}": "${cmd}"`); - } + lines.push(...scriptEntryLines(composerScripts)); } if (developer.secrets.length) { lines.push( diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 6dd6647..2c07c20 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -46,6 +46,22 @@ const { indexEntryToRecord, } = require('./sources'); +/** + * Render every value in a scripts map (npm or composer) through Mustache. + * + * @param {Object} scripts Raw `{name: command}` map from scaffold.json. + * @param {Object} resolved Resolved inputs to render with. + * @return {Object} Rendered `{name: command}` map. + */ +function renderScriptMap(scripts, resolved) { + return Object.fromEntries( + Object.entries(scripts || {}).map(([name, cmd]) => [ + name, + render(cmd, resolved), + ]) + ); +} + class ScaffoldRegistry { /** * @param {Object|string} options @@ -290,6 +306,13 @@ class ScaffoldRegistry { const discovery = await loadDiscovery(cwd); const resolved = resolveInputs(scaffold, inputs, discovery); + // Rendered before any write, so a bad placeholder throws before partial writes. + const scaffoldScripts = scaffold.scripts || {}; + const renderedScripts = { + npm: renderScriptMap(scaffoldScripts.npm, resolved), + composer: renderScriptMap(scaffoldScripts.composer, resolved), + }; + // Warn on supplied keys the scaffold does not declare. Typos like // `--namspace=Inc` would otherwise be silently dropped while the // real `namespace` input falls back to its default. Soft warning, @@ -462,7 +485,6 @@ class ScaffoldRegistry { }); } - const scaffoldScripts = scaffold.scripts || {}; return { scaffold: { id: makeId(scaffold), @@ -492,10 +514,7 @@ class ScaffoldRegistry { npm: { ...(scaffold.npm_dependencies || {}) }, npmDev: { ...(scaffold.npm_dev_dependencies || {}) }, }, - scripts: { - npm: { ...(scaffoldScripts.npm || {}) }, - composer: { ...(scaffoldScripts.composer || {}) }, - }, + scripts: renderedScripts, secrets: (scaffold.secrets || []).map((s) => ({ ...s })), }, ai: { wiring: aiWiring, tests: aiTests }, @@ -1094,6 +1113,14 @@ function inferPlaceholders(scaffold) { seen.add(p); } } + for (const target of ['npm', 'composer']) { + const map = (scaffold.scripts && scaffold.scripts[target]) || {}; + for (const cmd of Object.values(map)) { + for (const p of collectPlaceholders(cmd)) { + seen.add(p); + } + } + } return Array.from(seen); } diff --git a/node-packages/wp-tooling/src/scaffolds/render.js b/node-packages/wp-tooling/src/scaffolds/render.js index 818303d..1957838 100644 --- a/node-packages/wp-tooling/src/scaffolds/render.js +++ b/node-packages/wp-tooling/src/scaffolds/render.js @@ -225,6 +225,11 @@ const TRANSFORMS = { 'upper-snake-case': (s) => splitWords(s).join('_').toUpperCase(), // Escape JSON string contents; templates supply the surrounding quotes. 'json-escape': (s) => JSON.stringify(String(s)).slice(1, -1), + // POSIX-shell quote a single argument, only when it needs quoting. + 'shell-escape': (s) => { + const v = String(s); + return /^[\w@%+=:,./-]+$/.test(v) ? v : `'${v.replace(/'/g, `'\\''`)}'`; + }, }; function splitWords(s) { diff --git a/node-packages/wp-tooling/src/scaffolds/schema.js b/node-packages/wp-tooling/src/scaffolds/schema.js index c4601f8..09d2690 100644 --- a/node-packages/wp-tooling/src/scaffolds/schema.js +++ b/node-packages/wp-tooling/src/scaffolds/schema.js @@ -54,6 +54,7 @@ const ALLOWED_INPUT_TRANSFORMS = [ 'snake-case', 'upper-snake-case', 'json-escape', + 'shell-escape', ]; /** Optional dependency maps. Each is `{ "": "" }`. */ diff --git a/node-packages/wp-tooling/src/scaffolds/validate.js b/node-packages/wp-tooling/src/scaffolds/validate.js index 20a71dd..3e32a6b 100644 --- a/node-packages/wp-tooling/src/scaffolds/validate.js +++ b/node-packages/wp-tooling/src/scaffolds/validate.js @@ -669,6 +669,24 @@ function checkTemplatesRender(scaffold, scaffoldDir) { errs.push(`wiring snippet_template render failed: ${err.message}`); } } + for (const target of ALLOWED_SCRIPT_TARGETS) { + const map = (scaffold.scripts && scaffold.scripts[target]) || {}; + for (const [name, cmd] of Object.entries(map)) { + const missing = collectPlaceholders(cmd).filter( + (k) => !(k in supply) + ); + for (const k of missing) { + supply[k] = 'x'; + } + try { + render(cmd, supply); + } catch (err) { + errs.push( + `scripts.${target}['${name}'] render failed: ${err.message}` + ); + } + } + } return errs; } diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js new file mode 100644 index 0000000..9e1c141 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -0,0 +1,512 @@ +'use strict'; + +jest.mock('child_process'); +jest.mock('../../src/perf/resolve-module'); +jest.mock('../../src/perf/collect-vitals'); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); +const resolveModule = require('../../src/perf/resolve-module'); +const collectVitalsModule = require('../../src/perf/collect-vitals'); +const { runCli } = require('../../src/perf/run'); +const { RunnerError } = require('../../src/perf/errors'); + +const FIXTURES = path.join(__dirname, 'fixtures'); +const FIXTURE_CONFIG = path.join(FIXTURES, '.perfrc.json'); +const PARTIAL_CONFIG = path.join(FIXTURES, 'partial.perfrc.json'); +const MISSING_CONFIG = path.join(FIXTURES, 'does-not-exist.json'); +const MALFORMED_CONFIG = path.join(FIXTURES, 'malformed.perfrc.json'); + +const GOOD_METRICS = { + metrics: { + 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; + let cwdSpy; + let root; + let lighthouseEntry; + + beforeEach(() => { + // A consumer project with lighthouse installed: the shared resolver + // reads its package.json bin entry and launches it with Node. + root = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-cli-')); + const packageDir = path.join(root, 'node_modules', 'lighthouse'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ bin: { lighthouse: './cli/index.js' } }) + ); + lighthouseEntry = path.join(packageDir, 'cli', 'index.js'); + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(root); + + stdout = []; + stderr = []; + outSpy = jest.spyOn(process.stdout, 'write').mockImplementation((c) => { + stdout.push(c.toString()); + return true; + }); + errSpy = jest.spyOn(process.stderr, 'write').mockImplementation((c) => { + stderr.push(c.toString()); + return true; + }); + + resolveModule.requirePuppeteer.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(); + cwdSpy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + 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.requirePuppeteer.mockReturnValue(null); + expect(await runCli(['--config', FIXTURE_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/puppeteer not found/); + expect(stderr.join('')).toMatch(/wp-tooling add setup\/perf/); + }); + + test('launches lighthouse through Node and its package bin entry, never a .bin shim', async () => { + const code = await runCli(['--config', FIXTURE_CONFIG]); + expect(code).toBe(0); + const lighthouseCalls = execFileSync.mock.calls.filter( + (call) => call[1][0] === lighthouseEntry + ); + // The --version probe and one run per fixture URL. + expect(lighthouseCalls.length).toBeGreaterThan(1); + for (const [command] of lighthouseCalls) { + expect(command).toBe(process.execPath); + } + }); + + test('an uninstalled lighthouse exits 2 without probing or falling back to npx', async () => { + fs.rmSync(path.join(root, 'node_modules', 'lighthouse'), { + recursive: true, + }); + expect(await runCli(['--config', FIXTURE_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/lighthouse not found/); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + test('a lighthouse that fails its --version probe exits 2', async () => { + mockChildProcess({ lighthouseAvailable: false }); + expect(await runCli(['--config', FIXTURE_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/lighthouse not found/); + }); + + test('no config and no --url exits 2 (ENOURLS)', async () => { + expect(await runCli(['--config', MISSING_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/no URLs to test/); + }); + + test('a config with a wrongly typed field exits 2 (EBADCONFIG) before any layer runs', async () => { + const configPath = path.join(root, '.perfrc.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + urls: ['http://localhost:8888/'], + server: { enabled: 'false' }, + }) + ); + expect(await runCli(['--config', configPath])).toBe(2); + expect(stderr.join('')).toMatch(/"server\.enabled" must be a boolean/); + expect(spawnSync).not.toHaveBeenCalled(); + expect(collectVitalsModule.collectVitals).not.toHaveBeenCalled(); + }); + + test('a malformed config exits 2 (EBADJSON), not 1', async () => { + expect(await runCli(['--config', MALFORMED_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/invalid JSON/); + }); + + 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 RunnerError( + 'ENAVFAIL', + 'navigation failed: 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(/incomplete/); + + // Lighthouse navigates in its own browser, so a puppeteer navigation + // failure (e.g. a networkidle2 timeout) doesn't skip it: it still runs + // and its result is recorded on the failed URL. + const nonProbeCalls = execFileSync.mock.calls.filter( + (call) => !call[1].includes('--version') + ); + expect(nonProbeCalls.length).toBeGreaterThan(0); + expect(parsed.results[0].scanError).toMatch(/navigation failed/); + expect(parsed.results[0].lighthouse.scores.performance).toBe(0.95); + // The server layer profiles via WP-CLI, not the browser, so it still + // runs even though the frontend layer failed to load. + expect(spawnSync).toHaveBeenCalled(); + }); + + test('an empty web-vitals harvest is a run failure (exit 1), and lighthouse still runs for it', async () => { + collectVitalsModule.collectVitals.mockResolvedValue({ + metrics: { LCP: null, CLS: null, INP: null, FCP: null, TTFB: null }, + attribution: { lcpElement: null, clsSources: [], inpTarget: null }, + }); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(1); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.summary.passedUrls).toBe(0); + expect(parsed.summary.failedUrls).toBeGreaterThan(0); + expect(parsed.results[0].notes.join('')).toMatch( + /web-vitals harvest returned no metrics/ + ); + + // Unlike a scanError, the page loaded fine, so lighthouse still runs. + const nonProbeCalls = execFileSync.mock.calls.filter( + (call) => !call[1].includes('--version') + ); + expect(nonProbeCalls.length).toBeGreaterThan(0); + }); + + test('a collector failure after navigation is a run failure (exit 1), and lighthouse still runs for it', async () => { + collectVitalsModule.collectVitals.mockRejectedValue( + new Error('Execution context was destroyed') + ); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(1); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.summary.failedUrls).toBeGreaterThan(0); + + // Only a navigation failure is a scanError; this page loaded, so its + // lighthouse result survives into the report. + const result = parsed.results[0]; + expect(result.scanError).toBeNull(); + expect(result.lighthouse.scores.performance).toBe(0.95); + expect(result.notes.join('')).toMatch( + /web-vitals collection failed — Execution context was destroyed/ + ); + const nonProbeCalls = execFileSync.mock.calls.filter( + (call) => !call[1].includes('--version') + ); + expect(nonProbeCalls.length).toBeGreaterThan(0); + }); + + test('--output text still renders the server section for a URL that failed to scan', async () => { + mockChildProcess({ + serverResult: { + stdout: JSON.stringify({ + 'WP_Query::get_posts': { + ct: 3, + wt: 41200, + cpu: 38000, + mu: 1048576, + pmu: 1148576, + }, + }), + stderr: '', + status: 0, + }, + }); + collectVitalsModule.collectVitals.mockRejectedValue( + new RunnerError( + 'ENAVFAIL', + 'navigation failed: net::ERR_CONNECTION_REFUSED' + ) + ); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'text', + ]); + expect(code).toBe(1); + const out = stdout.join(''); + expect(out).toMatch(/— scan failed/); + expect(out).toMatch(/lighthouse performance 0\.95/); + expect(out).toMatch(/server top: WP_Query::get_posts/); + expect(out).toMatch(/server note: profiled via/); + }); + + test('after a navigation failure, a failing lighthouse degrades on its own', async () => { + mockChildProcess({ lighthouseRunThrows: true }); + collectVitalsModule.collectVitals.mockRejectedValue( + new RunnerError( + 'ENAVFAIL', + 'navigation failed: Navigation timeout of 5000 ms exceeded' + ) + ); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(1); + const result = JSON.parse(stdout.join('')).results[0]; + expect(result.scanError).toMatch(/Navigation timeout/); + expect(result.lighthouse).toBeNull(); + expect(result.notes.join('')).toMatch(/lighthouse: failed/); + }); + + test('a lighthouse runtime failure degrades that layer without affecting the exit code', async () => { + mockChildProcess({ lighthouseRunThrows: true }); + const code = await runCli([ + '--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(/non-zero exit \(255\)/); + 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 reports an uninstalled lighthouse as NOT FOUND', async () => { + fs.rmSync(path.join(root, 'node_modules', 'lighthouse'), { + recursive: true, + }); + const code = await runCli(['--dry-run', '--config', FIXTURE_CONFIG]); + expect(code).toBe(0); + expect(stdout.join('')).toContain('lighthouse: NOT FOUND'); + }); + + test('--dry-run resolves everything but runs nothing', async () => { + const code = await runCli(['--dry-run', '--config', FIXTURE_CONFIG]); + expect(code).toBe(0); + const out = stdout.join(''); + expect(out).toMatch(/\[dry-run\] perf would run:/); + expect(out).toMatch(/puppeteer:/); + expect(out).toMatch(/lighthouse:.*not probed — dry run/); + expect(out).toContain( + `lighthouse: ${process.execPath} ${lighthouseEntry} (local,` + ); + expect(out).toMatch(/server:/); + + // Dry-run must not probe lighthouse (or invoke anything else). + // 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(0); + expect(spawnSync).not.toHaveBeenCalled(); + expect(collectVitalsModule.launchBrowser).not.toHaveBeenCalled(); + expect(collectVitalsModule.collectVitals).not.toHaveBeenCalled(); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/collect-vitals.test.js b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js new file mode 100644 index 0000000..6ab2474 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js @@ -0,0 +1,170 @@ +'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: { target: '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('an explicit 0 for settleMs/timeoutMs is respected, not defaulted', async () => { + const { page, calls } = fakePage({ harvested: {} }); + const browser = { newPage: jest.fn(async () => page) }; + + await collectVitals(browser, '/* iife */', 'http://localhost:8888/', { + settleMs: 0, + timeoutMs: 0, + }); + + expect(calls[1]).toEqual([ + 'goto', + 'http://localhost:8888/', + { waitUntil: 'networkidle2', timeout: 0 }, + ]); + }); + + test('a goto rejection propagates, but the page is still closed', async () => { + const gotoError = new Error('net::ERR_CONNECTION_REFUSED'); + const { page, calls } = fakePage({ gotoError }); + const browser = { newPage: jest.fn(async () => page) }; + + await expect( + collectVitals(browser, '/* iife */', 'http://localhost:8888/', { + settleMs: 1, + }) + ).rejects.toMatchObject({ + code: 'ENAVFAIL', + message: expect.stringContaining('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: { target: '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..a4ee558 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/config.test.js @@ -0,0 +1,218 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +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'); + }); + + test('a config path that is a directory throws ECONFIGREAD, not a silent fallback', () => { + const err = grab(() => + resolveConfig({ cwd: FIXTURES, configPath: '.' }) + ); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('ECONFIGREAD'); + }); + + test('a config path that is a directory throws ECONFIGREAD even when --url is given', () => { + const err = grab(() => + resolveConfig({ + cwd: FIXTURES, + configPath: '.', + urls: ['http://example.test/'], + }) + ); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('ECONFIGREAD'); + }); +}); + +describe('resolveConfig — field validation', () => { + let dir; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-config-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** + * Write `raw` as the config and resolve it with a --url, returning the error. + * + * @param {*} raw Config value to serialise. + * @return {Error|null} The thrown error, or null. + */ + function resolveRaw(raw) { + fs.writeFileSync(path.join(dir, '.perfrc.json'), JSON.stringify(raw)); + return grab(() => + resolveConfig({ cwd: dir, urls: ['http://example.test/'] }) + ); + } + + test.each([ + [{ server: { enabled: 'false' } }, 'server.enabled', 'a boolean'], + [ + { lighthouse: { categories: 'performance' } }, + 'lighthouse.categories', + 'array', + ], + [{ lighthouse: { topAudits: '5' } }, 'lighthouse.topAudits', 'number'], + [{ webVitals: { settleMs: -1 } }, 'webVitals.settleMs', 'number'], + [{ server: { command: [] } }, 'server.command', 'array'], + [{ server: { shim: 42 } }, 'server.shim', 'a string'], + [{ thresholds: { cwv: 'bad' } }, 'thresholds.cwv', '"poor"'], + [{ server: 'yes' }, 'server', 'an object'], + [{ urls: 'http://x/' }, 'urls', 'an array'], + [['http://x/'], '(root)', 'an object'], + ])('%j throws EBADCONFIG naming %s', (raw, field, expected) => { + const err = resolveRaw(raw); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('EBADCONFIG'); + expect(err.message).toContain(`"${field}" must be`); + expect(err.message).toContain(expected); + }); + + test('valid fields and unknown keys pass through', () => { + expect( + resolveRaw({ + server: { enabled: true, top: 0, futureKey: 'x' }, + thresholds: { cwv: 'never', lighthousePerformance: 0.9 }, + }) + ).toBeNull(); + }); +}); + +describe('mergeConfig', () => { + test('merges a partial section over its defaults without touching others', () => { + const merged = mergeConfig({ + 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..d385316 --- /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", "--no-install", "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..0c31fac --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/normalize.test.js @@ -0,0 +1,458 @@ +'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('skips malformed entries in the profiler payload instead of throwing', () => { + const normalized = normalizeServer({ + data: { + 'WP_Query::get_posts': { + ct: 3, + wt: 41200, + cpu: 38000, + mu: 1048576, + pmu: 1148576, + }, + bad_null: null, + bad_array: [1, 2], + bad_primitive: 'oops', + }, + diagnostic: null, + error: null, + }); + expect(normalized.top.map((f) => f.fn)).toEqual([ + 'WP_Query::get_posts', + ]); + }); + + test('an invocation failure carries the error and skips the empty-backend guidance', () => { + const normalized = normalizeServer({ + data: null, + 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 scan error keeps the independently captured lighthouse layer, still not an issue', () => { + const lighthouse = { scores: { performance: 0.2 }, audits: [] }; + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: + 'navigation failed: Navigation timeout of 30000 ms exceeded', + vitals: null, + lighthouse, + server: null, + notes: [], + }, + ]); + expect(report.results[0].lighthouse).toEqual(lighthouse); + expect(report.summary.failedUrls).toBe(1); + expect(report.summary.issues).toBe(0); + }); + + test('a clean URL with good metrics counts as passed with zero issues', () => { + const report = normalizePerf([ + { + 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('vitalsError counts the URL as failed (not passed), even with zero metric issues, and is appended to notes', () => { + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: null, + vitalsError: 'web-vitals harvest returned no metrics', + vitals: { + metrics: { LCP: null, CLS: null, FCP: null, TTFB: null }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: [], + }, + ]); + expect(report.summary).toEqual({ + urls: 1, + passedUrls: 0, + failedUrls: 1, + issues: 0, + worst: null, + }); + expect(report.results[0].notes).toEqual([ + 'web-vitals harvest returned no metrics', + ]); + }); + + test('a lighthouse issue still counts towards summary.issues alongside a vitalsError', () => { + const report = normalizePerf( + [ + { + url: 'http://localhost:8888/', + scanError: null, + vitalsError: 'web-vitals harvest returned no metrics', + vitals: { metrics: {}, attribution: {} }, + lighthouse: extractLighthouse(LHR), + server: null, + notes: [], + }, + ], + { thresholds: { cwv: 'poor', lighthousePerformance: 0.9 } } + ); + expect(report.summary.failedUrls).toBe(1); + expect(report.summary.passedUrls).toBe(0); + expect(report.summary.issues).toBe(1); + }); + + test('carries per-URL notes through untouched', () => { + const report = normalizePerf([ + { + 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..75c0157 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/resolve-module.test.js @@ -0,0 +1,190 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + findModuleDir, + resolveModuleDir, + resolveModuleFile, + requirePuppeteer, + 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('requirePuppeteer', () => { + let root; + + afterEach(() => { + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('requires and returns the installed puppeteer', () => { + root = tmpTree(); + const dir = makeModule(root, 'puppeteer', { main: 'index.js' }); + fs.writeFileSync( + path.join(dir, 'index.js'), + 'module.exports = { marker: "fixture-loaded" };' + ); + expect(requirePuppeteer({ cwd: root })).toEqual({ + marker: 'fixture-loaded', + }); + }); + + test('returns null when not installed', () => { + root = tmpTree(); + expect(requirePuppeteer({ 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..ecfad24 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/server-profile.test.js @@ -0,0 +1,186 @@ +'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 the site URL (origin + path) from path + query', () => { + expect(splitUrl('http://localhost:8765/?p=1')).toEqual({ + siteUrl: 'http://localhost:8765/', + pathAndQuery: '/?p=1', + }); + }); + + test('a bare root path has no query', () => { + expect(splitUrl('http://localhost:8765/')).toEqual({ + siteUrl: 'http://localhost:8765/', + pathAndQuery: '/', + }); + }); + + test('keeps a subdirectory multisite path in the site URL', () => { + expect(splitUrl('https://example.com/site1/page/?s=x')).toEqual({ + siteUrl: 'https://example.com/site1/page/', + pathAndQuery: '/site1/page/?s=x', + }); + }); +}); + +describe('runServerProfile', () => { + 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: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/no parseable output/); + }); + + test('a non-zero exit is a failure even when stdout happens to be parseable', () => { + spawnSync.mockReturnValue({ + stdout: '{"fn":{"ct":1,"wt":1,"cpu":1,"mu":1,"pmu":1}}', + stderr: '', + status: 255, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/non-zero exit \(255\)/); + }); + + test('keeps searching past a bracket that is not the real JSON payload', () => { + spawnSync.mockReturnValue({ + stdout: '[notice] filesystem warning\n{"fn":{"ct":1,"wt":10,"cpu":10,"mu":100,"pmu":200}}', + stderr: '', + status: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data.fn.ct).toBe(1); + expect(result.error).toBeNull(); + }); + + test('falls through to null when no candidate bracket ever parses', () => { + spawnSync.mockReturnValue({ + stdout: '[not json] {also not json', + stderr: '', + status: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/no parseable output/); + }); + + 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(); + }); + + test('degrades (never throws) when spawnSync itself throws — e.g. an empty server.command', () => { + spawnSync.mockImplementation(() => { + throw new TypeError('The "file" argument must be of type string'); + }); + const result = runServerProfile( + { ...SERVER, command: [] }, + 'http://localhost:8765/' + ); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/file.*argument/); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/add.test.js b/node-packages/wp-tooling/tests/scaffolds/add.test.js index 15b24a0..13accfe 100644 --- a/node-packages/wp-tooling/tests/scaffolds/add.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/add.test.js @@ -209,6 +209,104 @@ describe('add command non-interactive flow', () => { }); }); +describe('add command human report', () => { + async function buildProjectWithDevDeps(cwd) { + const scaffoldDir = path.join( + cwd, + 'bin', + 'scaffolds', + 'test', + 'devdeps' + ); + await fs.mkdir(path.join(scaffoldDir, 'templates'), { + recursive: true, + }); + await fs.writeFile( + path.join(scaffoldDir, 'scaffold.json'), + JSON.stringify({ + slug: 'devdeps', + category: 'test', + name: 'Dev deps', + description: + 'Scaffold with dev dependencies, for report testing.', + source: 'template', + files: [{ src: 'templates/out.txt.mustache', dest: 'out.txt' }], + npm_dev_dependencies: { 'pa11y-ci': '^6.0.0' }, + scripts: { + npm: { + 'test:perf': 'wp-tooling perf', + 'profile:server': `run --cwd='my "odd" dir'`, + }, + composer: { lint: 'phpcs', analyse: 'phpstan analyse' }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(scaffoldDir, 'templates/out.txt.mustache'), + 'ok\n', + 'utf8' + ); + } + + it('prints an "Install (npm dev):" section for npm_dev_dependencies', async () => { + const cwd = makeTmpDir(); + await buildProjectWithDevDeps(cwd); + const originalOut = process.stdout.write.bind(process.stdout); + let captured = ''; + process.stdout.write = (chunk) => { + captured += chunk; + return true; + }; + const code = await add.runCli([ + 'test/devdeps', + '--non-interactive', + '--cwd', + cwd, + ]); + process.stdout.write = originalOut; + expect(code).toBe(0); + expect(captured).toContain('Install (npm dev):'); + expect(captured).toContain('npm install --save-dev pa11y-ci@^6.0.0'); + }); + + it('prints each scripts block as a pasteable JSON body — escaped and comma-separated', async () => { + const cwd = makeTmpDir(); + await buildProjectWithDevDeps(cwd); + const originalOut = process.stdout.write.bind(process.stdout); + let captured = ''; + process.stdout.write = (chunk) => { + captured += chunk; + return true; + }; + const code = await add.runCli([ + 'test/devdeps', + '--non-interactive', + '--cwd', + cwd, + ]); + process.stdout.write = originalOut; + expect(code).toBe(0); + // The entry lines right under a heading, parsed together as one object. + const block = (heading) => { + const lines = captured.split('\n'); + const start = lines.indexOf(heading) + 1; + const end = lines.findIndex( + (l, i) => i >= start && !l.startsWith(' ') + ); + return JSON.parse(`{${lines.slice(start, end).join('\n')}}`); + }; + expect(block(' Add to package.json "scripts":')).toEqual({ + 'test:perf': 'wp-tooling perf', + 'profile:server': `run --cwd='my "odd" dir'`, + }); + expect(block(' Add to composer.json "scripts":')).toEqual({ + lint: 'phpcs', + analyse: 'phpstan analyse', + }); + }); +}); + describe('add debug logging does not leak raw argument values', () => { let logPath; diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index 76b45cd..e09392d 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -124,6 +124,209 @@ describe('ci/test-measure rendering', () => { }); }); +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', server_env_cwd: '.' }, + { 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', + '--no-install', + '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', + '--no-install', + '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 explicitly "."', async () => { + const r = registry; + const target = makeTmpDir(); + await r.execute( + 'setup/perf', + { + base_url: 'http://localhost:8888', + server_enabled: 'true', + server_env_cwd: '.', + }, + { 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', + '--no-install', + 'wp-env', + 'run', + 'cli', + '--env-cwd=.', + '--', + 'wp', + ]); + }); + + it('renders the profile:server npm script from the resolved server_env_cwd, not a hardcoded plugin-path guess', async () => { + const r = registry; + + const pluginResult = await r.execute( + 'setup/perf', + { + base_url: 'http://localhost:8888', + server_env_cwd: 'wp-content/plugins/dummy-plugin', + }, + { dryRun: true, cwd: makeTmpDir() } + ); + expect(pluginResult.developer.scripts.npm['profile:server']).toBe( + 'wp-env run cli --env-cwd=wp-content/plugins/dummy-plugin -- wp eval-file server-profile.php' + ); + + const rootResult = await r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888', server_env_cwd: '.' }, + { dryRun: true, cwd: makeTmpDir() } + ); + expect(rootResult.developer.scripts.npm['profile:server']).toBe( + 'wp-env run cli --env-cwd=. -- wp eval-file server-profile.php' + ); + }); + + it('JSON-escapes every URL input while preserving its value', async () => { + const target = makeTmpDir(); + const inputs = { + base_url: 'http://localhost:8888/"quoted"', + sample_page: '/path\\segment', + search_page: '/?s="hello"&page=2', + extra_page: '/line\nbreak', + server_env_cwd: '.', + }; + await registry.execute('setup/perf', inputs, { cwd: target }); + const config = JSON.parse( + fs.readFileSync(path.join(target, '.perfrc.json'), 'utf8') + ); + expect(config.urls).toEqual([ + `${inputs.base_url}/`, + inputs.base_url + inputs.sample_page, + inputs.base_url + inputs.search_page, + inputs.base_url + inputs.extra_page, + ]); + }); + + it('escapes server_env_cwd separately for the JSON config and the shell script', async () => { + const target = makeTmpDir(); + const envCwd = 'wp-content/plugins/my "odd" plugin'; + const result = await registry.execute( + 'setup/perf', + { base_url: 'http://localhost:8888', server_env_cwd: envCwd }, + { cwd: target } + ); + const config = JSON.parse( + fs.readFileSync(path.join(target, '.perfrc.json'), 'utf8') + ); + expect(config.server.command).toContain(`--env-cwd=${envCwd}`); + expect(result.developer.scripts.npm['profile:server']).toBe( + `wp-env run cli --env-cwd='${envCwd}' -- wp eval-file server-profile.php` + ); + }); + + it('has no safe default for server_env_cwd, since a wrong guess would silently point the server layer at the wrong shim location', async () => { + const r = registry; + const target = makeTmpDir(); + await expect( + r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888' }, + { cwd: target } + ) + ).rejects.toThrow(/server_env_cwd/); + }); + + it('copies the server-profile.php shim verbatim (raw: true, no mustache rendering)', async () => { + const r = registry; + const target = makeTmpDir(); + await r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888', server_env_cwd: '.' }, + { 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'); + }); +}); + describe('setup/pa11y rendered config', () => { it('JSON-escapes every URL input while preserving its value', async () => { const target = makeTmpDir(); diff --git a/node-packages/wp-tooling/tests/scaffolds/registry.test.js b/node-packages/wp-tooling/tests/scaffolds/registry.test.js index d0d0493..cc2b37a 100644 --- a/node-packages/wp-tooling/tests/scaffolds/registry.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/registry.test.js @@ -589,6 +589,77 @@ describe('execute() passes scripts through to developer block', () => { 'rtcamp/wp-php-toolkit': '^1', }); }); + + it('a bad script placeholder throws before any files are written', async () => { + const tmp = makeTmpDir(); + const sDir = path.join(tmp, 'lint'); + await fs.mkdir(sDir, { recursive: true }); + await fs.writeFile( + path.join(sDir, 'scaffold.json'), + JSON.stringify({ + slug: 'eslint', + category: 'lint', + name: 'ESLint', + description: 'ESLint', + source: 'template', + inputs: [], + files: [{ src: 'eslintrc.mustache', dest: '.eslintrc.js' }], + scripts: { + npm: { 'lint:js': '{{does_not_exist}}' }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(sDir, 'eslintrc.mustache'), + 'module.exports = {};', + 'utf8' + ); + const r = new ScaffoldRegistry({ projectDir: tmp }); + await r.scan(); + const targetDir = makeTmpDir(); + await expect( + r.execute('lint/eslint', {}, { cwd: targetDir }) + ).rejects.toMatchObject({ code: 'ERENDERFAIL' }); + expect(fssync.existsSync(path.join(targetDir, '.eslintrc.js'))).toBe( + false + ); + }); + + it('a supplied value used only in a script survives the no-inputs[] fallback', async () => { + const tmp = makeTmpDir(); + const sDir = path.join(tmp, 'lint'); + await fs.mkdir(sDir, { recursive: true }); + await fs.writeFile( + path.join(sDir, 'scaffold.json'), + JSON.stringify({ + slug: 'eslint', + category: 'lint', + name: 'ESLint', + description: 'ESLint', + source: 'template', + files: [{ src: 'eslintrc.mustache', dest: '.eslintrc.js' }], + scripts: { + npm: { greet: 'echo {{message}}' }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(sDir, 'eslintrc.mustache'), + 'module.exports = {};', + 'utf8' + ); + const r = new ScaffoldRegistry({ projectDir: tmp }); + await r.scan(); + const targetDir = makeTmpDir(); + const result = await r.execute( + 'lint/eslint', + { message: 'hello' }, + { cwd: targetDir } + ); + expect(result.developer.scripts.npm.greet).toBe('echo hello'); + }); }); describe('execute() never embeds secret values', () => { diff --git a/node-packages/wp-tooling/tests/scaffolds/render.test.js b/node-packages/wp-tooling/tests/scaffolds/render.test.js index 8b0588a..012b130 100644 --- a/node-packages/wp-tooling/tests/scaffolds/render.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/render.test.js @@ -195,6 +195,16 @@ describe('applyTransform', () => { ); }); + it('shell-escape leaves safe values bare and single-quotes the rest', () => { + expect(applyTransform('wp-content/plugins/x', 'shell-escape')).toBe( + 'wp-content/plugins/x' + ); + expect(applyTransform('.', 'shell-escape')).toBe('.'); + expect(applyTransform('my plugin', 'shell-escape')).toBe("'my plugin'"); + expect(applyTransform("it's", 'shell-escape')).toBe("'it'\\''s'"); + expect(applyTransform('', 'shell-escape')).toBe("''"); + }); + it('returns value unchanged when no transform', () => { expect(applyTransform('Foo', undefined)).toBe('Foo'); }); diff --git a/node-packages/wp-tooling/tests/scaffolds/validate.test.js b/node-packages/wp-tooling/tests/scaffolds/validate.test.js index e54aa9a..aa6b546 100644 --- a/node-packages/wp-tooling/tests/scaffolds/validate.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/validate.test.js @@ -409,6 +409,28 @@ describe('validateOne on-disk checks', () => { fssync.rmSync(dir, { recursive: true, force: true }); } }); + + test('scripts commands are render-checked alongside files/tests/wiring', () => { + const { dir, file } = makeOnDiskScaffold({ + slug: 'local', + category: 'wp', + name: 'Local', + description: 'fixture', + source: 'template', + scripts: { npm: { 'lint:js': 123 } }, + }); + try { + const result = validateOne(file); + expect(result.valid).toBe(false); + expect( + result.errors.some((e) => + /scripts\.npm\['lint:js'\] render failed/.test(e) + ) + ).toBe(true); + } finally { + fssync.rmSync(dir, { recursive: true, force: true }); + } + }); }); function makeTmpDir() { diff --git a/package-lock.json b/package-lock.json index d7fcd08..984ed5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "node-packages/*" ], "engines": { - "node": ">=22" + "node": ">=22.19" } }, "node_modules/@alloc/quick-lru": { @@ -12112,7 +12112,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "peerDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.7.1", @@ -12136,7 +12136,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "peerDependencies": { "@wordpress/stylelint-config": "^23.37.0", @@ -12153,7 +12153,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "peerDependencies": { "@tailwindcss/postcss": "^4.3.0", @@ -12174,7 +12174,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" } } } diff --git a/package.json b/package.json index e0f96d6..f3794f9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "author": "rtCamp", "private": true, "engines": { - "node": ">=22" + "node": ">=22.19" }, "workspaces": [ "node-packages/*"