diff --git a/playwriter/package.json b/playwriter/package.json index c2bd6fd..c4d9a15 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -7,7 +7,7 @@ "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 dist/ && tsc", + "build": "rm -rf dist *.tsbuildinfo && mkdir dist && cp src/resource.md src/prompt.md dist/ && vite-node scripts/download-selector-generator.ts && tsc", "prepublishOnly": "pnpm build", "watch": "tsc -w", "typecheck": "tsc --noEmit", diff --git a/playwriter/scripts/download-selector-generator.ts b/playwriter/scripts/download-selector-generator.ts new file mode 100644 index 0000000..bfbb82c --- /dev/null +++ b/playwriter/scripts/download-selector-generator.ts @@ -0,0 +1,55 @@ +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 56ab904..f4f0b19 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -175,6 +175,37 @@ describe('MCP Server Tests', () => { return testCtx.browserContext } + it('should inject script via addScriptTag through CDP relay', 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()) + const cdpPage = browser.contexts()[0].pages().find(p => { + return p.url().startsWith('about:') + }) + expect(cdpPage).toBeDefined() + + const hasGlobalBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__testGlobal) + expect(hasGlobalBefore).toBe(false) + + await cdpPage!.addScriptTag({ content: 'globalThis.__testGlobal = { foo: "bar" };' }) + + const hasGlobalAfter = await cdpPage!.evaluate(() => (globalThis as any).__testGlobal) + expect(hasGlobalAfter).toEqual({ foo: 'bar' }) + + await browser.close() + await page.close() + }, 60000) + it('should execute code and capture console output', async () => { await client.callTool({ name: 'execute', @@ -1331,6 +1362,60 @@ describe('MCP Server Tests', () => { await page.close() }, 60000) + it('should get locator string for element using getLocatorStringForElement', 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 result = await client.callTool({ + name: 'execute', + arguments: { + code: js` + let testPage; + for (const p of context.pages()) { + const html = await p.content(); + if (html.includes('test-btn')) { testPage = p; break; } + } + if (!testPage) throw new Error('Test page not found'); + const btn = testPage.locator('#test-btn'); + const locatorString = await getLocatorStringForElement(btn); + console.log('Locator string:', locatorString); + const locatorFromString = eval('testPage.' + locatorString); + const count = await locatorFromString.count(); + console.log('Locator count:', count); + const text = await locatorFromString.textContent(); + console.log('Locator text:', text); + `, + timeout: 30000, + }, + }) + + expect(result.isError).toBeFalsy() + const text = (result.content as any)[0]?.text || '' + expect(text).toContain('Locator string:') + expect(text).toContain("getByRole('button', { name: 'Click Me' })") + expect(text).toContain('Locator count: 1') + expect(text).toContain('Locator text: Click Me') + + await page.close() + }, 60000) + it('should return correct layout metrics via CDP', async () => { const browserContext = getBrowserContext() const serviceWorker = await getExtensionServiceWorker(browserContext) @@ -1711,7 +1796,7 @@ describe('CDP Session Tests', () => { sampleFunctionNames: functionNames, }).toMatchInlineSnapshot(` { - "durationMicroseconds": 6486, + "durationMicroseconds": 6205, "hasNodes": true, "nodeCount": 3, "sampleFunctionNames": [ diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index cfea736..da5e115 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -486,20 +486,19 @@ server.tool( throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle') } - return await element.evaluate(async (el: any) => { - const WIN = globalThis as any - if (!WIN.__selectorGenerator) { - const module: SelectorGenerator = await import( - // @ts-ignore - 'https://unpkg.com/@mizchi/selector-generator@1.50.0-next/dist/index.js' - ) - WIN.__selectorGenerator = { - createSelectorGenerator: module.createSelectorGenerator, - toLocator: module.toLocator, - } - } - const { createSelectorGenerator, toLocator } = WIN.__selectorGenerator as SelectorGenerator - const generator = createSelectorGenerator(WIN) + const elementPage = element.page ? element.page() : page + const hasGenerator = await elementPage.evaluate(() => !!(globalThis as any).__selectorGenerator) + + if (!hasGenerator) { + 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 }) + } + + return await element.evaluate((el: any) => { + const { createSelectorGenerator, toLocator } = (globalThis as any).__selectorGenerator + const generator = createSelectorGenerator(globalThis) const result = generator(el) return toLocator(result.selector, 'javascript') }) diff --git a/playwriter/src/prompt.md b/playwriter/src/prompt.md index 1a4251b..72a51ec 100644 --- a/playwriter/src/prompt.md +++ b/playwriter/src/prompt.md @@ -126,14 +126,24 @@ Then you can use `page.locator(`aria-ref=${ref}`)` to get an element with a spec IMPORTANT: notice that we do not add any quotes in `aria-ref`! it MUST be called without quotes -## getting selector for a locator identified by snapshot aria-ref +## getting a stable selector for an element (getLocatorStringForElement) -in some cases you want to get a selector for a locator you just identified using `const element = page.locator('aria-ref=${ref}')`. To do so you can use `await getLocatorStringForElement(element)`. This is useful if you need to find other elements of the same type in a list for example. If you know the selector you can usually change a bit the selector to find the other elements of the same type in the list or table +The `aria-ref` values from accessibility snapshots are ephemeral - they change on page reload and when components remount. Use `getLocatorStringForElement(element)` to get a stable Playwright locator string that you can reuse programmatically. + +This is useful for: +- Getting a selector you can store and reuse across page reloads +- Finding similar elements in a list (modify the selector pattern) +- Debugging which selector Playwright would use for an element ```js -const loc = page.locator('aria-ref=123'); -console.log(await getLocatorStringForElement(loc)); -// => "getByRole('button', { name: 'Save' })" or similar +const loc = page.locator('aria-ref=e14'); +const selector = await getLocatorStringForElement(loc); +console.log(selector); +// => "getByRole('button', { name: 'Save' })" + +// use the selector programmatically with eval: +const stableLocator = page.getByRole('button', { name: 'Save' }) +await stableLocator.click(); ``` ## finding specific elements with snapshot