diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index 816af31..9b23d6d 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -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 diff --git a/playwriter/package.json b/playwriter/package.json index 284772e..f774f4d 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -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", diff --git a/playwriter/scripts/build-bippy.ts b/playwriter/scripts/build-bippy.ts new file mode 100644 index 0000000..fa35239 --- /dev/null +++ b/playwriter/scripts/build-bippy.ts @@ -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) +}) diff --git a/playwriter/scripts/build-selector-generator.ts b/playwriter/scripts/build-selector-generator.ts new file mode 100644 index 0000000..135f381 --- /dev/null +++ b/playwriter/scripts/build-selector-generator.ts @@ -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) +}) diff --git a/playwriter/scripts/download-selector-generator.ts b/playwriter/scripts/download-selector-generator.ts deleted file mode 100644 index bfbb82c..0000000 --- a/playwriter/scripts/download-selector-generator.ts +++ /dev/null @@ -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) -}) diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index eb6e0cb..5e32b70 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -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(` + + + + + + + +
+ + + + `) + 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) }) diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index bfee227..763de9d 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -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 formatStylesAsText: (styles: StylesResult) => string + getReactSource: (options: { locator: any }) => Promise require: NodeRequire import: (specifier: string) => Promise } @@ -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 diff --git a/playwriter/src/prompt.md b/playwriter/src/prompt.md index a005957..50cdbfa 100644 --- a/playwriter/src/prompt.md +++ b/playwriter/src/prompt.md @@ -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: diff --git a/playwriter/src/react-source.ts b/playwriter/src/react-source.ts new file mode 100644 index 0000000..910d1f8 --- /dev/null +++ b/playwriter/src/react-source.ts @@ -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 { + 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 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0092f9b..c814c72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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