-
Notifications
You must be signed in to change notification settings - Fork 419
feat(react): /react-doctor umbrella skill + in-house browser core + debug job #853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aidenybai
wants to merge
39
commits into
main
Choose a base branch
from
feat/react-skill
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+6,458
−536
Open
Changes from 23 commits
Commits
Show all changes
39 commits
Select commit
Hold shift + click to select a range
a7d5a69
feat(react): /react-doctor umbrella skill, in-house browser core, and…
aidenybai 5b1dc39
feat(react): add `react-doctor mcp` Model Context Protocol server
aidenybai a3484a9
refactor(mcp): collapse the read-only browser tools into a registrati…
aidenybai 29178ba
fix(mcp): harden debug fetch + clear viewport override (thermos review)
aidenybai bbf744e
refactor(cli): reuse the shared Viewport type for --viewport
aidenybai 068f3b2
fix(mcp): allowlist debug endpoints + guard non-loopback bind (thermo…
aidenybai 7665dad
refactor(browser): encapsulate playwright/axe laziness in the package
aidenybai c51eed3
feat(browser): add combined React + CPU profiler
aidenybai a7c4d4a
fix(cli): allowlist browser profile's --interaction flag (bugbot)
aidenybai c4e5658
refactor: drop comments that restate names or duplicate doc
aidenybai 35e3661
fix(browser): stop the React profiler when a profiled interaction throws
aidenybai b02f102
refactor(browser): collapse the capture commands into eval --profile
aidenybai d47f33e
fix(browser): stop V8 sampling on the error path in inspect
aidenybai 7f6f088
feat(browser): capture a DevTools timeline trace in eval --profile
aidenybai 08c8da4
fix(browser): harden inspect from dogfooding eval --profile
aidenybai cdcf38c
fix(browser): render environment failures as actionable user errors
aidenybai 4d2de17
fix(browser): add the missing close-launched-browser source files
aidenybai 7cc684f
feat(browser): memory snapshot + headless launch/close + richer network
aidenybai e5dfeb8
feat(browser): make eval self-reporting and consolidate the skill
aidenybai ffa57e8
Merge remote-tracking branch 'origin/main' into feat/react-skill
aidenybai f4d735b
fix(browser): don't orphan launched Chrome on attach/close failure (b…
aidenybai f13c069
fix(browser): forget a dead launched endpoint after falling back (bug…
aidenybai 909957b
fix(react): force-inline @react-doctor/browser into the CLI and MCP b…
aidenybai 8461ca8
fix(browser): keep eval/profile diagnostics when the driven action fa…
aidenybai 815d4d4
fix(browser): scope eval --profile LoAF/CLS to the post-action window…
aidenybai e5718ef
feat(browser): add eval --codegen (emit Playwright tests) and --video…
aidenybai 12288a8
fix(browser): align profile windows + raise react-doctor playwright f…
aidenybai a626a7f
fix(cli): force-bundle @react-doctor/debug into the CLI (windows pack…
aidenybai 82b431e
feat(install): make pkg.pr.new preview builds self-referential
aidenybai b918c46
fix(browser): make profile vitals navigation-aware + filter LCP (bugbot)
aidenybai d64c7ff
ci(smoke): verify the Linux-packed CLI tarball on Windows/macOS
aidenybai 48dad7f
fix(ci): partition build cache by OS so a divergent build can't poiso…
aidenybai f2c1d54
fix(browser): don't adopt a foreign default-port Chrome over our laun…
aidenybai 9417c96
fix(ci): publish deslop-js to pkg.pr.new so previews install
aidenybai a83df6d
chore(install): disable optional pre-commit + agent-hook setup
aidenybai 2baa9c7
docs(skill): drop the non-existent /doctor command reference
aidenybai 3d46a73
chore(deps): upgrade agent-install to 0.0.8
aidenybai 5b4136c
feat(install): add global vs project skill install choice
aidenybai 135fa5d
fix(browser): set perf recording marker without swallowing failures
aidenybai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ../../skills/react-doctor |
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. `--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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.49.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^25.6.0", | ||
| "esbuild": "^0.25.12", | ||
| "react-devtools-inline": "^6.1.5" | ||
| }, | ||
| "engines": { | ||
| "node": "^20.19.0 || >=22.13.0" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number, CpuProfileNode>(); | ||
| 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<string, SelfTimeAccumulator>(); | ||
| 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 }; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, keyof TimelineAnalysis> = { | ||
| 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; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<boolean> => { | ||
| 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; | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.