release: playwriter@0.0.43
This commit is contained in:
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# 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
|
## 0.0.42
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "playwriter",
|
"name": "playwriter",
|
||||||
"description": "",
|
"description": "",
|
||||||
"version": "0.0.42",
|
"version": "0.0.43",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"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()
|
await page.close()
|
||||||
}, 60000)
|
}, 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 { getReactSource, type ReactSourceLocation } from './react-source.js'
|
||||||
import { ScopedFS } from './scoped-fs.js'
|
import { ScopedFS } from './scoped-fs.js'
|
||||||
import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js'
|
import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js'
|
||||||
|
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
@@ -72,9 +73,9 @@ interface VMContext {
|
|||||||
accessibilitySnapshot: (options: {
|
accessibilitySnapshot: (options: {
|
||||||
page: Page
|
page: Page
|
||||||
search?: string | RegExp
|
search?: string | RegExp
|
||||||
contextLines?: number
|
|
||||||
showDiffSinceLastCall?: boolean
|
showDiffSinceLastCall?: boolean
|
||||||
}) => Promise<string>
|
}) => Promise<string>
|
||||||
|
getCleanHTML: (options: GetCleanHTMLOptions) => Promise<string>
|
||||||
getLocatorStringForElement: (element: any) => Promise<string>
|
getLocatorStringForElement: (element: any) => Promise<string>
|
||||||
resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }>
|
resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }>
|
||||||
getLatestLogs: (options?: { page?: Page; count?: number; search?: string | RegExp }) => Promise<string[]>
|
getLatestLogs: (options?: { page?: Page; count?: number; search?: string | RegExp }) => Promise<string[]>
|
||||||
@@ -713,10 +714,9 @@ server.tool(
|
|||||||
const accessibilitySnapshot = async (options: {
|
const accessibilitySnapshot = async (options: {
|
||||||
page: Page
|
page: Page
|
||||||
search?: string | RegExp
|
search?: string | RegExp
|
||||||
contextLines?: number
|
|
||||||
showDiffSinceLastCall?: boolean
|
showDiffSinceLastCall?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { page: targetPage, search, contextLines = 10, showDiffSinceLastCall = false } = options
|
const { page: targetPage, search, showDiffSinceLastCall = false } = options
|
||||||
if ((targetPage as any)._snapshotForAI) {
|
if ((targetPage as any)._snapshotForAI) {
|
||||||
const snapshot = await (targetPage as any)._snapshotForAI()
|
const snapshot = await (targetPage as any)._snapshotForAI()
|
||||||
// Sanitize to remove unpaired surrogates that break JSON encoding for Claude API
|
// 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', {
|
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', {
|
||||||
context: contextLines,
|
context: 3,
|
||||||
})
|
})
|
||||||
if (patch.split('\n').length <= 4) {
|
if (patch.split('\n').length <= 4) {
|
||||||
return 'No changes detected since last snapshot'
|
return 'No changes detected since last snapshot'
|
||||||
@@ -747,10 +747,9 @@ server.tool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines = snapshotStr.split('\n')
|
const lines = snapshotStr.split('\n')
|
||||||
const matches: { line: string; index: number }[] = []
|
const matches: string[] = []
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (const line of lines) {
|
||||||
const line = lines[i]
|
|
||||||
let isMatch = false
|
let isMatch = false
|
||||||
if (isRegExp(search)) {
|
if (isRegExp(search)) {
|
||||||
isMatch = search.test(line)
|
isMatch = search.test(line)
|
||||||
@@ -759,7 +758,7 @@ server.tool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
matches.push({ line, index: i })
|
matches.push(line)
|
||||||
if (matches.length >= 10) break
|
if (matches.length >= 10) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -768,13 +767,7 @@ server.tool(
|
|||||||
return 'No matches found'
|
return 'No matches found'
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches
|
return matches.join('\n')
|
||||||
.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')
|
|
||||||
}
|
}
|
||||||
throw new Error('accessibilitySnapshot is not available on this page')
|
throw new Error('accessibilitySnapshot is not available on this page')
|
||||||
}
|
}
|
||||||
@@ -878,6 +871,7 @@ server.tool(
|
|||||||
state: userState,
|
state: userState,
|
||||||
console: customConsole,
|
console: customConsole,
|
||||||
accessibilitySnapshot,
|
accessibilitySnapshot,
|
||||||
|
getCleanHTML,
|
||||||
getLocatorStringForElement,
|
getLocatorStringForElement,
|
||||||
getLatestLogs,
|
getLatestLogs,
|
||||||
clearAllLogs,
|
clearAllLogs,
|
||||||
@@ -898,6 +892,7 @@ server.tool(
|
|||||||
state: userState,
|
state: userState,
|
||||||
console: customConsole,
|
console: customConsole,
|
||||||
accessibilitySnapshot,
|
accessibilitySnapshot,
|
||||||
|
getCleanHTML,
|
||||||
getLocatorStringForElement,
|
getLocatorStringForElement,
|
||||||
getLatestLogs,
|
getLatestLogs,
|
||||||
clearAllLogs,
|
clearAllLogs,
|
||||||
|
|||||||
@@ -38,13 +38,18 @@ If nothing changed, try `await page.waitForLoadState('networkidle', {timeout: 30
|
|||||||
## accessibility snapshots
|
## accessibility snapshots
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await accessibilitySnapshot({ page, search?, contextLines?, showDiffSinceLastCall? })
|
await accessibilitySnapshot({ page, search?, showDiffSinceLastCall? })
|
||||||
```
|
```
|
||||||
|
|
||||||
- `search` - string/regex to filter results (returns first 10 matches with context)
|
- `search` - string/regex to filter results (returns first 10 matching lines)
|
||||||
- `contextLines` - lines of context around matches (default: 10)
|
|
||||||
- `showDiffSinceLastCall` - returns diff since last snapshot (useful after actions)
|
- `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:
|
Example output:
|
||||||
|
|
||||||
```md
|
```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()))`
|
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:
|
**waitForPageLoad** - smart load detection that ignores analytics/ads:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
Reference in New Issue
Block a user