Add getReactSource to extract React component source location

- Add bippy library for React fiber introspection
- Create build-bippy.ts and build-selector-generator.ts using Bun.build
- Use CDP Runtime.evaluate instead of addScriptTag to bypass CSP
- Add getReactSource utility function exposed in MCP VMContext
- Works on local React dev servers with JSX transform (not production builds)
This commit is contained in:
Tommy D. Rossi
2025-12-29 16:09:52 +01:00
parent 56e4e1566e
commit 5e0cd2dd98
10 changed files with 366 additions and 60 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 0.0.28
### Patch Changes
- **Added `getReactSource` utility**: Extract React component source location (file, line, column) from DOM elements
- Uses bippy library for React fiber introspection
- Returns `{ fileName, lineNumber, columnNumber, componentName }` or `null`
- Only works on local dev servers (Vite, Next.js, CRA) with JSX transform in development mode
- **CSP bypass for script injection**: Changed `getLocatorStringForElement` and `getReactSource` to use CDP `Runtime.evaluate` instead of `addScriptTag`
- Scripts now work on pages with strict Content Security Policy
- **Switched to Bun.build**: Replaced esbuild and esm.sh downloads with Bun.build for bundling selector-generator and bippy
- New `build-selector-generator.ts` and `build-bippy.ts` scripts
## 0.0.27
### Patch Changes
+3 -2
View File
@@ -1,13 +1,13 @@
{
"name": "playwriter",
"description": "",
"version": "0.0.27",
"version": "0.0.28",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"repository": "https://github.com/remorses/playwriter",
"scripts": {
"build": "rm -rf dist *.tsbuildinfo && mkdir dist && cp src/resource.md src/prompt.md src/debugger-examples.ts dist/ && vite-node scripts/download-selector-generator.ts && tsc",
"build": "rm -rf dist *.tsbuildinfo && mkdir dist && cp src/resource.md src/prompt.md src/debugger-examples.ts dist/ && bun scripts/build-selector-generator.ts && bun scripts/build-bippy.ts && tsc",
"prepublishOnly": "pnpm build",
"watch": "tsc -w",
"typecheck": "tsc --noEmit",
@@ -32,6 +32,7 @@
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"@vitest/ui": "^4.0.8",
"bippy": "^0.5.27",
"image-size": "^2.0.2",
"mcp-extension": "workspace:*",
"vite-node": "^5.0.0",
+60
View File
@@ -0,0 +1,60 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = path.join(__dirname, '..', 'dist')
const ENTRY_CODE = `
import { getFiberFromHostInstance, getDisplayName, traverseFiber, isCompositeFiber, isHostFiber } from 'bippy'
import { getSource, getOwnerStack, normalizeFileName, isSourceFile } from 'bippy/source'
globalThis.__bippy = {
getFiberFromHostInstance,
getDisplayName,
traverseFiber,
isCompositeFiber,
isHostFiber,
getSource,
getOwnerStack,
normalizeFileName,
isSourceFile,
}
`
async function main() {
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true })
}
const entryPath = path.join(distDir, '_bippy-entry.js')
fs.writeFileSync(entryPath, ENTRY_CODE)
console.log('Bundling bippy...')
const result = await Bun.build({
entrypoints: [entryPath],
target: 'browser',
format: 'iife',
define: {
'process.env.NODE_ENV': '"development"',
},
})
fs.unlinkSync(entryPath)
if (!result.success) {
console.error('Bundle errors:', result.logs)
process.exit(1)
}
const bundledCode = await result.outputs[0].text()
const outputPath = path.join(distDir, 'bippy.js')
fs.writeFileSync(outputPath, bundledCode)
console.log(`Saved to ${outputPath} (${Math.round(bundledCode.length / 1024)}kb)`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -0,0 +1,46 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = path.join(__dirname, '..', 'dist')
const ENTRY_CODE = `
import { createSelectorGenerator, toLocator } from '@mizchi/selector-generator'
globalThis.__selectorGenerator = { createSelectorGenerator, toLocator }
`
async function main() {
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true })
}
const entryPath = path.join(distDir, '_selector-generator-entry.js')
fs.writeFileSync(entryPath, ENTRY_CODE)
console.log('Bundling selector-generator...')
const result = await Bun.build({
entrypoints: [entryPath],
target: 'browser',
format: 'iife',
})
fs.unlinkSync(entryPath)
if (!result.success) {
console.error('Bundle errors:', result.logs)
process.exit(1)
}
const bundledCode = await result.outputs[0].text()
const outputPath = path.join(distDir, 'selector-generator.js')
fs.writeFileSync(outputPath, bundledCode)
console.log(`Saved to ${outputPath} (${Math.round(bundledCode.length / 1024)}kb)`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -1,55 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = path.join(__dirname, '..', 'dist')
const SELECTOR_GENERATOR_URL = 'https://esm.sh/@mizchi/selector-generator@1.50.0-next/es2022/selector-generator.bundle.mjs'
async function main() {
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true })
}
console.log('Downloading selector-generator from esm.sh...')
const response = await fetch(SELECTOR_GENERATOR_URL)
if (!response.ok) {
throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
}
let esmScript = await response.text()
const exportMatch = esmScript.match(/export\s*\{([^}]+)\}\s*;?\s*(\/\/.*)?$/)
if (!exportMatch) {
throw new Error('Could not find export statement in bundle')
}
const exports = exportMatch[1].split(',').map((e) => {
const parts = e.trim().split(/\s+as\s+/)
return { local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() }
})
const createSelectorGenerator = exports.find((e) => e.exported === 'createSelectorGenerator')
const toLocator = exports.find((e) => e.exported === 'toLocator')
if (!createSelectorGenerator || !toLocator) {
throw new Error('Could not find createSelectorGenerator or toLocator exports')
}
esmScript = esmScript.replace(/export\s*\{[^}]+\}\s*;?\s*(\/\/.*)?$/, '')
const wrappedScript = `(function() {
${esmScript}
globalThis.__selectorGenerator = { createSelectorGenerator: ${createSelectorGenerator.local}, toLocator: ${toLocator.local} };
})();`
const outputPath = path.join(distDir, 'selector-generator.js')
fs.writeFileSync(outputPath, wrappedScript)
console.log(`Saved to ${outputPath}`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+80
View File
@@ -2809,4 +2809,84 @@ describe('CDP Session Tests', () => {
await browser.close()
await page.close()
}, 60000)
it('should inject bippy and find React fiber with getReactSource', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
await page.setContent(`
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
</head>
<body>
<div id="root"></div>
<script>
function MyComponent() {
return React.createElement('button', { id: 'react-btn' }, 'Click me');
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(MyComponent));
</script>
</body>
</html>
`)
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const pages = browser.contexts()[0].pages()
const cdpPage = pages.find(p => p.url().startsWith('about:'))
expect(cdpPage).toBeDefined()
const btn = cdpPage!.locator('#react-btn')
const btnCount = await btn.count()
expect(btnCount).toBe(1)
const hasBippyBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
expect(hasBippyBefore).toBe(false)
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
const { getReactSource } = await import('./react-source.js')
const source = await getReactSource({ locator: btn, cdp: cdpSession })
const hasBippyAfter = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
expect(hasBippyAfter).toBe(true)
const hasFiber = await btn.evaluate((el) => {
const bippy = (globalThis as any).__bippy
const fiber = bippy.getFiberFromHostInstance(el)
return !!fiber
})
expect(hasFiber).toBe(true)
const componentName = await btn.evaluate((el) => {
const bippy = (globalThis as any).__bippy
const fiber = bippy.getFiberFromHostInstance(el)
let current = fiber
while (current) {
if (bippy.isCompositeFiber(current)) {
return bippy.getDisplayName(current.type)
}
current = current.return
}
return null
})
expect(componentName).toBe('MyComponent')
console.log('Component name from fiber:', componentName)
console.log('Source location (null for UMD React, works on local dev servers with JSX transform):', source)
await browser.close()
await page.close()
}, 60000)
})
+11 -1
View File
@@ -19,6 +19,7 @@ import { getCDPSessionForPage, CDPSession } from './cdp-session.js'
import { Debugger } from './debugger.js'
import { Editor } from './editor.js'
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
import { getReactSource, type ReactSourceLocation } from './react-source.js'
class CodeExecutionTimeoutError extends Error {
constructor(timeout: number) {
@@ -80,6 +81,7 @@ interface VMContext {
createEditor: (options: { cdp: CDPSession }) => Editor
getStylesForLocator: (options: { locator: any }) => Promise<StylesResult>
formatStylesAsText: (styles: StylesResult) => string
getReactSource: (options: { locator: any }) => Promise<ReactSourceLocation | null>
require: NodeRequire
import: (specifier: string) => Promise<any>
}
@@ -641,7 +643,8 @@ server.tool(
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const scriptPath = path.join(currentDir, '..', 'dist', 'selector-generator.js')
const scriptContent = fs.readFileSync(scriptPath, 'utf-8')
await elementPage.addScriptTag({ content: scriptContent })
const cdp = await getCDPSession({ page: elementPage })
await cdp.send('Runtime.evaluate', { expression: scriptContent })
}
return await element.evaluate((el: any) => {
@@ -709,6 +712,11 @@ server.tool(
return getStylesForLocator({ locator: options.locator, cdp })
}
const getReactSourceFn = async (options: { locator: any }) => {
const cdp = await getCDPSession({ page: options.locator.page() })
return getReactSource({ locator: options.locator, cdp })
}
let vmContextObj: VMContextWithGlobals = {
page,
context,
@@ -724,6 +732,7 @@ server.tool(
createEditor,
getStylesForLocator: getStylesForLocatorFn,
formatStylesAsText,
getReactSource: getReactSourceFn,
resetPlaywright: async () => {
const { page: newPage, context: newContext } = await resetConnection()
@@ -742,6 +751,7 @@ server.tool(
createEditor,
getStylesForLocator: getStylesForLocatorFn,
formatStylesAsText,
getReactSource: getReactSourceFn,
resetPlaywright: vmContextObj.resetPlaywright,
require,
// TODO --experimental-vm-modules is needed to make import work in vm
+13
View File
@@ -158,6 +158,19 @@ you have access to some functions in addition to playwright methods:
console.log(formatStylesAsText(styles));
```
- `formatStylesAsText(styles)`: formats a `StylesResult` object as human-readable text. Use this to display styles in a readable format.
- `getReactSource({ locator })`: gets the React component source location (file, line, column) for an element.
- `locator`: a Playwright Locator or ElementHandle for the element to inspect
- Returns: `{ fileName, lineNumber, columnNumber, componentName }` or `null` if not found
- **Important**: Only works on **local dev servers** (localhost with Vite, Next.js, CRA in dev mode). Production builds strip source info. The JSX transform must have `development: true` to include `_debugSource`.
- Example:
```js
const loc = page.locator('.my-component');
const source = await getReactSource({ locator: loc });
if (source) {
console.log(`Component: ${source.componentName}`);
console.log(`File: ${source.fileName}:${source.lineNumber}:${source.columnNumber}`);
}
```
example:
+92
View File
@@ -0,0 +1,92 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Page, Locator, ElementHandle } from 'playwright-core'
import type { CDPSession } from './cdp-session.js'
export interface ReactSourceLocation {
fileName: string | null
lineNumber: number | null
columnNumber: number | null
componentName: string | null
}
let bippyCode: string | null = null
function getBippyCode(): string {
if (bippyCode) {
return bippyCode
}
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const bippyPath = path.join(currentDir, '..', 'dist', 'bippy.js')
bippyCode = fs.readFileSync(bippyPath, 'utf-8')
return bippyCode
}
export async function getReactSource({
locator,
cdp,
}: {
locator: Locator | ElementHandle
cdp: CDPSession
}): Promise<ReactSourceLocation | null> {
const page: Page = 'page' in locator && typeof locator.page === 'function' ? locator.page() : (locator as any)._page
if (!page) {
throw new Error('Could not get page from locator')
}
const hasBippy = await page.evaluate(() => !!(globalThis as any).__bippy)
if (!hasBippy) {
const code = getBippyCode()
await cdp.send('Runtime.evaluate', { expression: code })
}
const result = await (locator as any).evaluate(async (el: any) => {
const bippy = (globalThis as any).__bippy
if (!bippy) {
throw new Error('bippy not loaded')
}
const fiber = bippy.getFiberFromHostInstance(el)
if (!fiber) {
return { _notFound: 'fiber' as const }
}
const source = await bippy.getSource(fiber)
if (source) {
return {
fileName: source.fileName ? bippy.normalizeFileName(source.fileName) : null,
lineNumber: source.lineNumber ?? null,
columnNumber: source.columnNumber ?? null,
componentName: source.functionName ?? bippy.getDisplayName(fiber.type) ?? null,
}
}
const ownerStack = await bippy.getOwnerStack(fiber)
for (const frame of ownerStack) {
if (frame.fileName && bippy.isSourceFile(frame.fileName)) {
return {
fileName: bippy.normalizeFileName(frame.fileName),
lineNumber: frame.lineNumber ?? null,
columnNumber: frame.columnNumber ?? null,
componentName: frame.functionName ?? null,
}
}
}
return { _notFound: 'source' as const }
})
if (result && '_notFound' in result) {
if (result._notFound === 'fiber') {
console.warn('[getReactSource] no fiber found - is this a React element?')
} else {
console.warn('[getReactSource] no source location found - is this a React dev build?')
}
return null
}
return result
}
+48 -2
View File
@@ -38,7 +38,7 @@ importers:
version: link:../playwriter
zustand:
specifier: ^5.0.8
version: 5.0.8
version: 5.0.8(@types/react@19.2.7)(react@19.2.3)
devDependencies:
'@types/chrome':
specifier: ^0.0.315
@@ -124,6 +124,9 @@ importers:
'@vitest/ui':
specifier: ^4.0.8
version: 4.0.8(vitest@4.0.8)
bippy:
specifier: ^0.5.27
version: 0.5.27(@types/react@19.2.7)(react@19.2.3)
image-size:
specifier: ^2.0.2
version: 2.0.2
@@ -887,6 +890,14 @@ packages:
'@types/node@24.10.1':
resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==}
'@types/react-reconciler@0.28.9':
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
peerDependencies:
'@types/react': '*'
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@@ -1090,6 +1101,11 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bippy@0.5.27:
resolution: {integrity: sha512-LvGYTQm5cMqEhnUT2tr6mSE1mrhmX/xxu3jiypHAscThhxC+9SWdaQW8Nye5CT/sR79LObNJlQpOUgi4DopTMg==}
peerDependencies:
react: '>=17.0.1'
body-parser@2.2.0:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'}
@@ -1228,6 +1244,9 @@ packages:
resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==}
engines: {node: '>=20'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@@ -2220,6 +2239,10 @@ packages:
resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==}
engines: {node: '>= 0.10'}
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
read-yaml-file@1.1.0:
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
engines: {node: '>=6'}
@@ -2727,6 +2750,7 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
@@ -3622,6 +3646,14 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@types/react-reconciler@0.28.9(@types/react@19.2.7)':
dependencies:
'@types/react': 19.2.7
'@types/react@19.2.7':
dependencies:
csstype: 3.2.3
'@types/retry@0.12.0': {}
'@types/uuid@10.0.0': {}
@@ -3818,6 +3850,13 @@ snapshots:
binary-extensions@2.3.0: {}
bippy@0.5.27(@types/react@19.2.7)(react@19.2.3):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@19.2.7)
react: 19.2.3
transitivePeerDependencies:
- '@types/react'
body-parser@2.2.0:
dependencies:
bytes: 3.1.2
@@ -3989,6 +4028,8 @@ snapshots:
css-tree: 3.1.0
optional: true
csstype@3.2.3: {}
data-uri-to-buffer@4.0.1: {}
data-uri-to-buffer@6.0.2:
@@ -5060,6 +5101,8 @@ snapshots:
iconv-lite: 0.7.0
unpipe: 1.0.0
react@19.2.3: {}
read-yaml-file@1.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -5660,4 +5703,7 @@ snapshots:
zod@3.25.76: {}
zustand@5.0.8: {}
zustand@5.0.8(@types/react@19.2.7)(react@19.2.3):
optionalDependencies:
'@types/react': 19.2.7
react: 19.2.3