diff --git a/.agents/skills/react-doctor b/.agents/skills/react-doctor new file mode 120000 index 000000000..97981d623 --- /dev/null +++ b/.agents/skills/react-doctor @@ -0,0 +1 @@ +../../skills/react-doctor \ No newline at end of file diff --git a/.agents/skills/react-doctor/SKILL.md b/.agents/skills/react-doctor/SKILL.md deleted file mode 100644 index 332a47f8b..000000000 --- a/.agents/skills/react-doctor/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: react-doctor -description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook. -version: "1.2.0" ---- - -# React Doctor - -Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. - -## After making React code changes: - -Run `npx react-doctor@latest --verbose --scope changed` and check the score did not regress. - -If the score dropped, fix the regressions before committing. - -## For general cleanup or code improvement: - -Run `npx react-doctor@latest --verbose` (the default `--scope full`) to scan the full codebase. Fix issues by severity — errors first, then warnings. - -## /doctor — full local triage workflow - -When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it: - -```bash -curl --fail --silent --show-error \ - --header 'Cache-Control: no-cache' \ - https://www.react.doctor/prompts/react-doctor-agent.md -``` - -The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed. - -Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe. - -## Configuring or explaining rules - -When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). - -## Command - -```bash -npx react-doctor@latest --verbose --scope changed -``` - -| Flag | Purpose | -| ----------------- | ---------------------------------------------------------------- | -| `.` | Scan current directory | -| `--verbose` | Show affected files and line numbers per rule | -| `--scope changed` | Only report issues introduced vs the base branch (default: full) | -| `--scope lines` | Only report issues on the changed lines | -| `--score` | Output only the numeric score | diff --git a/.agents/skills/react-doctor/references/explain.md b/.agents/skills/react-doctor/references/explain.md deleted file mode 100644 index 722c6f642..000000000 --- a/.agents/skills/react-doctor/references/explain.md +++ /dev/null @@ -1,72 +0,0 @@ -# Explaining and configuring rules - -Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user -wants to understand a rule or change which rules run — not for fixing diagnostics -(that is the main `react-doctor` skill / `/doctor`). - -Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off", -"stop flagging X", "too noisy", "disable design rules". - -## Workflow - -1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`). -2. Explain it before changing anything: - -```bash -npx react-doctor@latest rules explain react-doctor/no-array-index-as-key -``` - -3. Pick the narrowest control that matches the user's intent (see decision guide). -4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting). -5. Validate the change did what they wanted: - -```bash -npx react-doctor@latest --verbose --scope changed -``` - -## Commands - -```bash -npx react-doctor@latest rules list # every rule + its effective severity -npx react-doctor@latest rules list --configured # only what your config changed -npx react-doctor@latest rules list --category Performance # filter by category -npx react-doctor@latest rules explain # why it matters + how to configure -npx react-doctor@latest rules disable # rule never runs -npx react-doctor@latest rules enable # turn back on at its recommended severity -npx react-doctor@latest rules set warn # off | warn | error -npx react-doctor@latest rules category "React Native" off # whole category -npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …) -npx react-doctor@latest rules unignore-tag design -``` - -Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`). - -## Decision guide - -Match the control to the intent — prefer the narrowest one: - -- **User disagrees with one rule / it's a false positive for them** → `rules disable ` (sets `rules. = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule". -- **Rule is fine but wrong severity** → `rules set warn` or `rules set error`. -- **A disabled-by-default rule they want on** → `rules enable `. -- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "" off`. -- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag `. -- **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output. - -How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs. - -## Config shape - -Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`: - -```ts -// doctor.config.ts -export default { - rules: { "react-doctor/no-array-index-as-key": "off" }, - categories: { "React Native": "warn" }, - ignore: { tags: ["design"] }, -}; -``` - -## Educating the user - -When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules//.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues. diff --git a/.changeset/browser-eval-codegen.md b/.changeset/browser-eval-codegen.md new file mode 100644 index 000000000..ec68e24ed --- /dev/null +++ b/.changeset/browser-eval-codegen.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Add `--codegen` to `browser eval` (and `codegen: true` to the `browser_eval` MCP tool): drive a Playwright expression as usual, then write it as a runnable Playwright regression test. The generated spec navigates to the page the session is on, replays the action, and asserts no console or page errors fired — the same signal `eval` already reports — so a verified interaction becomes a guarded test in one step. Writes to `--out` (default `react-doctor.spec.ts`); a failing action throws instead of writing a green-looking test. diff --git a/.changeset/browser-eval-errors-geometry.md b/.changeset/browser-eval-errors-geometry.md new file mode 100644 index 000000000..f0fcd14d9 --- /dev/null +++ b/.changeset/browser-eval-errors-geometry.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Make `browser eval` and `browser eval --profile` self-reporting about what an action did to the page. A driven action that triggers a page-side error (a `console.error` or an uncaught throw) now appends an "Errors during eval" section instead of failing silently, so a broken interaction surfaces without hand-wiring a console hook — including when the action itself throws (a missing locator, a timeout), where those page errors are appended to the thrown message rather than dropped. `--profile` no longer throws the whole recording away when the action fails: it returns the captured picture (console, network, CPU, timeline, React) with the failure as an `evalError`, since that picture is the failure's context. `--profile` (and the `browser_profile` MCP tool) now reports page geometry alongside memory — viewport size, devicePixelRatio, scroll offset, and how far the page scrolled while the action ran — so "did the element move, or did the page scroll under me?" is answerable from the output. Page scroll delta only prints when the viewport actually moved. Console and network capture now starts after the page settles, so the recording covers the driven action's window rather than pre-action load traffic. diff --git a/.changeset/browser-eval-snapshot-ergonomics.md b/.changeset/browser-eval-snapshot-ergonomics.md new file mode 100644 index 000000000..595635cc8 --- /dev/null +++ b/.changeset/browser-eval-snapshot-ergonomics.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Make `browser eval` the one primitive for driving a page: when an expression just acts (returns nothing), it now hands back the resulting accessibility tree, so a single call both drives the page and shows the new state — no follow-up `snapshot`. Multi-statement source works without hand-wrapping it in an async IIFE, and a page-context `ReferenceError` (`window is not defined`) now explains that `eval` runs in Node with the Playwright `page` in scope and to reach page globals through `page.evaluate(() => …)`. The same applies to the `browser_eval` MCP tool. Locating stays pure Playwright — `browser snapshot`, or `page.locator(...).ariaSnapshot()` inside `eval` for a subtree. diff --git a/.changeset/browser-eval-video.md b/.changeset/browser-eval-video.md new file mode 100644 index 000000000..5e07b0589 --- /dev/null +++ b/.changeset/browser-eval-video.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Add `--video [path]` to `browser eval` (and `video: ".webm"` to the `browser_eval` MCP tool): record a `.webm` screen recording of the page while the expression runs, for playback. It works in any mode — plain `eval`, `--profile`, and `--codegen` — so a profiled run or a generated regression test can ship with a video you watch to verify what happened, and the saved path is reported in the summary (returned as `video` from the MCP tool). Uses Playwright's imperative screencast (1.59+), the only video API that records a CDP-attached page; encoding needs Playwright's bundled ffmpeg, so a missing one surfaces an actionable `npx playwright install ffmpeg` hint. Bumps the `playwright-core` floor to `^1.59.0` for the screencast API. diff --git a/.changeset/install-global-skill.md b/.changeset/install-global-skill.md new file mode 100644 index 000000000..586dc972e --- /dev/null +++ b/.changeset/install-global-skill.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Add `--global` to `react-doctor install` (and an interactive "Where should the skill be installed?" prompt): install the `/react-doctor` skill into each agent's home directory (`~/.cursor`, `~/.claude`, …) so it applies to every project, instead of only this repo's local agent dirs. The default stays project-local; `--global` opts in, and non-interactive runs (`--yes`) remain local unless `--global` is passed. diff --git a/.changeset/react-browser-debug-skill.md b/.changeset/react-browser-debug-skill.md new file mode 100644 index 000000000..baf2f2b92 --- /dev/null +++ b/.changeset/react-browser-debug-skill.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Add the `browser`, `debug`, and `mcp` commands behind the unified `/react-doctor` skill. `browser` drives a real Chrome over CDP (attaching to your running session, launching a dedicated persistent profile only as a fallback): `open` a page, `eval` a Playwright expression, `snapshot` the accessibility tree, and `screenshot`. Adding `--profile` to `eval` records the whole runtime picture in one pass while the expression runs — console, network, performance (long animation frames with per-script attribution, LCP, CLS, plus a DevTools timeline roll-up of forced style-recalc/layout/hit-test/paint cost), an axe-core accessibility audit, a React render profile (slowest commits, hottest components by self time, unnecessary re-render counts), and a Chrome DevTools CPU profile via V8's sampling profiler over CDP (the hottest JS functions ranked by self time). It also writes the raw DevTools timeline trace to a file (`--out`, default `react-doctor-trace.json`) that loads in the DevTools Performance panel. `debug` runs an NDJSON logging server the debug job posts runtime evidence to. `mcp` runs a Model Context Protocol server over stdio that exposes the doctor scan and the browser/debug jobs as MCP tools, so any MCP-capable agent can run `react-doctor mcp` and call `doctor_scan`, the `browser_*` tools (`browser_eval` takes a `profile: true` argument that captures every signal together), and the `debug_*` log server directly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36e6c8fcf..f87307bd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,11 +120,27 @@ jobs: pnpm build pnpm check:published-deps + # Pack the publishable tarballs ONCE on Linux — the OS the release and the + # pkg.pr.new preview actually build on — then verify the install here and + # hand the same artifact to the windows/macos `smoke-packed-cli-cross-os` + # job. A per-OS rebuild would test a bundle that never ships (Windows + # rollup intermittently externalizes a private workspace dep), so this + # tests the real artifact on every OS instead of a per-OS false negative. - name: Smoke test packed CLI install - if: ${{ matrix.node-version == '22.18.0' && (matrix.os == 'ubuntu-latest' || matrix.os == 'windows-latest') }} + if: ${{ matrix.os == 'ubuntu-latest' && matrix.node-version == '22.18.0' }} run: | pnpm build - pnpm smoke:packed-cli-install + pnpm smoke:packed-cli-install --pack-only "${{ runner.temp }}/packed-cli-tarballs" + pnpm smoke:packed-cli-install --tarballs "${{ runner.temp }}/packed-cli-tarballs" + + - name: Upload packed CLI tarballs for cross-OS smoke + if: ${{ matrix.os == 'ubuntu-latest' && matrix.node-version == '22.18.0' }} + uses: actions/upload-artifact@v5 + with: + name: packed-cli-tarballs + path: ${{ runner.temp }}/packed-cli-tarballs/*.tgz + retention-days: 1 + if-no-files-found: error # Allocates a real pseudo-terminal (`pty.openpty()`) so the CLI sees an # interactive TTY and renders the multiselect prompt, then asserts the @@ -133,3 +149,43 @@ jobs: - name: Smoke test interactive TTY prompt if: ${{ matrix.os == 'ubuntu-latest' && matrix.node-version == '22.18.0' }} run: pnpm smoke:tty-prompt + + # Install the Linux-packed tarballs (the exact artifact npm + pkg.pr.new ship) + # on Windows and macOS. The CLI bundle is platform-independent JS, so the only + # per-OS variable is install + native-binding resolution + runtime — which is + # what we want to test, without a per-OS rebuild's bundling drift. + smoke-packed-cli-cross-os: + needs: test + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v5 + + - uses: actions/setup-node@v5 + with: + node-version: "22.18.0" + cache: pnpm + + - run: pnpm install --frozen-lockfile --prefer-offline + + # The verify step validates the CLI's JSON output against + # @react-doctor/core/schemas, so only core needs building here; the CLI + # under test comes from the downloaded Linux-packed tarball, never a local + # build of the package whose Windows bundle is the thing we're avoiding. + - run: pnpm --filter @react-doctor/core build + + - uses: actions/download-artifact@v5 + with: + name: packed-cli-tarballs + path: ${{ runner.temp }}/packed-cli-tarballs + + - name: Smoke test packed CLI install + run: pnpm smoke:packed-cli-install --tarballs "${{ runner.temp }}/packed-cli-tarballs" diff --git a/.github/workflows/publish-any-commit.yml b/.github/workflows/publish-any-commit.yml index f31673c2b..4ac43a490 100644 --- a/.github/workflows/publish-any-commit.yml +++ b/.github/workflows/publish-any-commit.yml @@ -30,17 +30,31 @@ jobs: - run: pnpm install --frozen-lockfile --prefer-offline + # Bake the preview's own immutable pkg.pr.new URL into the build so the + # shipped skill's `npx` commands and `react-doctor install` reference this + # exact commit — a beta tester then exercises the previewed branch instead + # of silently falling back to the published `react-doctor@latest`. - run: pnpm build + env: + REACT_DOCTOR_PACKAGE_SPECIFIER: https://pkg.pr.new/react-doctor@${{ github.sha }} - name: Publish packages (retry on transient failures) run: | max_attempts=4 attempt=1 while [ "$attempt" -le "$max_attempts" ]; do + # deslop-js MUST be published too: react-doctor depends on it + # (`deslop-js: workspace:*`), and pkg-pr-new only rewrites a + # workspace dep to a preview URL when that package is in the publish + # set. Omitting it shipped a tarball with a raw `workspace:*` spec + # that `npx https://pkg.pr.new/react-doctor@ install` rejected + # with EUNSUPPORTEDPROTOCOL. (The private @react-doctor/* workspace + # devDependencies stay `workspace:*` but are ignored on install.) if pnpm dlx pkg-pr-new publish \ ./packages/react-doctor \ ./packages/oxlint-plugin-react-doctor \ - ./packages/eslint-plugin-react-doctor; then + ./packages/eslint-plugin-react-doctor \ + ./packages/deslop-js; then exit 0 fi diff --git a/.gitignore b/.gitignore index db97f6942..e575024ed 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,7 @@ review-*.md /.agents/* !/.agents/skills/ /.agents/skills/* -!/.agents/skills/react-doctor/ -!/.agents/skills/react-doctor/** +!/.agents/skills/react-doctor !/.agents/skills/rule-research/ !/.agents/skills/rule-research/** !/.agents/skills/rule-writing/ @@ -46,3 +45,8 @@ review-*.md /scripts/print-batch-input.mjs /scripts/rule-prompts/ /rules.json + +# Local-only artifacts written by `react-doctor browser` (timeline trace, +# screenshot) when run from the repo root during development. +/react-doctor-trace.json +/react-doctor-screenshot.png diff --git a/packages/browser/package.json b/packages/browser/package.json new file mode 100644 index 000000000..cb434562d --- /dev/null +++ b/packages/browser/package.json @@ -0,0 +1,32 @@ +{ + "name": "@react-doctor/browser", + "version": "0.5.4", + "private": true, + "description": "Internal: React Doctor's browser driver. Attaches to a running Chrome over CDP (or launches one) and keeps the page open across commands, backing the debug and design jobs.", + "license": "MIT", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && cross-env NODE_ENV=production vp pack", + "typecheck": "tsc --noEmit", + "test": "vp test run" + }, + "dependencies": { + "axe-core": "^4.10.2", + "playwright-core": "^1.59.0" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "esbuild": "^0.25.12", + "react-devtools-inline": "^6.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + } +} diff --git a/packages/browser/src/analyze-cpu-profile.ts b/packages/browser/src/analyze-cpu-profile.ts new file mode 100644 index 000000000..b61999abc --- /dev/null +++ b/packages/browser/src/analyze-cpu-profile.ts @@ -0,0 +1,97 @@ +import { MAX_PROFILE_FUNCTIONS } from "./constants.js"; +import type { CpuProfileAnalysis } from "./types.js"; +import { roundToHundredths } from "./utils/round.js"; + +interface CpuProfileCallFrame { + functionName: string; + url: string; + lineNumber: number; +} + +interface CpuProfileNode { + id: number; + callFrame: CpuProfileCallFrame; + hitCount?: number; +} + +// The shape of `Profiler.stop`'s `profile` (a structural subset of CDP's +// Protocol.Profiler.Profile), the same JSON DevTools writes to a `.cpuprofile`. +export interface CdpCpuProfile { + nodes: CpuProfileNode[]; + startTime: number; + endTime: number; + samples?: number[]; + timeDeltas?: number[]; +} + +// A function's display key: V8's synthetic frames ("(idle)", "(program)", +// "(garbage collector)", "(root)") have no url and are kept as-is so the +// percentages still add up to the wall time the profile covered. +const labelFor = (callFrame: CpuProfileCallFrame): { name: string; url: string | null } => { + const name = callFrame.functionName || "(anonymous)"; + const url = callFrame.url ? `${callFrame.url}:${callFrame.lineNumber + 1}` : null; + return { name, url }; +}; + +// Fold a CDP CPU profile into self-time-per-function: each sample attributes its +// paired time delta to the function on top of the stack at that sample. This +// approximates the self-time DevTools' bottom-up view shows — where JS wall time +// went (attribution can shift by up to one sample interval) — without the raw +// node tree. Totals still sum to the wall time the profile covered. +export const analyzeCpuProfile = (profile: CdpCpuProfile): CpuProfileAnalysis => { + const durationMs = (profile.endTime - profile.startTime) / 1000; + const samples = profile.samples ?? []; + const timeDeltas = profile.timeDeltas ?? []; + + const nodeById = new Map(); + for (const node of profile.nodes) nodeById.set(node.id, node); + + // Accumulate self time (microseconds) by function key, summing same-named + // frames so a function split across optimization tiers reads as one row. + interface SelfTimeAccumulator { + functionName: string; + url: string | null; + selfUs: number; + } + const accumulatorByKey = new Map(); + const addSelfTime = (node: CpuProfileNode | undefined, microseconds: number): void => { + if (!node) return; + const { name, url } = labelFor(node.callFrame); + const key = `${name}@${url ?? ""}`; + const existing = accumulatorByKey.get(key); + if (existing) { + existing.selfUs += microseconds; + return; + } + accumulatorByKey.set(key, { functionName: name, url, selfUs: microseconds }); + }; + + if (samples.length > 0 && timeDeltas.length === samples.length) { + for (let index = 0; index < samples.length; index += 1) { + addSelfTime(nodeById.get(samples[index]), timeDeltas[index]); + } + } else { + // No sample stream (rare): fall back to hitCount, scaling the node's share of + // total hits across the measured duration. + const totalHits = profile.nodes.reduce((sum, node) => sum + (node.hitCount ?? 0), 0) || 1; + const durationUs = durationMs * 1000; + for (const node of profile.nodes) { + addSelfTime(node, ((node.hitCount ?? 0) / totalHits) * durationUs); + } + } + + const topFunctions = [...accumulatorByKey.values()] + .map((accumulator) => { + const selfMs = accumulator.selfUs / 1000; + return { + functionName: accumulator.functionName, + url: accumulator.url, + selfMs: roundToHundredths(selfMs), + selfPercent: durationMs > 0 ? roundToHundredths((selfMs / durationMs) * 100) : 0, + }; + }) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, MAX_PROFILE_FUNCTIONS); + + return { durationMs: roundToHundredths(durationMs), sampleCount: samples.length, topFunctions }; +}; diff --git a/packages/browser/src/analyze-timeline-trace.ts b/packages/browser/src/analyze-timeline-trace.ts new file mode 100644 index 000000000..e43aa3c3c --- /dev/null +++ b/packages/browser/src/analyze-timeline-trace.ts @@ -0,0 +1,50 @@ +import type { TimelineAnalysis, TimelinePhaseStat } from "./types.js"; +import { roundToHundredths } from "./utils/round.js"; + +// CDP types trace events loosely (string maps), so we narrow `name`/`dur` +// ourselves. Complete events (`ph: "X"`) carry a microsecond `dur`; the rest of +// the event shape is written to the trace file verbatim but ignored here. +interface TraceEvent { + name?: unknown; + dur?: unknown; +} + +// Trace event names that represent a forced/scheduled reflow phase. `Layout` and +// `UpdateLayoutTree` (style recalc) are the cost of reading layout on a dirty +// page; `HitTest` is what `elementsFromPoint` triggers; `Paint` follows both. +const PHASE_BY_EVENT_NAME: Record = { + UpdateLayoutTree: "styleRecalc", + RecalculateStyles: "styleRecalc", + Layout: "layout", + HitTest: "hitTest", + Paint: "paint", +}; + +const emptyPhase = (): TimelinePhaseStat => ({ totalMs: 0, count: 0, longestMs: 0 }); + +// Roll a Chrome DevTools timeline trace up into per-phase wall time, so the +// native style/layout/hit-test cost a forced reflow incurs is a number in the +// perf report rather than something you can only see in the trace file. +export const analyzeTimelineTrace = (events: TraceEvent[]): TimelineAnalysis => { + const phases: TimelineAnalysis = { + styleRecalc: emptyPhase(), + layout: emptyPhase(), + hitTest: emptyPhase(), + paint: emptyPhase(), + }; + for (const event of events) { + if (typeof event.name !== "string" || typeof event.dur !== "number") continue; + const phaseKey = PHASE_BY_EVENT_NAME[event.name]; + if (!phaseKey) continue; + const durationMs = event.dur / 1000; + const phase = phases[phaseKey]; + phase.totalMs += durationMs; + phase.count += 1; + if (durationMs > phase.longestMs) phase.longestMs = durationMs; + } + for (const phase of Object.values(phases)) { + phase.totalMs = roundToHundredths(phase.totalMs); + phase.longestMs = roundToHundredths(phase.longestMs); + } + return phases; +}; diff --git a/packages/browser/src/browser-environment-error.ts b/packages/browser/src/browser-environment-error.ts new file mode 100644 index 000000000..e0cb3ae5b --- /dev/null +++ b/packages/browser/src/browser-environment-error.ts @@ -0,0 +1,16 @@ +// A browser failure caused by the machine's environment, not a react-doctor bug: +// no Google Chrome to launch, the optional `playwright-core` dependency not +// installed, or no debuggable Chrome to attach to. The CLI renders these as a +// plain, actionable message and keeps them out of crash reporting (Sentry + the +// error-rate metric) — see the CLI's `isExpectedUserError`. The message is the +// fix instruction; throw sites phrase it for the user. +export class BrowserEnvironmentError extends Error { + override readonly name = "BrowserEnvironmentError"; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export const isBrowserEnvironmentError = (error: unknown): error is BrowserEnvironmentError => + error instanceof BrowserEnvironmentError; diff --git a/packages/browser/src/close-launched-browser.ts b/packages/browser/src/close-launched-browser.ts new file mode 100644 index 000000000..4efcead72 --- /dev/null +++ b/packages/browser/src/close-launched-browser.ts @@ -0,0 +1,26 @@ +import { CONNECT_TIMEOUT_MS } from "./constants.js"; +import { clearLaunchedEndpoint } from "./utils/clear-launched-endpoint.js"; +import { loadPlaywright } from "./utils/load-playwright.js"; +import { readLaunchedEndpoint } from "./utils/read-launched-endpoint.js"; + +// Terminate the persistent Chrome we launched. `dispose()` only disconnects (the +// persistent model keeps the page alive across commands), so this is the one path +// that actually stops it — the cleanup a headless instance needs since there's no +// window to quit. It targets ONLY our recorded endpoint, never a browser the user +// started, so it can't kill their Chrome. Returns whether it closed anything. +export const closeLaunchedBrowser = async (): Promise => { + const endpoint = readLaunchedEndpoint(); + if (!endpoint) return false; + const { chromium } = await loadPlaywright(); + const browser = await chromium + .connectOverCDP(endpoint, { timeout: CONNECT_TIMEOUT_MS }) + .catch(() => null); + // Couldn't attach: the instance may just be briefly unreachable, so keep the + // endpoint rather than orphaning a still-running Chrome we've now forgotten. A + // genuinely dead endpoint is harmless — the next launch overwrites it. + if (!browser) return false; + const cdpSession = await browser.newBrowserCDPSession(); + await cdpSession.send("Browser.close").catch(() => {}); + clearLaunchedEndpoint(); + return true; +}; diff --git a/packages/browser/src/connect.ts b/packages/browser/src/connect.ts new file mode 100644 index 000000000..9a259c2b9 --- /dev/null +++ b/packages/browser/src/connect.ts @@ -0,0 +1,116 @@ +import type { Browser } from "playwright-core"; +import { BrowserEnvironmentError } from "./browser-environment-error.js"; +import { CONNECT_TIMEOUT_MS, DEFAULT_CDP_ENDPOINT } from "./constants.js"; +import { launchPersistentChrome } from "./launch.js"; +import type { BrowserConnectOptions } from "./types.js"; +import { cdpPortFromEndpoint } from "./utils/cdp-port.js"; +import { clearLaunchedEndpoint } from "./utils/clear-launched-endpoint.js"; +import { findAvailablePort } from "./utils/find-available-port.js"; +import { isLoopbackEndpoint } from "./utils/is-loopback-endpoint.js"; +import { isPortAvailable } from "./utils/is-port-available.js"; +import { killProcess } from "./utils/kill-process.js"; +import { loadPlaywright } from "./utils/load-playwright.js"; +import { readLaunchedEndpoint } from "./utils/read-launched-endpoint.js"; +import { writeLaunchedEndpoint } from "./utils/write-launched-endpoint.js"; + +export interface BrowserConnection { + browser: Browser; + launched: boolean; +} + +// The endpoint to launch our own Chrome on. The default port is often held by +// another app (some Chromium-based browsers squat on 9222), and reusing a port +// we just failed to attach to is what doomed the launch — so when it isn't free, +// pick one that is. An explicit --cdp is honored exactly: the user asked for that +// port, so a busy one should surface as a clear failure, not move silently. +const resolveLaunchEndpoint = async (endpoint: string): Promise => { + const port = Number(cdpPortFromEndpoint(endpoint)); + if (await isPortAvailable(port)) return endpoint; + const freePort = await findAvailablePort(); + const url = new URL(endpoint); + url.port = String(freePort); + return url.origin; +}; + +// Attach to a debuggable Chrome over CDP. If none is reachable on a local +// endpoint, launch our own persistent, reattachable instance and attach to +// that. We always end up attached over CDP — never holding a launched process +// handle — so the browser survives across commands, the model Chrome DevTools +// MCP uses to keep state. +export const connectToBrowser = async ( + options: BrowserConnectOptions = {}, +): Promise => { + const { chromium } = await loadPlaywright(); + + // Without an explicit --cdp, prefer the instance we previously launched (which + // may be on a non-default port) before the well-known default. + const launchedEndpoint = readLaunchedEndpoint(); + const preferredLaunchedEndpoint = + !options.cdpEndpoint && launchedEndpoint && launchedEndpoint !== DEFAULT_CDP_ENDPOINT + ? launchedEndpoint + : null; + // When we have our own launched instance, fall back to the well-known default + // ONLY if launching is disabled (attaching to whatever is already there is then + // the only option). With launching enabled we relaunch our own below instead: + // another Chromium app often squats on 9222, so a launched instance that is + // briefly unreachable (slow, mid-restart) must not be silently swapped for a + // foreign browser there — that orphans our profile and runs later commands + // against the wrong session. The default is also tried on a cold start (no + // launched endpoint yet), where it is the user's own running Chrome. + const shouldTryDefaultFallback = !preferredLaunchedEndpoint || options.launch === false; + const attachCandidates = options.cdpEndpoint + ? [options.cdpEndpoint] + : [ + ...(preferredLaunchedEndpoint ? [preferredLaunchedEndpoint] : []), + ...(shouldTryDefaultFallback ? [DEFAULT_CDP_ENDPOINT] : []), + ]; + + let lastAttachError: unknown; + for (const candidate of attachCandidates) { + try { + const browser = await chromium.connectOverCDP(candidate, { timeout: CONNECT_TIMEOUT_MS }); + // Adopted the default because the recorded launched endpoint didn't answer + // (only reachable here when launching is disabled): that instance is gone, + // so forget it — otherwise every later command pays the full attach timeout + // against a dead port first. + if (preferredLaunchedEndpoint && candidate !== preferredLaunchedEndpoint) { + clearLaunchedEndpoint(); + } + return { browser, launched: false }; + } catch (attachError) { + lastAttachError = attachError; + } + } + + const fallbackEndpoint = options.cdpEndpoint ?? DEFAULT_CDP_ENDPOINT; + // Only launch for a loopback endpoint — we can't spawn Chrome on a remote host. + if (options.launch === false || !isLoopbackEndpoint(fallbackEndpoint)) { + throw new BrowserEnvironmentError( + `Could not attach to Chrome at ${fallbackEndpoint}. Start Chrome with --remote-debugging-port=${cdpPortFromEndpoint(fallbackEndpoint)}, or allow launching a local browser.`, + { cause: lastAttachError }, + ); + } + + const launchEndpoint = options.cdpEndpoint + ? options.cdpEndpoint + : await resolveLaunchEndpoint(fallbackEndpoint); + const launched = await launchPersistentChrome(launchEndpoint, options.headless ?? true); + writeLaunchedEndpoint(launched.endpoint); + try { + return { + browser: await chromium.connectOverCDP(launched.endpoint, { timeout: CONNECT_TIMEOUT_MS }), + launched: true, + }; + } catch (launchedAttachError) { + // The debugger answered /json/version, yet the CDP handshake still failed + // (usually a Chrome/playwright-core mismatch). Terminate the instance we + // just spawned and forget its endpoint, so a retry doesn't attach-fail + // against it again and stack another orphan Chrome on a fresh port. + if (launched.pid !== undefined) killProcess(launched.pid); + clearLaunchedEndpoint(); + throw new BrowserEnvironmentError( + `Launched Chrome at ${launched.endpoint} but could not attach to it. Update Chrome (or playwright-core), or start Chrome yourself with --remote-debugging-port and pass --cdp.`, + { cause: launchedAttachError }, + ); + } +}; diff --git a/packages/browser/src/constants.ts b/packages/browser/src/constants.ts new file mode 100644 index 000000000..480df6117 --- /dev/null +++ b/packages/browser/src/constants.ts @@ -0,0 +1,106 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Default Chrome DevTools Protocol endpoint. A user opts their browser in by +// launching Chrome with `--remote-debugging-port=9222`; we attach to that. +export const DEFAULT_CDP_PORT = 9222; +export const DEFAULT_CDP_ENDPOINT = `http://127.0.0.1:${DEFAULT_CDP_PORT}`; + +// How long to wait for a CDP attach before falling back to launching Chrome. +export const CONNECT_TIMEOUT_MS = 5_000; + +export const NAVIGATION_TIMEOUT_MS = 30_000; + +// Upper bound on waiting for the page to settle (network quiet + fonts) before +// reading or screenshotting it. Best-effort: a page that never goes idle (long +// polling, analytics) hits this cap and we proceed anyway. +export const SETTLE_TIMEOUT_MS = 10_000; + +export const REACT_DOCTOR_CACHE_DIRECTORY = join(homedir(), ".cache", "react-doctor"); + +// Dedicated Chrome profile for the browser we launch ourselves. Mirrors how +// Chrome DevTools MCP keeps a persistent profile out of the user's real one, so +// our launched instance is reattachable across commands and never touches their +// main browsing data. (Chrome also refuses --remote-debugging-port on the +// default profile, so a dedicated dir is required regardless.) +export const LAUNCHED_CHROME_PROFILE_DIRECTORY = join( + REACT_DOCTOR_CACHE_DIRECTORY, + "chrome-profile", +); + +// Where we remember the endpoint of the Chrome we launched. The default port may +// be taken by another app, so the launch can land on a free port instead; the +// next command reads this to reattach to that same persistent instance before +// falling back to the well-known default. +export const LAUNCHED_CHROME_ENDPOINT_FILE = join( + REACT_DOCTOR_CACHE_DIRECTORY, + "launched-endpoint", +); + +export const LAUNCH_READY_TIMEOUT_MS = 20_000; +export const LAUNCH_POLL_INTERVAL_MS = 100; + +// After the page settles, keep watching for long animation frames this long so +// post-load jank (hydration, late effects) is captured, not just the load burst. +export const PERFORMANCE_OBSERVE_WINDOW_MS = 1_000; + +// Window property the perf recording-start timestamp is stashed under so the +// in-page observer can floor its entries to the current recording window. Lives +// on the document so a navigation during the driven action wipes it — the new +// document then keeps its full load vitals instead of filtering them all out. +export const PERFORMANCE_RECORDING_MARKER = "__REACT_DOCTOR_PERF_SINCE__"; + +// Failing element selectors kept per accessibility violation — enough to locate +// the problem without dumping every match on a busy page. +export const MAX_VIOLATION_TARGETS = 5; + +// Upper bound on an emulated viewport dimension, so a typo can't push an absurd +// device-metrics override into CDP. +export const MAX_VIEWPORT_PX = 10_000; + +// Caps on what a profile analysis returns inline, so a long recording stays a +// readable result rather than a dump keyed by thousands of fibers. The summary +// counts still reflect everything recorded. +export const MAX_PROFILE_COMPONENTS = 20; +export const MAX_PROFILE_COMMITS = 10; +export const MAX_COMMIT_COMPONENTS = 8; + +// V8 CPU profiler sampling interval, matching Chrome DevTools' default (100us). +export const DEFAULT_CPU_SAMPLING_INTERVAL_US = 100; + +// Trace categories for the timeline recording captured alongside the CPU profile. +// `-*` drops everything, then we opt into the DevTools timeline events +// (style/layout/hit-test/paint, with their triggering JS stacks) — but NOT +// `disabled-by-default-v8.cpu_profiler`, which would collide with the Profiler +// domain we already run for the CPU analysis. The result loads in the DevTools +// Performance panel and carries the forced-reflow events we roll up. +export const TIMELINE_TRACE_CATEGORIES = [ + "-*", + "devtools.timeline", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "disabled-by-default-devtools.timeline.stack", + "blink.user_timing", + "latencyInfo", + "loading", + "toplevel", +].join(","); + +// Default file the raw timeline trace is written to (in the working directory). +export const DEFAULT_TRACE_FILENAME = "react-doctor-trace.json"; + +// Default file `eval --codegen` writes the generated Playwright spec to. +export const DEFAULT_CODEGEN_FILENAME = "react-doctor.spec.ts"; + +// Default file `eval --video` writes the screen recording (.webm) to. +export const DEFAULT_VIDEO_FILENAME = "react-doctor.webm"; + +// Functions returned inline by a CPU profile analysis, ranked by self time. +export const MAX_PROFILE_FUNCTIONS = 20; + +// Built React-profiler init script, relative to the bundle that imports it. +// `react-profiler/inject.ts` is esbuilt into this self-contained IIFE at build +// time (see vite.config.ts); the session injects it via `addInitScript`. The +// path stays valid whether `dist/index.js` runs standalone or is re-bundled +// into the CLI, because the build copies the asset next to each output. +export const REACT_PROFILER_INJECT_FILE = "inject/react-profiler.global.js"; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts new file mode 100644 index 000000000..a8e27aff3 --- /dev/null +++ b/packages/browser/src/index.ts @@ -0,0 +1,13 @@ +export { BrowserSession } from "./session.js"; +export { BrowserEnvironmentError, isBrowserEnvironmentError } from "./browser-environment-error.js"; +export { connectToBrowser } from "./connect.js"; +export type { BrowserConnection } from "./connect.js"; +export { closeLaunchedBrowser } from "./close-launched-browser.js"; +export { parseViewport } from "./parse-viewport.js"; +export { formatEvalValue } from "./utils/format-eval-value.js"; +export { + DEFAULT_CODEGEN_FILENAME, + DEFAULT_TRACE_FILENAME, + DEFAULT_VIDEO_FILENAME, +} from "./constants.js"; +export type * from "./types.js"; diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts new file mode 100644 index 000000000..42861cc76 --- /dev/null +++ b/packages/browser/src/launch.ts @@ -0,0 +1,117 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { BrowserEnvironmentError } from "./browser-environment-error.js"; +import { + LAUNCH_POLL_INTERVAL_MS, + LAUNCH_READY_TIMEOUT_MS, + LAUNCHED_CHROME_PROFILE_DIRECTORY, +} from "./constants.js"; +import { cdpPortFromEndpoint } from "./utils/cdp-port.js"; +import { delay } from "./utils/delay.js"; + +const chromeExecutableCandidates = (): readonly string[] => { + switch (process.platform) { + case "darwin": + return [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ]; + case "win32": + return [ + `${process.env.PROGRAMFILES ?? "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env.LOCALAPPDATA ?? ""}\\Google\\Chrome\\Application\\chrome.exe`, + ]; + default: + return [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/opt/google/chrome/chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ]; + } +}; + +const resolveChromeExecutable = (): string => { + const candidates = [process.env.CHROME_PATH, ...chromeExecutableCandidates()]; + const executable = candidates.find( + (candidate): candidate is string => typeof candidate === "string" && existsSync(candidate), + ); + if (!executable) { + throw new BrowserEnvironmentError( + "Could not find Google Chrome to launch. Install Chrome, set CHROME_PATH, or start Chrome with --remote-debugging-port and pass --cdp to attach to it.", + ); + } + return executable; +}; + +// Chrome may bind the debug port on IPv4 or IPv6 depending on the host stack — +// notably it falls back to [::1] when 127.0.0.1 is already taken — so probe both +// loopback forms of the endpoint. +const loopbackVariants = (endpoint: string): readonly string[] => { + const variants = new Set([endpoint]); + const url = new URL(endpoint); + if (url.hostname === "127.0.0.1" || url.hostname === "localhost") { + url.hostname = "::1"; + variants.add(url.origin); + } + return [...variants]; +}; + +// Returns the loopback form that actually responded so the caller attaches to +// the right one. +const waitForCdpEndpoint = async (endpoint: string): Promise => { + const candidates = loopbackVariants(endpoint); + const deadline = Date.now() + LAUNCH_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + for (const candidate of candidates) { + try { + const response = await fetch(new URL("/json/version", candidate)); + if (response.ok) return candidate; + } catch {} + } + await delay(LAUNCH_POLL_INTERVAL_MS); + } + throw new BrowserEnvironmentError( + `Launched Chrome but it never exposed its debugger at ${endpoint}. Start Chrome yourself with --remote-debugging-port and pass --cdp, or set CHROME_PATH to a working Chrome.`, + ); +}; + +export interface LaunchedChrome { + // The loopback form that actually responded, to attach to. + endpoint: string; + // The spawned process, so the caller can terminate it if the CDP handshake + // still fails after the debugger came up — otherwise it leaks as an orphan. + pid: number | undefined; +} + +// Detached and unref'd on success so the browser outlives this process and the +// next `browser` command reattaches over CDP — the persistent model Chrome +// DevTools MCP uses to keep state across calls. Headless by default (an agent +// rarely needs the window); `headless: false` shows it for a human to watch. +export const launchPersistentChrome = async ( + endpoint: string, + headless: boolean, +): Promise => { + const executable = resolveChromeExecutable(); + const args = [ + `--remote-debugging-port=${cdpPortFromEndpoint(endpoint)}`, + `--user-data-dir=${LAUNCHED_CHROME_PROFILE_DIRECTORY}`, + "--no-first-run", + "--no-default-browser-check", + ...(headless ? ["--headless=new"] : []), + ]; + + const child = spawn(executable, args, { detached: true, stdio: "ignore" }); + // HACK: swallow async spawn errors (e.g. a non-executable binary) so they + // don't crash the CLI as an uncaught exception; waitForCdpEndpoint surfaces + // the failure as an actionable timeout instead. + child.on("error", () => {}); + const reachableEndpoint = await waitForCdpEndpoint(endpoint).catch((error: unknown) => { + child.kill(); + throw error; + }); + child.unref(); + return { endpoint: reachableEndpoint, pid: child.pid }; +}; diff --git a/packages/browser/src/parse-viewport.ts b/packages/browser/src/parse-viewport.ts new file mode 100644 index 000000000..93ca6d4c9 --- /dev/null +++ b/packages/browser/src/parse-viewport.ts @@ -0,0 +1,19 @@ +import { MAX_VIEWPORT_PX } from "./constants.js"; +import type { Viewport } from "./types.js"; + +// Parse a `WIDTHxHEIGHT` string (e.g. 390x844) into a viewport, throwing a +// readable Error on a malformed or out-of-range value. Pure (no playwright), so +// both the CLI's `--viewport` parser and the MCP tool reuse it without dragging +// the browser engine into their bundles. +export const parseViewport = (value: string): Viewport => { + const match = /^(\d+)x(\d+)$/i.exec(value.trim()); + const width = match ? Number(match[1]) : 0; + const height = match ? Number(match[2]) : 0; + if (!match || width === 0 || height === 0) { + throw new Error(`Use WIDTHxHEIGHT in pixels, e.g. 390x844 (got "${value}").`); + } + if (width > MAX_VIEWPORT_PX || height > MAX_VIEWPORT_PX) { + throw new Error(`Viewport dimensions must be at most ${MAX_VIEWPORT_PX}px.`); + } + return { width, height }; +}; diff --git a/packages/browser/src/perf-observer.ts b/packages/browser/src/perf-observer.ts new file mode 100644 index 000000000..f17a39726 --- /dev/null +++ b/packages/browser/src/perf-observer.ts @@ -0,0 +1,107 @@ +import type { PageVitals } from "./types.js"; + +// Runs in the page (via evaluate) and resolves after `windowMs`. Installs fresh +// LoAF / LCP / CLS observers with `buffered: true`, so frames already in the +// timeline when the observer attaches (an interaction the caller drove just +// before measuring) are replayed immediately, while the window catches anything +// that fires next. The recording-start floor is read in-page from `markerKey` +// (the `performance.now()` the caller stashed when the recorders armed): every +// entry at or below it is skipped, so the report only counts this window's +// frames — never initial page-load jank still in the buffer, never frames an +// earlier no-reload run already reported. A navigation during the driven action +// wipes the marker with the old document, so the new document reads 0 and keeps +// its full load vitals — the navigation is itself the measured event. LoAF +// fields are not in lib.dom, so the casts here are unavoidable. +export const collectPerformanceReport = (options: { + windowMs: number; + markerKey: string; +}): Promise => { + const { windowMs, markerKey } = options; + const markerValue = Reflect.get(globalThis, markerKey); + const sinceMs = typeof markerValue === "number" ? markerValue : 0; + interface ScriptTiming { + sourceURL?: string; + sourceFunctionName?: string; + invokerType?: string; + duration?: number; + forcedStyleAndLayoutDuration?: number; + } + interface LongAnimationFrameEntry { + startTime: number; + duration: number; + blockingDuration?: number; + scripts?: ScriptTiming[]; + } + interface LayoutShiftEntry { + value: number; + hadRecentInput: boolean; + } + interface MutableReport { + longAnimationFrames: PageVitals["longAnimationFrames"]; + largestContentfulPaintMs: number | null; + cumulativeLayoutShift: number; + } + + return new Promise((resolve) => { + const report: MutableReport = { + longAnimationFrames: [], + largestContentfulPaintMs: null, + cumulativeLayoutShift: 0, + }; + + const observers: PerformanceObserver[] = []; + const observe = (type: string, onEntry: (entry: PerformanceEntry) => void): void => { + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) onEntry(entry); + }); + observer.observe({ type, buffered: true }); + observers.push(observer); + } catch {} + }; + + observe("long-animation-frame", (entry) => { + if (entry.startTime <= sinceMs) return; + const longAnimationFrame = entry as unknown as LongAnimationFrameEntry; + report.longAnimationFrames.push({ + startTimeMs: Math.round(longAnimationFrame.startTime), + durationMs: Math.round(longAnimationFrame.duration), + blockingDurationMs: Math.round(longAnimationFrame.blockingDuration ?? 0), + scripts: (longAnimationFrame.scripts ?? []).map((scriptTiming) => ({ + sourceUrl: scriptTiming.sourceURL ?? "", + sourceFunctionName: scriptTiming.sourceFunctionName ?? "", + invokerType: scriptTiming.invokerType ?? "", + durationMs: Math.round(scriptTiming.duration ?? 0), + forcedStyleAndLayoutMs: Math.round(scriptTiming.forcedStyleAndLayoutDuration ?? 0), + })), + }); + }); + + observe("largest-contentful-paint", (entry) => { + if (entry.startTime <= sinceMs) return; + report.largestContentfulPaintMs = Math.round(entry.startTime); + }); + + observe("layout-shift", (entry) => { + if (entry.startTime <= sinceMs) return; + const layoutShift = entry as unknown as LayoutShiftEntry; + if (!layoutShift.hadRecentInput) report.cumulativeLayoutShift += layoutShift.value; + }); + + setTimeout(() => { + for (const observer of observers) observer.disconnect(); + resolve({ + // Blocking duration — not total duration — is the jank signal: a long + // frame that blocks nothing (an idle/backgrounded render, the first + // frame after navigation) isn't main-thread jank, and ranking by total + // duration buries the frames that actually stalled input behind those + // artifacts. Drop the non-blocking frames and rank by what blocked. + longAnimationFrames: report.longAnimationFrames + .filter((frame) => frame.blockingDurationMs > 0) + .sort((left, right) => right.blockingDurationMs - left.blockingDurationMs), + largestContentfulPaintMs: report.largestContentfulPaintMs, + cumulativeLayoutShift: Math.round(report.cumulativeLayoutShift * 1000) / 1000, + }); + }, windowMs); + }); +}; diff --git a/packages/browser/src/react-profiler/analyze-profile.ts b/packages/browser/src/react-profiler/analyze-profile.ts new file mode 100644 index 000000000..894b34379 --- /dev/null +++ b/packages/browser/src/react-profiler/analyze-profile.ts @@ -0,0 +1,113 @@ +import { + MAX_COMMIT_COMPONENTS, + MAX_PROFILE_COMMITS, + MAX_PROFILE_COMPONENTS, +} from "../constants.js"; +import type { + ReactComponentRenderStat, + ReactProfileAnalysis, + ReactProfileCommitStat, +} from "../types.js"; +import { roundToHundredths } from "../utils/round.js"; +import type { + ReactProfilerChangeDescription, + ReactProfilerDataExport, +} from "./types/profiling-export.js"; + +// A render is "wasted" when the component re-rendered without anything it owns +// changing — not its first mount, no hook/state/props/context change — i.e. it +// rendered only because a parent did. These are the memo / useCallback targets. +const isUnnecessaryRender = (change: ReactProfilerChangeDescription): boolean => { + if (change.isFirstMount || change.didHooksChange) return false; + const changedContext = Array.isArray(change.context) + ? change.context.length > 0 + : Boolean(change.context); + const changedProps = change.props?.length ?? 0; + const changedState = change.state?.length ?? 0; + return changedProps === 0 && changedState === 0 && !changedContext; +}; + +// Fold the DevTools profiling export — per-root commits keyed by fiber id — into +// a component-level summary an agent can act on: which components render most and +// cost the most self-time, which commits were the slowest, and how many renders +// were wasted (re-rendered with nothing they own changed). +export const analyzeReactProfile = (data: ReactProfilerDataExport): ReactProfileAnalysis => { + const componentStats = new Map(); + const commitStats: ReactProfileCommitStat[] = []; + let totalCommitDurationMs = 0; + let unnecessaryRenderCount = 0; + let commitIndex = 0; + + const statFor = (name: string): ReactComponentRenderStat => { + const existing = componentStats.get(name); + if (existing) return existing; + const created: ReactComponentRenderStat = { + name, + renderCount: 0, + totalSelfMs: 0, + totalActualMs: 0, + maxSelfMs: 0, + unnecessaryRenderCount: 0, + }; + componentStats.set(name, created); + return created; + }; + + for (const root of data.dataForRoots) { + const nameByFiber = new Map(root.elementNames); + const nameFor = (fiberId: number): string => nameByFiber.get(fiberId) ?? `#${fiberId}`; + + for (const commit of root.commitData) { + totalCommitDurationMs += commit.duration; + const actualByFiber = new Map(commit.fiberActualDurations); + const changeByFiber = new Map(commit.changeDescriptions ?? []); + const selfByName = new Map(); + + for (const [fiberId, selfMs] of commit.fiberSelfDurations) { + const name = nameFor(fiberId); + const stat = statFor(name); + stat.renderCount += 1; + stat.totalSelfMs += selfMs; + stat.totalActualMs += actualByFiber.get(fiberId) ?? 0; + stat.maxSelfMs = Math.max(stat.maxSelfMs, selfMs); + selfByName.set(name, (selfByName.get(name) ?? 0) + selfMs); + + const change = changeByFiber.get(fiberId); + if (change && isUnnecessaryRender(change)) { + stat.unnecessaryRenderCount += 1; + unnecessaryRenderCount += 1; + } + } + + const components = [...selfByName.entries()] + .sort(([, a], [, b]) => b - a) + .slice(0, MAX_COMMIT_COMPONENTS) + .map(([name]) => name); + commitStats.push({ commitIndex, durationMs: roundToHundredths(commit.duration), components }); + commitIndex += 1; + } + } + + const topComponents = [...componentStats.values()] + .sort((a, b) => b.totalSelfMs - a.totalSelfMs) + .slice(0, MAX_PROFILE_COMPONENTS) + .map((stat) => ({ + ...stat, + totalSelfMs: roundToHundredths(stat.totalSelfMs), + totalActualMs: roundToHundredths(stat.totalActualMs), + maxSelfMs: roundToHundredths(stat.maxSelfMs), + })); + + const slowestCommits = [...commitStats] + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, MAX_PROFILE_COMMITS); + + return { + rootCount: data.dataForRoots.length, + commitCount: commitIndex, + totalCommitDurationMs: roundToHundredths(totalCommitDurationMs), + unnecessaryRenderCount, + topComponents, + slowestCommits, + }; +}; diff --git a/packages/browser/src/react-profiler/devtools/collect-profiling-export.ts b/packages/browser/src/react-profiler/devtools/collect-profiling-export.ts new file mode 100644 index 000000000..84debf5e7 --- /dev/null +++ b/packages/browser/src/react-profiler/devtools/collect-profiling-export.ts @@ -0,0 +1,63 @@ +import type { + ReactProfilerDataExport, + ReactProfilerDataForRootExport, + ReactProfilerRootDataBackend, +} from "../types/profiling-export.js"; +import type { DevtoolsGlobal, ReactRendererInterface } from "../types/react-devtools.js"; + +export const PROFILING_EXPORT_VERSION = 5; + +const collectFiberIds = (root: ReactProfilerRootDataBackend): Set => { + const fiberIds = new Set(); + for (const [fiberId] of root.initialTreeBaseDurations) fiberIds.add(fiberId); + for (const commit of root.commitData) { + for (const [fiberId] of commit.fiberActualDurations) fiberIds.add(fiberId); + for (const [fiberId] of commit.fiberSelfDurations) fiberIds.add(fiberId); + for (const [fiberId] of commit.changeDescriptions ?? []) fiberIds.add(fiberId); + } + return fiberIds; +}; + +// Skipped when the renderer can't resolve names (older React), leaving raw ids. +const resolveElementNames = ( + renderer: ReactRendererInterface, + root: ReactProfilerRootDataBackend, +): Array<[number, string]> => { + const resolve = renderer.getDisplayNameForElementID; + if (!resolve) return []; + const elementNames: Array<[number, string]> = []; + for (const fiberId of collectFiberIds(root)) { + const name = resolve(fiberId); + if (name) elementNames.push([fiberId, name]); + } + return elementNames; +}; + +// Returns null when no renderer is attached or no commits were recorded (e.g. a +// production React build), so `stop()` resolves with null rather than an empty +// object. +export const collectProfilingExport = (target: DevtoolsGlobal): ReactProfilerDataExport | null => { + const renderers = target.__REACT_DEVTOOLS_GLOBAL_HOOK__?.rendererInterfaces; + if (!renderers || renderers.size === 0) return null; + + const dataForRoots: Array = []; + for (const renderer of renderers.values()) { + renderer.stopProfiling(); + // A renderer that attached after `start()` (lazy/code-split root) was never + // profiled, and `getProfilingData` throws for it — skip it rather than lose + // every other renderer's data. + let roots: ReactProfilerRootDataBackend[]; + try { + roots = renderer.getProfilingData().dataForRoots; + } catch { + continue; + } + for (const root of roots) { + dataForRoots.push({ ...root, elementNames: resolveElementNames(renderer, root) }); + } + } + + const hasCommits = dataForRoots.some((root) => root.commitData.length > 0); + if (!hasCommits) return null; + return { version: PROFILING_EXPORT_VERSION, dataForRoots }; +}; diff --git a/packages/browser/src/react-profiler/devtools/install-backend.ts b/packages/browser/src/react-profiler/devtools/install-backend.ts new file mode 100644 index 000000000..9b0cde05e --- /dev/null +++ b/packages/browser/src/react-profiler/devtools/install-backend.ts @@ -0,0 +1,14 @@ +import { initialize as initializeBackend } from "react-devtools-inline/backend"; +import type { DevtoolsGlobal } from "../types/react-devtools.js"; + +// Track per target, not a single boolean, so a second global (iframe/worker) +// still gets its own hook. +const installedTargets = new WeakSet(); + +// MUST run before React loads, otherwise React never connects to the hook and +// no commits are recorded. +export const installReactDevtoolsBackend = (target: DevtoolsGlobal = globalThis): void => { + if (installedTargets.has(target)) return; + initializeBackend(target); + installedTargets.add(target); +}; diff --git a/packages/browser/src/react-profiler/harness.ts b/packages/browser/src/react-profiler/harness.ts new file mode 100644 index 000000000..0c356fb70 --- /dev/null +++ b/packages/browser/src/react-profiler/harness.ts @@ -0,0 +1,30 @@ +import { collectProfilingExport } from "./devtools/collect-profiling-export.js"; +import { installReactDevtoolsBackend } from "./devtools/install-backend.js"; +import type { ReactProfilerDataExport } from "./types/profiling-export.js"; +import type { DevtoolsGlobal } from "./types/react-devtools.js"; + +export interface ReactPerfHarness { + start: () => void; + stop: () => Promise; +} + +declare global { + // eslint-disable-next-line no-var, vars-on-top + var __REACT_PERF__: ReactPerfHarness | undefined; +} + +export const createReactPerfHarness = (target: DevtoolsGlobal = globalThis): ReactPerfHarness => { + installReactDevtoolsBackend(target); + + const harness: ReactPerfHarness = { + start: () => { + const renderers = target.__REACT_DEVTOOLS_GLOBAL_HOOK__?.rendererInterfaces; + if (!renderers) return; + for (const renderer of renderers.values()) renderer.startProfiling(true); + }, + stop: () => Promise.resolve(collectProfilingExport(target)), + }; + + target.__REACT_PERF__ = harness; + return harness; +}; diff --git a/packages/browser/src/react-profiler/inject.ts b/packages/browser/src/react-profiler/inject.ts new file mode 100644 index 000000000..e60aa2e5c --- /dev/null +++ b/packages/browser/src/react-profiler/inject.ts @@ -0,0 +1,6 @@ +import { createReactPerfHarness } from "./harness.js"; + +// esbuilt into the IIFE the session injects via `addInitScript`, so it runs at +// document-start — the only moment installing the DevTools hook lets React +// attach to it. +createReactPerfHarness(); diff --git a/packages/browser/src/react-profiler/types/devtools-inline-backend.d.ts b/packages/browser/src/react-profiler/types/devtools-inline-backend.d.ts new file mode 100644 index 000000000..534c5a520 --- /dev/null +++ b/packages/browser/src/react-profiler/types/devtools-inline-backend.d.ts @@ -0,0 +1,5 @@ +// Types for `react-devtools-inline/backend`, which ships Flow source with no +// TypeScript types. Wired in via tsconfig `paths`. We only use `initialize`, +// which installs the DevTools hook; the renderer attaches itself to the hook +// when React loads, and we drive that renderer interface directly. +export const initialize: (windowOrGlobal: unknown) => void; diff --git a/packages/browser/src/react-profiler/types/profiling-export.ts b/packages/browser/src/react-profiler/types/profiling-export.ts new file mode 100644 index 000000000..090a22ccb --- /dev/null +++ b/packages/browser/src/react-profiler/types/profiling-export.ts @@ -0,0 +1,49 @@ +import type { PROFILING_EXPORT_VERSION } from "../devtools/collect-profiling-export.js"; + +export interface ReactProfilerChangeDescription { + context: Array | boolean | null; + didHooksChange: boolean; + isFirstMount: boolean; + props: Array | null; + state: Array | null; + hooks?: Array | null; +} + +export interface ReactProfilerSerializedElement { + displayName: string | null; + id: number; + key: number | string | null; + type: number; +} + +export interface ReactProfilerCommitDataExport { + changeDescriptions: Array<[number, ReactProfilerChangeDescription]> | null; + duration: number; + effectDuration: number | null; + fiberActualDurations: Array<[number, number]>; + fiberSelfDurations: Array<[number, number]>; + passiveEffectDuration: number | null; + priorityLevel: string | null; + timestamp: number; + updaters: Array | null; +} + +// One profiled root, exactly as the renderer's `getProfilingData` returns it. +export interface ReactProfilerRootDataBackend { + rootID: number; + displayName: string; + commitData: Array; + initialTreeBaseDurations: Array<[number, number]>; +} + +// The renderer's per-root data plus `elementNames`, the `fiberID → component +// name` map an agent needs to read the durations and change descriptions +// (which key everything by fiber id). +export interface ReactProfilerDataForRootExport extends ReactProfilerRootDataBackend { + elementNames: Array<[number, string]>; +} + +export interface ReactProfilerDataExport { + version: typeof PROFILING_EXPORT_VERSION; + dataForRoots: Array; +} diff --git a/packages/browser/src/react-profiler/types/react-devtools.ts b/packages/browser/src/react-profiler/types/react-devtools.ts new file mode 100644 index 000000000..c0f7958b3 --- /dev/null +++ b/packages/browser/src/react-profiler/types/react-devtools.ts @@ -0,0 +1,22 @@ +import type { ReactProfilerRootDataBackend } from "./profiling-export.js"; + +export type DevtoolsGlobal = typeof globalThis; + +// The subset of React DevTools' RendererInterface we drive directly, bypassing +// the frontend Store/bridge/wall. `getDisplayNameForElementID` is absent on +// older React. +export interface ReactRendererInterface { + startProfiling: (recordChangeDescriptions: boolean) => void; + stopProfiling: () => void; + getProfilingData: () => { dataForRoots: Array }; + getDisplayNameForElementID?: (id: number) => string | null; +} + +export interface ReactDevtoolsHook { + rendererInterfaces?: Map; +} + +declare global { + // eslint-disable-next-line no-var, vars-on-top + var __REACT_DEVTOOLS_GLOBAL_HOOK__: ReactDevtoolsHook | undefined; +} diff --git a/packages/browser/src/session.ts b/packages/browser/src/session.ts new file mode 100644 index 000000000..3d1c3e161 --- /dev/null +++ b/packages/browser/src/session.ts @@ -0,0 +1,624 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Browser, CDPSession, ConsoleMessage, Page, Request, Response } from "playwright-core"; +import { BrowserEnvironmentError } from "./browser-environment-error.js"; +import { connectToBrowser, type BrowserConnection } from "./connect.js"; +import { analyzeCpuProfile, type CdpCpuProfile } from "./analyze-cpu-profile.js"; +import { analyzeTimelineTrace } from "./analyze-timeline-trace.js"; +import { + DEFAULT_CPU_SAMPLING_INTERVAL_US, + MAX_VIOLATION_TARGETS, + NAVIGATION_TIMEOUT_MS, + PERFORMANCE_OBSERVE_WINDOW_MS, + PERFORMANCE_RECORDING_MARKER, + REACT_PROFILER_INJECT_FILE, + SETTLE_TIMEOUT_MS, + TIMELINE_TRACE_CATEGORIES, +} from "./constants.js"; +import { collectPerformanceReport } from "./perf-observer.js"; +import { appendEvalErrors } from "./utils/append-eval-errors.js"; +import { compileEval } from "./utils/compile-eval.js"; +import { enrichEvalError } from "./utils/enrich-eval-error.js"; +import { formatEvalValue } from "./utils/format-eval-value.js"; +import { generatePlaywrightTest } from "./utils/generate-playwright-test.js"; +import { writeTraceFile } from "./utils/write-trace-file.js"; +import { analyzeReactProfile } from "./react-profiler/analyze-profile.js"; +import type { ReactProfilerDataExport } from "./react-profiler/types/profiling-export.js"; +import type { + AccessibilityViolation, + BrowserConnectOptions, + ConsoleMessageEntry, + CpuProfileAnalysis, + InspectOptions, + MemoryStats, + NetworkRequestEntry, + PageGeometry, + PageInspection, + PageVitals, + Viewport, +} from "./types.js"; + +const emptyVitals = (): PageVitals => ({ + longAnimationFrames: [], + largestContentfulPaintMs: null, + cumulativeLayoutShift: 0, +}); + +// Used only when `Profiler.stop` fails (the recording is otherwise still useful): +// the rest of the inspection — console, network, React, axe — shouldn't be lost +// over a CPU-profile hiccup, so the CPU lens degrades to empty instead of throwing. +const emptyCpuAnalysis = (): CpuProfileAnalysis => ({ + durationMs: 0, + sampleCount: 0, + topFunctions: [], +}); + +// When the CDP Performance domain is unavailable (older Chrome, a failed +// enable): a zeroed snapshot degrades the memory lens without losing the rest. +const emptyMemory = (): MemoryStats => ({ + jsHeapUsedBytes: 0, + jsHeapTotalBytes: 0, + domNodes: 0, + jsEventListeners: 0, + documents: 0, + frames: 0, +}); + +// A Chrome DevTools trace event as it streams over CDP (loosely typed there as a +// string map). The full record — every field — is written to the trace file; the +// roll-up only reads `name`/`dur`, which it narrows itself. +interface TraceEventRecord { + [key: string]: unknown; +} + +const resolveActivePage = async (browser: Browser): Promise => { + for (const context of browser.contexts()) { + const [firstPage] = context.pages(); + if (firstPage) return firstPage; + } + const context = browser.contexts()[0] ?? (await browser.newContext()); + return context.newPage(); +}; + +// A live handle to the attached page. The page state lives in the browser, so a +// session is cheap to create per command and there is no server to keep alive. +export class BrowserSession { + // Held so the device-metrics override is cleared on dispose rather than + // relying on the override happening to reset when the CDP client disconnects + // — otherwise an emulated viewport could linger on the persistent Chrome. + private viewportOverride: CDPSession | null = null; + + private constructor( + private readonly connection: BrowserConnection, + readonly page: Page, + ) {} + + static async attach(options: BrowserConnectOptions = {}): Promise { + const connection = await connectToBrowser(options); + const page = await resolveActivePage(connection.browser); + return new BrowserSession(connection, page); + } + + get launched(): boolean { + return this.connection.launched; + } + + async open(url: string): Promise { + await this.navigate(url); + } + + // Open `url` with the React DevTools profiler wired in. The init script has to + // run before the page's React loads (the only moment the hook can attach), so + // register it, drive this one load, then remove the registration. Leaving it + // registered would linger in the persistent Chrome we only attached to: + // stacking another copy on every `browser open` (each a separate script that + // re-installs the backend) and re-running on a later command's navigation. The + // page it just loaded keeps `window.__REACT_PERF__` for subsequent `eval`s. + async openWithReactProfiler(url: string): Promise { + const injectUrl = new URL(REACT_PROFILER_INJECT_FILE, import.meta.url); + const source = await readFile(injectUrl, "utf8").catch(() => null); + if (source === null) { + throw new Error( + `React profiler init script missing at ${fileURLToPath(injectUrl)}; rebuild @react-doctor/browser.`, + ); + } + const cdpSession = await this.page.context().newCDPSession(this.page); + await cdpSession.send("Page.enable"); + const { identifier } = await cdpSession.send("Page.addScriptToEvaluateOnNewDocument", { + source, + }); + try { + await this.navigate(url); + } finally { + await cdpSession + .send("Page.removeScriptToEvaluateOnNewDocument", { identifier }) + .catch(() => {}); + await cdpSession.detach().catch(() => {}); + } + } + + private async navigate(url?: string): Promise { + const options = { timeout: NAVIGATION_TIMEOUT_MS, waitUntil: "domcontentloaded" } as const; + await (url ? this.page.goto(url, options) : this.page.reload(options)); + await this.settle(); + } + + // A CDP device-metrics override, not page.setViewportSize, so it works on a + // page we only attached to — it never resizes the user's real window. The + // session is kept and cleared in dispose() so the override doesn't linger. + async setViewport(viewport: Viewport): Promise { + const cdpSession = await this.page.context().newCDPSession(this.page); + await cdpSession.send("Emulation.setDeviceMetricsOverride", { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: 1, + mobile: false, + }); + this.viewportOverride = cdpSession; + } + + // The source runs here in Node with the Playwright `page` (the whole driver + // API) in scope, not in the page — so an agent locates and acts with + // Playwright's own selectors: `page.getByRole("button", { name: "Open" }) + // .click()`. A bare expression returns its value; multi-statement source works + // too (see `compileEval`). Page globals (`window`, `document`, …) live in the + // page, so reach them via `page.evaluate(...)`. + async evaluate(expression: string): Promise { + try { + return await compileEval(expression)(this.page); + } catch (error) { + throw enrichEvalError(error); + } + } + + // The driving path the CLI and MCP use: run the source, and when it was a pure + // action (returned nothing) hand back the resulting accessibility tree so one + // call both acts and shows the new page state — no follow-up `snapshot`. An + // expression that returns a value yields that value instead. Page-side errors + // the action triggered (console.error, an uncaught throw) are appended so a + // silent failure can't slip past without the agent hand-wiring a console hook. + async evaluateOrSnapshot(expression: string): Promise { + const consoleEntries: ConsoleMessageEntry[] = []; + const detach = this.collectConsole(consoleEntries); + try { + const result = await this.evaluate(expression); + const output = result === undefined ? await this.snapshot() : formatEvalValue(result); + await this.drainPageEvents(); + return appendEvalErrors(output, consoleEntries); + } catch (error) { + // A throwing action (a missing locator, a timeout) is exactly when the page + // usually logged the real cause (a React error boundary, a failed fetch), so + // append those page errors to the thrown message instead of dropping them. + await this.drainPageEvents(); + if (error instanceof Error) error.message = appendEvalErrors(error.message, consoleEntries); + throw error; + } finally { + detach(); + } + } + + // HACK: one event-loop turn lets page-side console/pageerror events queued + // during an action drain (CDP delivers them async) before we read them. + private drainPageEvents(): Promise { + return new Promise((resolveEvents) => setTimeout(resolveEvents, 0)); + } + + // Record a .webm of the page while `action` runs, returning its absolute path + // alongside the result. Uses Playwright's imperative screencast (1.59+) — the + // only video API that works on a CDP-attached page, since the declarative + // `recordVideo` context option can't record a context we merely connected to. + // Stops in a finally so the file is flushed even when the action throws (you + // still get the footage of the failing run). Encoding needs Playwright's + // bundled ffmpeg; a missing one surfaces as an actionable environment error. + async withVideo( + videoPath: string, + action: () => Promise, + ): Promise<{ result: T; video: string }> { + const video = resolve(videoPath); + try { + await this.page.screencast.start({ path: video }); + } catch (error) { + throw new BrowserEnvironmentError( + "Could not start video recording — Playwright needs its bundled ffmpeg to encode the .webm. Install it with `npx playwright install ffmpeg`.", + { cause: error }, + ); + } + try { + return { result: await action(), video }; + } finally { + await this.page.screencast.stop().catch(() => {}); + } + } + + // Persist a verified `eval` action as a runnable Playwright regression test: + // capture the page's current URL (so the test recreates the starting point), + // run the expression through the same drive path (which surfaces — and on a + // hard failure throws — page errors, so a broken action never writes a green- + // looking test), then emit a spec pinned to that URL + action. Returns the + // generated source, the drive output, and the absolute path written. + async codegen(options: { expression: string; outPath: string }): Promise<{ + path: string; + source: string; + output: string; + }> { + const url = this.page.url(); + const output = await this.evaluateOrSnapshot(options.expression); + const source = generatePlaywrightTest({ url, expression: options.expression }); + const path = resolve(options.outPath); + await writeFile(path, source); + return { path, source, output }; + } + + // Wait for the page to stop changing before we read it: in-flight requests + // drain, then web fonts finish loading. Without this the design job + // screenshots a half-rendered frame (lazy images, fade-in, fallback fonts). + // Bounded and best-effort — a page that never goes idle hits the cap. + private async settle(): Promise { + await this.page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {}); + await this.waitForFonts(); + } + + // `document.fonts.ready` can stall on a page that keeps registering fonts, so + // cap it — otherwise settle() (on the hot path of every command) could hang. + private waitForFonts(): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, SETTLE_TIMEOUT_MS); + void this.page + .evaluate(() => document.fonts?.ready.then(() => undefined)) + .catch(() => undefined) + .finally(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + + async snapshot(): Promise { + return this.page.locator("body").ariaSnapshot(); + } + + // Settle first so a screenshot taken straight after an SPA navigation (or in a + // separate command that reattaches) still captures the finished page. + async screenshot(path?: string): Promise { + await this.settle(); + return this.page.screenshot({ path }); + } + + // axe is injected with `evaluate`, not a