release: playwriter@0.0.43
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.43
|
||||
|
||||
### Features
|
||||
|
||||
- **`getCleanHTML` utility**: New function to get cleaned HTML from a locator or page
|
||||
- Removes script, style, svg, head tags
|
||||
- Keeps only essential attributes (aria-*, data-*, href, role, title, alt, etc.)
|
||||
- Supports `search` option to filter results (returns first 10 matching lines)
|
||||
- Supports `showDiffSinceLastCall` to see changes since last snapshot
|
||||
- Supports `includeStyles` to optionally keep style/class attributes
|
||||
|
||||
### Changes
|
||||
|
||||
- **Simplified `accessibilitySnapshot` search**: Removed `contextLines` parameter, search now returns just matching lines instead of context around matches. Use `.split('\n').slice()` for pagination instead.
|
||||
|
||||
## 0.0.42
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "playwriter",
|
||||
"description": "",
|
||||
"version": "0.0.42",
|
||||
"version": "0.0.43",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Page, Locator } from 'playwright-core'
|
||||
import { createPatch } from 'diff'
|
||||
import { formatHtmlForPrompt } from './htmlrewrite.js'
|
||||
|
||||
export interface GetCleanHTMLOptions {
|
||||
locator: Locator | Page
|
||||
search?: string | RegExp
|
||||
showDiffSinceLastCall?: boolean
|
||||
includeStyles?: boolean
|
||||
maxAttrLen?: number
|
||||
maxContentLen?: number
|
||||
}
|
||||
|
||||
// Store last HTML snapshots per locator/page for diffing
|
||||
const lastHtmlSnapshots: WeakMap<Page, Map<string, string>> = new WeakMap()
|
||||
|
||||
function isPage(obj: any): obj is Page {
|
||||
return obj && typeof obj.content === 'function' && typeof obj.goto === 'function'
|
||||
}
|
||||
|
||||
function isRegExp(value: any): value is RegExp {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
function getSnapshotKey(locator: Locator | Page): string {
|
||||
if (isPage(locator)) {
|
||||
return '__page__'
|
||||
}
|
||||
// For locators, use a string representation
|
||||
return (locator as any)._selector || '__locator__'
|
||||
}
|
||||
|
||||
export async function getCleanHTML(options: GetCleanHTMLOptions): Promise<string> {
|
||||
const {
|
||||
locator,
|
||||
search,
|
||||
showDiffSinceLastCall = false,
|
||||
includeStyles = false,
|
||||
maxAttrLen = 200,
|
||||
maxContentLen = 500,
|
||||
} = options
|
||||
|
||||
// Get raw HTML
|
||||
let rawHtml: string
|
||||
let page: Page
|
||||
|
||||
if (isPage(locator)) {
|
||||
page = locator
|
||||
rawHtml = await locator.content()
|
||||
} else {
|
||||
page = locator.page()
|
||||
rawHtml = await locator.innerHTML()
|
||||
}
|
||||
|
||||
// Clean the HTML using formatHtmlForPrompt
|
||||
const cleanedHtml = await formatHtmlForPrompt({
|
||||
html: rawHtml,
|
||||
keepStyles: includeStyles,
|
||||
maxAttrLen,
|
||||
maxContentLen,
|
||||
})
|
||||
|
||||
// Sanitize to remove unpaired surrogates that break JSON encoding
|
||||
let htmlStr = cleanedHtml.toWellFormed?.() ?? cleanedHtml
|
||||
|
||||
// Handle diffing
|
||||
if (showDiffSinceLastCall) {
|
||||
let pageSnapshots = lastHtmlSnapshots.get(page)
|
||||
if (!pageSnapshots) {
|
||||
pageSnapshots = new Map()
|
||||
lastHtmlSnapshots.set(page, pageSnapshots)
|
||||
}
|
||||
|
||||
const snapshotKey = getSnapshotKey(locator)
|
||||
const previousSnapshot = pageSnapshots.get(snapshotKey)
|
||||
|
||||
if (!previousSnapshot) {
|
||||
pageSnapshots.set(snapshotKey, htmlStr)
|
||||
return 'No previous snapshot available. This is the first call for this locator. Full snapshot stored for next diff.'
|
||||
}
|
||||
|
||||
const patch = createPatch('html', previousSnapshot, htmlStr, 'previous', 'current', {
|
||||
context: 3,
|
||||
})
|
||||
|
||||
pageSnapshots.set(snapshotKey, htmlStr)
|
||||
|
||||
if (patch.split('\n').length <= 4) {
|
||||
return 'No changes detected since last snapshot'
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
// Store snapshot for future diffs
|
||||
let pageSnapshots = lastHtmlSnapshots.get(page)
|
||||
if (!pageSnapshots) {
|
||||
pageSnapshots = new Map()
|
||||
lastHtmlSnapshots.set(page, pageSnapshots)
|
||||
}
|
||||
pageSnapshots.set(getSnapshotKey(locator), htmlStr)
|
||||
|
||||
// Handle search
|
||||
if (search) {
|
||||
const lines = htmlStr.split('\n')
|
||||
const matches: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
let isMatch = false
|
||||
if (isRegExp(search)) {
|
||||
isMatch = search.test(line)
|
||||
} else {
|
||||
isMatch = line.includes(search)
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
matches.push(line)
|
||||
if (matches.length >= 10) break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return 'No matches found'
|
||||
}
|
||||
|
||||
return matches.join('\n')
|
||||
}
|
||||
|
||||
return htmlStr
|
||||
}
|
||||
@@ -2436,6 +2436,96 @@ describe('MCP Server Tests', () => {
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should get clean HTML with getCleanHTML', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<head>
|
||||
<style>.hidden { display: none; }</style>
|
||||
<script>console.log('test')</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" data-testid="main">
|
||||
<h1>Hello World</h1>
|
||||
<button id="btn" aria-label="Click me">Submit</button>
|
||||
<a href="/about" title="About page">About</a>
|
||||
<input type="text" placeholder="Enter name" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
// Test basic getCleanHTML
|
||||
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('Hello World')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
const html = await getCleanHTML({ locator: testPage.locator('body') });
|
||||
return html;
|
||||
`,
|
||||
timeout: 15000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isError).toBeFalsy()
|
||||
const text = (result.content as any)[0]?.text || ''
|
||||
|
||||
// Inline snapshot of cleaned HTML
|
||||
expect(text).toMatchInlineSnapshot(`
|
||||
"Return value:
|
||||
<div data-testid="main">
|
||||
<h1>Hello World</h1>
|
||||
<button aria-label="Click me">Submit</button>
|
||||
<a href="/about" title="About page">About</a>
|
||||
<input type="text" placeholder="Enter name">
|
||||
</div>"
|
||||
`)
|
||||
|
||||
// Should NOT contain script/style tags (they're removed)
|
||||
expect(text).not.toContain('<script')
|
||||
expect(text).not.toContain('<style')
|
||||
expect(text).not.toContain('console.log')
|
||||
|
||||
// Test search functionality
|
||||
const searchResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('Hello World')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
const html = await getCleanHTML({ locator: testPage, search: /button/i });
|
||||
return html;
|
||||
`,
|
||||
timeout: 15000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(searchResult.isError).toBeFalsy()
|
||||
const searchText = (searchResult.content as any)[0]?.text || ''
|
||||
expect(searchText).toContain('button')
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
+10
-15
@@ -22,6 +22,7 @@ import { getStylesForLocator, formatStylesAsText, type StylesResult } from './st
|
||||
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
||||
import { ScopedFS } from './scoped-fs.js'
|
||||
import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js'
|
||||
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
@@ -72,9 +73,9 @@ interface VMContext {
|
||||
accessibilitySnapshot: (options: {
|
||||
page: Page
|
||||
search?: string | RegExp
|
||||
contextLines?: number
|
||||
showDiffSinceLastCall?: boolean
|
||||
}) => Promise<string>
|
||||
getCleanHTML: (options: GetCleanHTMLOptions) => Promise<string>
|
||||
getLocatorStringForElement: (element: any) => Promise<string>
|
||||
resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }>
|
||||
getLatestLogs: (options?: { page?: Page; count?: number; search?: string | RegExp }) => Promise<string[]>
|
||||
@@ -713,10 +714,9 @@ server.tool(
|
||||
const accessibilitySnapshot = async (options: {
|
||||
page: Page
|
||||
search?: string | RegExp
|
||||
contextLines?: number
|
||||
showDiffSinceLastCall?: boolean
|
||||
}) => {
|
||||
const { page: targetPage, search, contextLines = 10, showDiffSinceLastCall = false } = options
|
||||
const { page: targetPage, search, showDiffSinceLastCall = false } = options
|
||||
if ((targetPage as any)._snapshotForAI) {
|
||||
const snapshot = await (targetPage as any)._snapshotForAI()
|
||||
// Sanitize to remove unpaired surrogates that break JSON encoding for Claude API
|
||||
@@ -732,7 +732,7 @@ server.tool(
|
||||
}
|
||||
|
||||
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', {
|
||||
context: contextLines,
|
||||
context: 3,
|
||||
})
|
||||
if (patch.split('\n').length <= 4) {
|
||||
return 'No changes detected since last snapshot'
|
||||
@@ -747,10 +747,9 @@ server.tool(
|
||||
}
|
||||
|
||||
const lines = snapshotStr.split('\n')
|
||||
const matches: { line: string; index: number }[] = []
|
||||
const matches: string[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
for (const line of lines) {
|
||||
let isMatch = false
|
||||
if (isRegExp(search)) {
|
||||
isMatch = search.test(line)
|
||||
@@ -759,7 +758,7 @@ server.tool(
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
matches.push({ line, index: i })
|
||||
matches.push(line)
|
||||
if (matches.length >= 10) break
|
||||
}
|
||||
}
|
||||
@@ -768,13 +767,7 @@ server.tool(
|
||||
return 'No matches found'
|
||||
}
|
||||
|
||||
return matches
|
||||
.map((m) => {
|
||||
const start = Math.max(0, m.index - contextLines)
|
||||
const end = Math.min(lines.length, m.index + contextLines + 1)
|
||||
return lines.slice(start, end).join('\n')
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
return matches.join('\n')
|
||||
}
|
||||
throw new Error('accessibilitySnapshot is not available on this page')
|
||||
}
|
||||
@@ -878,6 +871,7 @@ server.tool(
|
||||
state: userState,
|
||||
console: customConsole,
|
||||
accessibilitySnapshot,
|
||||
getCleanHTML,
|
||||
getLocatorStringForElement,
|
||||
getLatestLogs,
|
||||
clearAllLogs,
|
||||
@@ -898,6 +892,7 @@ server.tool(
|
||||
state: userState,
|
||||
console: customConsole,
|
||||
accessibilitySnapshot,
|
||||
getCleanHTML,
|
||||
getLocatorStringForElement,
|
||||
getLatestLogs,
|
||||
clearAllLogs,
|
||||
|
||||
@@ -38,13 +38,18 @@ If nothing changed, try `await page.waitForLoadState('networkidle', {timeout: 30
|
||||
## accessibility snapshots
|
||||
|
||||
```js
|
||||
await accessibilitySnapshot({ page, search?, contextLines?, showDiffSinceLastCall? })
|
||||
await accessibilitySnapshot({ page, search?, showDiffSinceLastCall? })
|
||||
```
|
||||
|
||||
- `search` - string/regex to filter results (returns first 10 matches with context)
|
||||
- `contextLines` - lines of context around matches (default: 10)
|
||||
- `search` - string/regex to filter results (returns first 10 matching lines)
|
||||
- `showDiffSinceLastCall` - returns diff since last snapshot (useful after actions)
|
||||
|
||||
For pagination, use `.split('\n').slice(offset, offset + limit).join('\n')`:
|
||||
```js
|
||||
console.log((await accessibilitySnapshot({ page })).split('\n').slice(0, 50).join('\n')); // first 50 lines
|
||||
console.log((await accessibilitySnapshot({ page })).split('\n').slice(50, 100).join('\n')); // next 50 lines
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```md
|
||||
@@ -174,6 +179,29 @@ const pageLogs = await getLatestLogs({ page })
|
||||
|
||||
For custom log collection across runs, store in state: `state.logs = []; page.on('console', m => state.logs.push(m.text()))`
|
||||
|
||||
**getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:
|
||||
|
||||
```js
|
||||
await getCleanHTML({ locator, search?, showDiffSinceLastCall?, includeStyles? })
|
||||
// Examples:
|
||||
const html = await getCleanHTML({ locator: page.locator('body') })
|
||||
const html = await getCleanHTML({ locator: page, search: /button/i })
|
||||
const diff = await getCleanHTML({ locator: page, showDiffSinceLastCall: true })
|
||||
```
|
||||
|
||||
- `locator` - Playwright Locator or Page to get HTML from
|
||||
- `search` - string/regex to filter results (returns first 10 matching lines)
|
||||
- `showDiffSinceLastCall` - returns diff since last snapshot
|
||||
- `includeStyles` - keep style and class attributes (default: false)
|
||||
|
||||
Returns cleaned HTML with only essential attributes (aria-*, data-*, href, role, title, alt, etc.). Removes script, style, svg, head tags.
|
||||
|
||||
For pagination, use `.split('\n').slice(offset, offset + limit).join('\n')`:
|
||||
```js
|
||||
console.log((await getCleanHTML({ locator: page })).split('\n').slice(0, 50).join('\n')); // first 50 lines
|
||||
console.log((await getCleanHTML({ locator: page })).split('\n').slice(50, 100).join('\n')); // next 50 lines
|
||||
```
|
||||
|
||||
**waitForPageLoad** - smart load detection that ignores analytics/ads:
|
||||
|
||||
```js
|
||||
|
||||
Reference in New Issue
Block a user