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