format
This commit is contained in:
@@ -270,7 +270,7 @@
|
||||
|
||||
- **`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.)
|
||||
- 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
|
||||
@@ -342,9 +342,9 @@
|
||||
### Usage
|
||||
|
||||
```js
|
||||
const { snapshot, labelCount } = await showAriaRefLabels({ page });
|
||||
await page.screenshot({ path: '/tmp/labeled-page.png' });
|
||||
await page.locator('aria-ref=e5').click();
|
||||
const { snapshot, labelCount } = await showAriaRefLabels({ page })
|
||||
await page.screenshot({ path: '/tmp/labeled-page.png' })
|
||||
await page.locator('aria-ref=e5').click()
|
||||
// Labels auto-hide after 30 seconds, or call hideAriaRefLabels({ page }) manually
|
||||
```
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* Consolidated build script for all client-side JavaScript bundles.
|
||||
*
|
||||
*
|
||||
* These bundles are injected into pages via CDP Runtime.evaluate or page.evaluate().
|
||||
* All bundles are built as browser-targeted IIFEs that expose their APIs on globalThis.
|
||||
*
|
||||
*
|
||||
* Injection flow (see react-source.ts, page-markdown.ts for examples):
|
||||
* 1. Bundle is read from dist/*.js via fs.readFileSync (cached after first read)
|
||||
* 2. Check if already injected: `await page.evaluate(() => !!globalThis.__name)`
|
||||
* 3. If not present, inject: `await page.evaluate(code)` or `cdp.send('Runtime.evaluate', { expression: code })`
|
||||
* 4. Use the exposed global in subsequent evaluate calls: `globalThis.__readability`, `globalThis.__bippy`, etc.
|
||||
*
|
||||
*
|
||||
* Each bundle uses a separate Bun.build() call (not multiple entrypoints in one call)
|
||||
* to ensure fully self-contained output with no shared chunks.
|
||||
*
|
||||
*
|
||||
* Two types of bundles:
|
||||
* 1. Source file bundles - directly bundle a TypeScript source file
|
||||
* 2. Wrapper bundles - create entry code that imports from npm packages and exposes on globalThis
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Generates markdown resource files for the MCP at build time.
|
||||
*
|
||||
*
|
||||
* These files are written to:
|
||||
* - playwriter/dist/ - for the MCP to read at runtime
|
||||
* - website/public/ - for hosting on playwriter.dev
|
||||
*
|
||||
*
|
||||
* Source of truth:
|
||||
* - playwriter/src/skill.md - manually edited, contains full docs including CLI usage
|
||||
* - skills/playwriter/SKILL.md - stub with frontmatter for agent discovery
|
||||
*
|
||||
*
|
||||
* Generated files:
|
||||
* - playwriter/dist/prompt.md - MCP prompt (skill.md minus CLI sections)
|
||||
* - website/public/SKILL.md - full copy for playwriter.dev/SKILL.md
|
||||
@@ -40,26 +40,24 @@ function readFile(relativePath: string): string {
|
||||
function writeToDestinations(filename: string, content: string) {
|
||||
ensureDir(distDir)
|
||||
ensureDir(websitePublicDir)
|
||||
|
||||
|
||||
const distPath = path.join(distDir, filename)
|
||||
const websitePath = path.join(websitePublicDir, filename)
|
||||
|
||||
|
||||
fs.writeFileSync(distPath, content, 'utf-8')
|
||||
fs.writeFileSync(websitePath, content, 'utf-8')
|
||||
|
||||
|
||||
console.log(`Generated ${filename}`)
|
||||
}
|
||||
|
||||
function cleanTypes(typesContent: string): string {
|
||||
return typesContent
|
||||
.replace(/\/\/# sourceMappingURL=.*$/gm, '')
|
||||
.trim()
|
||||
return typesContent.replace(/\/\/# sourceMappingURL=.*$/gm, '').trim()
|
||||
}
|
||||
|
||||
function buildDebuggerApi() {
|
||||
const debuggerTypes = cleanTypes(readFile('dist/debugger.d.ts'))
|
||||
const debuggerExamples = readFile('src/debugger-examples.ts')
|
||||
|
||||
|
||||
const content = dedent`
|
||||
# Debugger API Reference
|
||||
|
||||
@@ -75,14 +73,14 @@ function buildDebuggerApi() {
|
||||
${debuggerExamples}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
|
||||
writeToDestinations('debugger-api.md', content)
|
||||
}
|
||||
|
||||
function buildEditorApi() {
|
||||
const editorTypes = cleanTypes(readFile('dist/editor.d.ts'))
|
||||
const editorExamples = readFile('src/editor-examples.ts')
|
||||
|
||||
|
||||
const content = dedent`
|
||||
# Editor API Reference
|
||||
|
||||
@@ -100,14 +98,14 @@ function buildEditorApi() {
|
||||
${editorExamples}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
|
||||
writeToDestinations('editor-api.md', content)
|
||||
}
|
||||
|
||||
function buildStylesApi() {
|
||||
const stylesTypes = cleanTypes(readFile('dist/styles.d.ts'))
|
||||
const stylesExamples = readFile('src/styles-examples.ts')
|
||||
|
||||
|
||||
const content = dedent`
|
||||
# Styles API Reference
|
||||
|
||||
@@ -125,24 +123,24 @@ function buildStylesApi() {
|
||||
${stylesExamples}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
|
||||
writeToDestinations('styles-api.md', content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes CLI-related sections from skill.md to create prompt.md for the MCP.
|
||||
*
|
||||
*
|
||||
* Sections removed:
|
||||
* - "## CLI Usage" section and all its subsections
|
||||
*/
|
||||
function stripCliSectionsFromSkill(skillContent: string): string {
|
||||
// Parse markdown tokens
|
||||
const tokens = Lexer.lex(skillContent)
|
||||
|
||||
|
||||
// Filter out CLI Usage section and its subsections
|
||||
const filteredTokens: Token[] = []
|
||||
let skipUntilLevel: number | null = null
|
||||
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'heading') {
|
||||
const heading = token as Tokens.Heading
|
||||
@@ -156,27 +154,34 @@ function stripCliSectionsFromSkill(skillContent: string): string {
|
||||
skipUntilLevel = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (skipUntilLevel === null) {
|
||||
filteredTokens.push(token)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Reconstruct markdown from tokens
|
||||
return filteredTokens.map((token) => { return token.raw }).join('').trim() + '\n'
|
||||
return (
|
||||
filteredTokens
|
||||
.map((token) => {
|
||||
return token.raw
|
||||
})
|
||||
.join('')
|
||||
.trim() + '\n'
|
||||
)
|
||||
}
|
||||
|
||||
function buildPromptFromSkill() {
|
||||
// Read skill.md as source of truth
|
||||
const skillPath = path.join(playwriterDir, 'src', 'skill.md')
|
||||
const skillContent = fs.readFileSync(skillPath, 'utf-8')
|
||||
|
||||
|
||||
// Generate prompt.md for MCP (without CLI sections)
|
||||
const promptContent = stripCliSectionsFromSkill(skillContent)
|
||||
const distPromptPath = path.join(distDir, 'prompt.md')
|
||||
fs.writeFileSync(distPromptPath, promptContent, 'utf-8')
|
||||
console.log('Generated playwriter/dist/prompt.md (from skill.md)')
|
||||
|
||||
|
||||
// Copy full skill.md to website/public/ for hosting at playwriter.dev/SKILL.md
|
||||
const websitePublicRoot = path.join(playwriterDir, '..', 'website', 'public')
|
||||
ensureDir(websitePublicRoot)
|
||||
@@ -193,10 +198,10 @@ function parseFrontmatter(content: string): { frontmatter: Record<string, string
|
||||
if (!match) {
|
||||
return { frontmatter: {}, body: content }
|
||||
}
|
||||
|
||||
|
||||
const yamlContent = match[1]
|
||||
const body = match[2]
|
||||
|
||||
|
||||
// Simple YAML parsing for key: value pairs
|
||||
const frontmatter: Record<string, string> = {}
|
||||
for (const line of yamlContent.split('\n')) {
|
||||
@@ -207,17 +212,17 @@ function parseFrontmatter(content: string): { frontmatter: Record<string, string
|
||||
frontmatter[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { frontmatter, body }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Well-Known Skills Discovery structure.
|
||||
*
|
||||
*
|
||||
* Creates:
|
||||
* - /.well-known/skills/index.json - discovery endpoint
|
||||
* - /.well-known/skills/playwriter/SKILL.md - skill file
|
||||
*
|
||||
*
|
||||
* See: https://agentskills.io/specification
|
||||
*/
|
||||
function buildWellKnownSkills() {
|
||||
@@ -226,35 +231,31 @@ function buildWellKnownSkills() {
|
||||
const websitePublicRoot = path.join(repoRoot, 'website', 'public')
|
||||
const wellKnownDir = path.join(websitePublicRoot, '.well-known', 'skills')
|
||||
const playwriterSkillDir = path.join(wellKnownDir, 'playwriter')
|
||||
|
||||
|
||||
// Read and parse the skill file
|
||||
const skillContent = fs.readFileSync(skillSourcePath, 'utf-8')
|
||||
const { frontmatter } = parseFrontmatter(skillContent)
|
||||
|
||||
|
||||
// Ensure directories exist
|
||||
ensureDir(wellKnownDir)
|
||||
ensureDir(playwriterSkillDir)
|
||||
|
||||
|
||||
// Copy SKILL.md to well-known location
|
||||
fs.writeFileSync(path.join(playwriterSkillDir, 'SKILL.md'), skillContent, 'utf-8')
|
||||
console.log('Generated website/public/.well-known/skills/playwriter/SKILL.md')
|
||||
|
||||
|
||||
// Generate index.json
|
||||
const indexJson = {
|
||||
skills: [
|
||||
{
|
||||
name: frontmatter.name || 'playwriter',
|
||||
description: frontmatter.description || '',
|
||||
files: ['SKILL.md']
|
||||
}
|
||||
]
|
||||
files: ['SKILL.md'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(wellKnownDir, 'index.json'),
|
||||
JSON.stringify(indexJson, null, 2) + '\n',
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
fs.writeFileSync(path.join(wellKnownDir, 'index.json'), JSON.stringify(indexJson, null, 2) + '\n', 'utf-8')
|
||||
console.log('Generated website/public/.well-known/skills/index.json')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,50 @@
|
||||
import playwright from 'playwright-core'
|
||||
|
||||
async function main() {
|
||||
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
|
||||
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {
|
||||
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
|
||||
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {})
|
||||
|
||||
})
|
||||
const contexts = browser.contexts()
|
||||
console.log(`Found ${contexts.length} browser context(s)`)
|
||||
|
||||
const contexts = browser.contexts()
|
||||
console.log(`Found ${contexts.length} browser context(s)`)
|
||||
// Sleep 200 ms
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
for (const context of contexts) {
|
||||
const pages = context.pages()
|
||||
console.log(`Context has ${pages.length} page(s):`)
|
||||
|
||||
// Sleep 200 ms
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
for (const context of contexts) {
|
||||
const pages = context.pages()
|
||||
console.log(`Context has ${pages.length} page(s):`)
|
||||
for (const page of pages) {
|
||||
await page.emulateMedia({ colorScheme: null })
|
||||
const url = page.url()
|
||||
console.log(`\nPage URL: ${url}`)
|
||||
|
||||
for (const page of pages) {
|
||||
await page.emulateMedia({colorScheme: null, })
|
||||
const url = page.url()
|
||||
console.log(`\nPage URL: ${url}`)
|
||||
const html = await page.content()
|
||||
const lines = html.split('\n').slice(0, 3)
|
||||
console.log('First 3 lines of HTML:')
|
||||
lines.forEach((line, i) => {
|
||||
console.log(` ${i + 1}: ${line}`)
|
||||
})
|
||||
// Watch for browser console logs and log them in Node.js
|
||||
page.on('console', (msg) => {
|
||||
console.log(`Browser log: [${msg.type()}] ${msg.text()}`)
|
||||
})
|
||||
|
||||
const html = await page.content()
|
||||
const lines = html.split('\n').slice(0, 3)
|
||||
console.log('First 3 lines of HTML:')
|
||||
lines.forEach((line, i) => {
|
||||
console.log(` ${i + 1}: ${line}`)
|
||||
})
|
||||
// Watch for browser console logs and log them in Node.js
|
||||
page.on('console', (msg) => {
|
||||
console.log(`Browser log: [${msg.type()}] ${msg.text()}`)
|
||||
})
|
||||
|
||||
console.log(`running eval`)
|
||||
// Evaluate a sum in the browser and log something from inside the browser context
|
||||
const sumResult = await page.evaluate(() => {
|
||||
console.log('Logging from inside browser context!')
|
||||
return 1 + 2 + 3
|
||||
})
|
||||
console.log(`Sum result evaluated in browser: ${sumResult}`)
|
||||
if ((page as any)._snapshotForAI) {
|
||||
const snapshot = await (page as any)._snapshotForAI()
|
||||
const snapshotStr =
|
||||
typeof snapshot === 'string'
|
||||
? snapshot
|
||||
: JSON.stringify(snapshot)
|
||||
console.log(
|
||||
'First 100 chars of _snapshotForAI():',
|
||||
snapshotStr.slice(0, 100),
|
||||
)
|
||||
} else {
|
||||
console.log('_snapshotForAI is not available on this page.')
|
||||
}
|
||||
}
|
||||
console.log(`running eval`)
|
||||
// Evaluate a sum in the browser and log something from inside the browser context
|
||||
const sumResult = await page.evaluate(() => {
|
||||
console.log('Logging from inside browser context!')
|
||||
return 1 + 2 + 3
|
||||
})
|
||||
console.log(`Sum result evaluated in browser: ${sumResult}`)
|
||||
if ((page as any)._snapshotForAI) {
|
||||
const snapshot = await (page as any)._snapshotForAI()
|
||||
const snapshotStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot)
|
||||
console.log('First 100 chars of _snapshotForAI():', snapshotStr.slice(0, 100))
|
||||
} else {
|
||||
console.log('_snapshotForAI is not available on this page.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import playwright from 'playwright-core'
|
||||
|
||||
async function main() {
|
||||
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
|
||||
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {
|
||||
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
|
||||
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {})
|
||||
|
||||
const contexts = browser.contexts()
|
||||
console.log(`Found ${contexts.length} browser context(s)`)
|
||||
|
||||
// Sleep 200 ms
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
for (const context of contexts) {
|
||||
const pages = context.pages()
|
||||
console.log(`Context has ${pages.length} page(s):`)
|
||||
// Log urls of current pages
|
||||
pages.forEach((page, idx) => {
|
||||
console.log(` Page ${idx + 1} URL: ${page.url()}`)
|
||||
})
|
||||
|
||||
const contexts = browser.contexts()
|
||||
console.log(`Found ${contexts.length} browser context(s)`)
|
||||
|
||||
// Sleep 200 ms
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
for (const context of contexts) {
|
||||
const pages = context.pages()
|
||||
console.log(`Context has ${pages.length} page(s):`)
|
||||
// Log urls of current pages
|
||||
pages.forEach((page, idx) => {
|
||||
console.log(` Page ${idx + 1} URL: ${page.url()}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import playwright from 'playwright-core'
|
||||
|
||||
async function main() {
|
||||
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
|
||||
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint)
|
||||
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
|
||||
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint)
|
||||
|
||||
const contexts = browser.contexts()
|
||||
console.log(`Found ${contexts.length} browser context(s)`)
|
||||
const contexts = browser.contexts()
|
||||
console.log(`Found ${contexts.length} browser context(s)`)
|
||||
|
||||
// Sleep 200 ms
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
for (const context of contexts) {
|
||||
const pages = context.pages()
|
||||
console.log(`Context has ${pages.length} page(s):`)
|
||||
// Create a new page
|
||||
const newPage = await context.newPage()
|
||||
// Evaluate a sum (e.g., 2 + 3) and log the result
|
||||
const sumResult = await newPage.evaluate(() => 2 + 3)
|
||||
console.log(`Evaluated sum 2 + 3 = ${sumResult}`)
|
||||
// Sleep 200 ms
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
for (const context of contexts) {
|
||||
const pages = context.pages()
|
||||
console.log(`Context has ${pages.length} page(s):`)
|
||||
// Create a new page
|
||||
const newPage = await context.newPage()
|
||||
// Evaluate a sum (e.g., 2 + 3) and log the result
|
||||
const sumResult = await newPage.evaluate(() => 2 + 3)
|
||||
console.log(`Evaluated sum 2 + 3 = ${sumResult}`)
|
||||
|
||||
// Sleep 1 second
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
// Close the page
|
||||
await newPage.close()
|
||||
}
|
||||
// Sleep 1 second
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
// Close the page
|
||||
await newPage.close()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -4,7 +4,6 @@ async function main() {
|
||||
const server = await startPlayWriterCDPRelayServer({ port: 19988 })
|
||||
|
||||
console.log('Server running. Press Ctrl+C to stop.')
|
||||
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -234,7 +234,8 @@ export function hideA11yLabels(): void {
|
||||
// Expose on globalThis for injection
|
||||
// ============================================================================
|
||||
|
||||
;(globalThis as { __a11y?: { renderA11yLabels: (labels: A11yLabel[]) => number; hideA11yLabels: () => void } }).__a11y = {
|
||||
renderA11yLabels,
|
||||
hideA11yLabels,
|
||||
}
|
||||
;(globalThis as { __a11y?: { renderA11yLabels: (labels: A11yLabel[]) => number; hideA11yLabels: () => void } }).__a11y =
|
||||
{
|
||||
renderA11yLabels,
|
||||
hideA11yLabels,
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ async function createHtmlServer({ htmlByPath }: { htmlByPath: Record<string, str
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,10 @@ describe('aria-snapshot', () => {
|
||||
try {
|
||||
await page.goto(outerServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 10000 })
|
||||
await page.locator('[data-testid="external-iframe"]').waitFor({ timeout: 5000 })
|
||||
await page.frameLocator('[data-testid="external-iframe"]').locator('[data-testid="iframe-button"]').waitFor({ timeout: 5000 })
|
||||
await page
|
||||
.frameLocator('[data-testid="external-iframe"]')
|
||||
.locator('[data-testid="iframe-button"]')
|
||||
.waitFor({ timeout: 5000 })
|
||||
|
||||
// Convert iframe Locator to Frame
|
||||
const iframeHandle = await page.locator('[data-testid="external-iframe"]').elementHandle()
|
||||
|
||||
+189
-98
@@ -11,11 +11,14 @@ import { Sema } from 'async-sema'
|
||||
import type { ICDPSession } from './cdp-session.js'
|
||||
import { getCDPSessionForPage } from './cdp-session.js'
|
||||
|
||||
|
||||
// Import sharp at module level - resolves to null if not available
|
||||
const sharpPromise = import('sharp')
|
||||
.then((m) => { return m.default })
|
||||
.catch(() => { return null })
|
||||
.then((m) => {
|
||||
return m.default
|
||||
})
|
||||
.catch(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Snapshot Format Types
|
||||
@@ -142,9 +145,7 @@ const INTERACTIVE_ROLES = new Set([
|
||||
'audio',
|
||||
])
|
||||
|
||||
const LABEL_ROLES = new Set([
|
||||
'labeltext',
|
||||
])
|
||||
const LABEL_ROLES = new Set(['labeltext'])
|
||||
|
||||
const MAX_LABEL_POSITION_CONCURRENCY = 24
|
||||
const BOX_MODEL_TIMEOUT_MS = 5000
|
||||
@@ -165,12 +166,7 @@ const CONTEXT_ROLES = new Set([
|
||||
'cell',
|
||||
])
|
||||
|
||||
const SKIP_WRAPPER_ROLES = new Set([
|
||||
'generic',
|
||||
'group',
|
||||
'none',
|
||||
'presentation',
|
||||
])
|
||||
const SKIP_WRAPPER_ROLES = new Set(['generic', 'group', 'none', 'presentation'])
|
||||
|
||||
const TEST_ID_ATTRS = [
|
||||
'data-testid',
|
||||
@@ -231,7 +227,15 @@ function buildLocatorFromStable(stable: { value: string; attr: string }): string
|
||||
return `[${stable.attr}="${escaped}"]`
|
||||
}
|
||||
|
||||
function buildBaseLocator({ role, name, stable }: { role: string; name: string; stable: { value: string; attr: string } | null }): string {
|
||||
function buildBaseLocator({
|
||||
role,
|
||||
name,
|
||||
stable,
|
||||
}: {
|
||||
role: string
|
||||
name: string
|
||||
stable: { value: string; attr: string } | null
|
||||
}): string {
|
||||
if (stable) {
|
||||
return buildLocatorFromStable(stable)
|
||||
}
|
||||
@@ -243,7 +247,6 @@ function buildBaseLocator({ role, name, stable }: { role: string; name: string;
|
||||
return `role=${role}`
|
||||
}
|
||||
|
||||
|
||||
function getAxValueString(value?: Protocol.Accessibility.AXValue): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
@@ -283,7 +286,13 @@ export type SnapshotNode = {
|
||||
children: SnapshotNode[]
|
||||
}
|
||||
|
||||
function buildSnapshotLine({ role, name, baseLocator, indent, hasChildren }: {
|
||||
function buildSnapshotLine({
|
||||
role,
|
||||
name,
|
||||
baseLocator,
|
||||
indent,
|
||||
hasChildren,
|
||||
}: {
|
||||
role: string
|
||||
name: string
|
||||
baseLocator?: string
|
||||
@@ -308,15 +317,16 @@ function buildTextLine(text: string, indent: number): SnapshotLine {
|
||||
export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] {
|
||||
return nodes.flatMap((node) => {
|
||||
const nodeIndent = indent + (node.indentOffset ?? 0)
|
||||
const line = node.role === 'text'
|
||||
? buildTextLine(node.name, nodeIndent)
|
||||
: buildSnapshotLine({
|
||||
role: node.role,
|
||||
name: node.name,
|
||||
baseLocator: node.baseLocator,
|
||||
indent: nodeIndent,
|
||||
hasChildren: node.children.length > 0,
|
||||
})
|
||||
const line =
|
||||
node.role === 'text'
|
||||
? buildTextLine(node.name, nodeIndent)
|
||||
: buildSnapshotLine({
|
||||
role: node.role,
|
||||
name: node.name,
|
||||
baseLocator: node.baseLocator,
|
||||
indent: nodeIndent,
|
||||
hasChildren: node.children.length > 0,
|
||||
})
|
||||
return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)]
|
||||
})
|
||||
}
|
||||
@@ -339,13 +349,15 @@ export function buildRawSnapshotTree(options: {
|
||||
|
||||
const role = getAxRole(node)
|
||||
const name = getAxValueString(node.name).trim()
|
||||
const children = (node.childIds ?? []).map((childId) => {
|
||||
return buildRawSnapshotTree({
|
||||
nodeId: childId,
|
||||
axById: options.axById,
|
||||
isNodeInScope: options.isNodeInScope,
|
||||
const children = (node.childIds ?? [])
|
||||
.map((childId) => {
|
||||
return buildRawSnapshotTree({
|
||||
nodeId: childId,
|
||||
axById: options.axById,
|
||||
isNodeInScope: options.isNodeInScope,
|
||||
})
|
||||
})
|
||||
}).filter(isTruthy)
|
||||
.filter(isTruthy)
|
||||
|
||||
const inScope = options.isNodeInScope(node) || children.length > 0
|
||||
if (!inScope) {
|
||||
@@ -367,7 +379,11 @@ export function filterInteractiveSnapshotTree(options: {
|
||||
labelContext: boolean
|
||||
refFilter?: (entry: { role: string; name: string }) => boolean
|
||||
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
|
||||
createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
|
||||
createRefForNode: (options: {
|
||||
backendNodeId?: Protocol.DOM.BackendNodeId
|
||||
role: string
|
||||
name: string
|
||||
}) => string | null
|
||||
}): { nodes: SnapshotNode[]; names: Set<string> } {
|
||||
const role = options.node.role
|
||||
const name = options.node.name
|
||||
@@ -476,7 +492,11 @@ export function filterFullSnapshotTree(options: {
|
||||
ancestorNames: string[]
|
||||
refFilter?: (entry: { role: string; name: string }) => boolean
|
||||
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
|
||||
createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
|
||||
createRefForNode: (options: {
|
||||
backendNodeId?: Protocol.DOM.BackendNodeId
|
||||
role: string
|
||||
name: string
|
||||
}) => string | null
|
||||
}): { nodes: SnapshotNode[]; names: Set<string> } {
|
||||
const role = options.node.role
|
||||
const name = options.node.name
|
||||
@@ -613,18 +633,20 @@ export function finalizeSnapshotOutput(
|
||||
}, [])
|
||||
|
||||
let lineLocatorIndex = 0
|
||||
const snapshot = lines.map((line) => {
|
||||
let text = line.text
|
||||
if (line.baseLocator) {
|
||||
const locator = locatorSequence[lineLocatorIndex]
|
||||
lineLocatorIndex += 1
|
||||
text = buildLocatorLineText({ line, locator })
|
||||
}
|
||||
if (line.hasChildren) {
|
||||
text += ':'
|
||||
}
|
||||
return text
|
||||
}).join('\n')
|
||||
const snapshot = lines
|
||||
.map((line) => {
|
||||
let text = line.text
|
||||
if (line.baseLocator) {
|
||||
const locator = locatorSequence[lineLocatorIndex]
|
||||
lineLocatorIndex += 1
|
||||
text = buildLocatorLineText({ line, locator })
|
||||
}
|
||||
if (line.hasChildren) {
|
||||
text += ':'
|
||||
}
|
||||
return text
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
let nodeLocatorIndex = 0
|
||||
const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => {
|
||||
@@ -676,7 +698,11 @@ function buildDomIndex(nodes: Protocol.DOM.Node[]): {
|
||||
return { domById, domByBackendId, childrenByParent }
|
||||
}
|
||||
|
||||
function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrValue: string): Protocol.DOM.NodeId | null {
|
||||
function findScopeRootNodeId(
|
||||
nodes: Protocol.DOM.Node[],
|
||||
attrName: string,
|
||||
attrValue: string,
|
||||
): Protocol.DOM.NodeId | null {
|
||||
for (const node of nodes) {
|
||||
if (!node.attributes) {
|
||||
continue
|
||||
@@ -692,7 +718,11 @@ function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrV
|
||||
return null
|
||||
}
|
||||
|
||||
function buildBackendIdSet(rootNodeId: Protocol.DOM.NodeId, childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>, domById: Map<Protocol.DOM.NodeId, DomNodeInfo>): Set<Protocol.DOM.BackendNodeId> {
|
||||
function buildBackendIdSet(
|
||||
rootNodeId: Protocol.DOM.NodeId,
|
||||
childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>,
|
||||
domById: Map<Protocol.DOM.NodeId, DomNodeInfo>,
|
||||
): Set<Protocol.DOM.BackendNodeId> {
|
||||
const result = new Set<Protocol.DOM.BackendNodeId>()
|
||||
const stack: Protocol.DOM.NodeId[] = [rootNodeId]
|
||||
while (stack.length > 0) {
|
||||
@@ -786,7 +816,14 @@ async function resolveFrame({ frame, page }: { frame?: Frame | FrameLocator; pag
|
||||
* await page.locator(selector).click()
|
||||
* ```
|
||||
*/
|
||||
export async function getAriaSnapshot({ page, frame, locator, refFilter, interactiveOnly = false, cdp }: {
|
||||
export async function getAriaSnapshot({
|
||||
page,
|
||||
frame,
|
||||
locator,
|
||||
refFilter,
|
||||
interactiveOnly = false,
|
||||
cdp,
|
||||
}: {
|
||||
page: Page
|
||||
frame?: Frame | FrameLocator
|
||||
locator?: Locator
|
||||
@@ -794,29 +831,29 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
||||
interactiveOnly?: boolean
|
||||
cdp?: ICDPSession
|
||||
}): Promise<AriaSnapshotResult> {
|
||||
const session = cdp || await getCDPSessionForPage({ page })
|
||||
const session = cdp || (await getCDPSessionForPage({ page }))
|
||||
|
||||
// Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame())
|
||||
// is a scoping helper without CDP access. We need the real Frame from page.frames()
|
||||
// which has frameId() for OOPIF session attachment.
|
||||
const resolvedFrame = await resolveFrame({ frame, page })
|
||||
|
||||
|
||||
// For cross-origin iframes (OOPIFs), we need to attach to the iframe's target
|
||||
// to get a separate CDP session. Same-origin iframes can use frameId directly.
|
||||
let oopifSessionId: string | null = null
|
||||
const frameId = resolvedFrame?.frameId() ?? null
|
||||
|
||||
|
||||
if (frameId) {
|
||||
const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse
|
||||
const { targetInfos } = (await session.send('Target.getTargets')) as Protocol.Target.GetTargetsResponse
|
||||
const frameUrl = resolvedFrame!.url()
|
||||
const iframeTarget = targetInfos.find((t) => {
|
||||
return t.type === 'iframe' && t.url === frameUrl
|
||||
})
|
||||
if (iframeTarget) {
|
||||
const { sessionId } = await session.send('Target.attachToTarget', {
|
||||
const { sessionId } = (await session.send('Target.attachToTarget', {
|
||||
targetId: iframeTarget.targetId,
|
||||
flatten: true,
|
||||
}) as Protocol.Target.AttachToTargetResponse
|
||||
})) as Protocol.Target.AttachToTargetResponse
|
||||
oopifSessionId = sessionId
|
||||
await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId)
|
||||
}
|
||||
@@ -831,13 +868,20 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
||||
|
||||
try {
|
||||
if (scopeLocator) {
|
||||
await scopeLocator.evaluate((element, data) => {
|
||||
element.setAttribute(data.attr, data.value)
|
||||
}, { attr: scopeAttr, value: scopeValue })
|
||||
await scopeLocator.evaluate(
|
||||
(element, data) => {
|
||||
element.setAttribute(data.attr, data.value)
|
||||
},
|
||||
{ attr: scopeAttr, value: scopeValue },
|
||||
)
|
||||
scopeApplied = true
|
||||
}
|
||||
|
||||
const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }, oopifSessionId) as Protocol.DOM.GetFlattenedDocumentResponse
|
||||
const { nodes: domNodes } = (await session.send(
|
||||
'DOM.getFlattenedDocument',
|
||||
{ depth: -1, pierce: true },
|
||||
oopifSessionId,
|
||||
)) as Protocol.DOM.GetFlattenedDocumentResponse
|
||||
const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes)
|
||||
|
||||
let scopeRootNodeId: Protocol.DOM.NodeId | null = null
|
||||
@@ -852,12 +896,14 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
||||
}
|
||||
}
|
||||
|
||||
const allowedBackendIds = scopeRootNodeId
|
||||
? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById)
|
||||
: null
|
||||
const allowedBackendIds = scopeRootNodeId ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) : null
|
||||
|
||||
const axParams = !oopifSessionId && frameId ? { frameId } : undefined
|
||||
const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree', axParams, oopifSessionId) as Protocol.Accessibility.GetFullAXTreeResponse
|
||||
const { nodes: axNodes } = (await session.send(
|
||||
'Accessibility.getFullAXTree',
|
||||
axParams,
|
||||
oopifSessionId,
|
||||
)) as Protocol.Accessibility.GetFullAXTreeResponse
|
||||
|
||||
const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
|
||||
for (const node of axNodes) {
|
||||
@@ -937,16 +983,18 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
||||
return allowedBackendIds.has(node.backendDOMNodeId)
|
||||
}
|
||||
|
||||
|
||||
let snapshotNodes: SnapshotNode[] = []
|
||||
if (rootAxNodeId) {
|
||||
const rootNode = axById.get(rootAxNodeId)
|
||||
const rootRole = rootNode ? getAxRole(rootNode) : ''
|
||||
const rawRoots = rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
|
||||
? rootNode.childIds.map((childId) => {
|
||||
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
|
||||
}).filter(isTruthy)
|
||||
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
|
||||
const rawRoots =
|
||||
rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
|
||||
? rootNode.childIds
|
||||
.map((childId) => {
|
||||
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
|
||||
})
|
||||
.filter(isTruthy)
|
||||
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
|
||||
|
||||
const filtered = rawRoots.flatMap((rawNode) => {
|
||||
if (interactiveOnly) {
|
||||
@@ -1027,7 +1075,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const matchingRefs = await page.evaluate(
|
||||
@@ -1037,7 +1085,16 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
||||
return null
|
||||
}
|
||||
|
||||
const testIdAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-pw', 'data-qa', 'data-e2e', 'data-automation-id']
|
||||
const testIdAttrs = [
|
||||
'data-testid',
|
||||
'data-test-id',
|
||||
'data-test',
|
||||
'data-cy',
|
||||
'data-pw',
|
||||
'data-qa',
|
||||
'data-e2e',
|
||||
'data-automation-id',
|
||||
]
|
||||
for (const attr of testIdAttrs) {
|
||||
const value = target.getAttribute(attr)
|
||||
if (value) {
|
||||
@@ -1066,7 +1123,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
||||
{
|
||||
targets: targetHandles,
|
||||
refData: result.refs,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return matchingRefs.map((ref) => {
|
||||
@@ -1140,7 +1197,7 @@ async function getLabelBoxesForRefs({
|
||||
cdp?: ICDPSession
|
||||
}): Promise<AriaLabel[]> {
|
||||
const log = logger?.info ?? logger?.error ?? console.error
|
||||
const session = cdp || await getCDPSessionForPage({ page })
|
||||
const session = cdp || (await getCDPSessionForPage({ page }))
|
||||
const sema = new Sema(maxConcurrency)
|
||||
const labelRefs = refs.filter((ref) => {
|
||||
return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)
|
||||
@@ -1161,14 +1218,20 @@ async function getLabelBoxesForRefs({
|
||||
await sema.acquire()
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
session.send('DOM.getBoxModel', { backendNodeId: ref.backendNodeId }) as Promise<Protocol.DOM.GetBoxModelResponse>,
|
||||
session.send('DOM.getBoxModel', {
|
||||
backendNodeId: ref.backendNodeId,
|
||||
}) as Promise<Protocol.DOM.GetBoxModelResponse>,
|
||||
new Promise<null>((resolve) => {
|
||||
setTimeout(() => { resolve(null) }, BOX_MODEL_TIMEOUT_MS)
|
||||
setTimeout(() => {
|
||||
resolve(null)
|
||||
}, BOX_MODEL_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
completed++
|
||||
if (completed % 50 === 0 || completed === labelRefs.length) {
|
||||
log(`[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`)
|
||||
log(
|
||||
`[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`,
|
||||
)
|
||||
}
|
||||
if (!response) {
|
||||
timedOut++
|
||||
@@ -1186,9 +1249,11 @@ async function getLabelBoxesForRefs({
|
||||
} finally {
|
||||
sema.release()
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
log(
|
||||
`[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`,
|
||||
)
|
||||
log(`[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`)
|
||||
return labels.filter(isTruthy)
|
||||
} finally {
|
||||
if (!cdp) {
|
||||
@@ -1217,7 +1282,12 @@ async function getLabelBoxesForRefs({
|
||||
* await page.locator('[data-testid="submit-btn"]').click()
|
||||
* ```
|
||||
*/
|
||||
export async function showAriaRefLabels({ page, locator, interactiveOnly = true, logger }: {
|
||||
export async function showAriaRefLabels({
|
||||
page,
|
||||
locator,
|
||||
interactiveOnly = true,
|
||||
logger,
|
||||
}: {
|
||||
page: Page
|
||||
locator?: Locator
|
||||
interactiveOnly?: boolean
|
||||
@@ -1240,11 +1310,15 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true,
|
||||
try {
|
||||
const snapshotStart = Date.now()
|
||||
const { snapshot, refs } = await getAriaSnapshot({ page, locator, interactiveOnly, cdp })
|
||||
const shortRefMap = new Map(refs.map((entry) => {
|
||||
return [entry.ref, entry.shortRef]
|
||||
}))
|
||||
const shortRefMap = new Map(
|
||||
refs.map((entry) => {
|
||||
return [entry.ref, entry.shortRef]
|
||||
}),
|
||||
)
|
||||
const interactiveRefs = refs.filter((ref) => Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role))
|
||||
log(`[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`)
|
||||
log(
|
||||
`[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`,
|
||||
)
|
||||
|
||||
const rootHandle = locator ? await locator.elementHandle() : null
|
||||
|
||||
@@ -1259,22 +1333,30 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true,
|
||||
log(`[showAriaRefLabels] getLabelBoxesForRefs: ${Date.now() - labelsStart}ms (${labels.length} boxes)`)
|
||||
|
||||
const renderStart = Date.now()
|
||||
const labelCount = await page.evaluate(({ entries, root, interactiveOnly: intOnly }) => {
|
||||
const a11y = (globalThis as {
|
||||
__a11y?: {
|
||||
renderA11yLabels?: (labels: typeof entries) => number
|
||||
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => { labelCount: number }
|
||||
const labelCount = await page.evaluate(
|
||||
({ entries, root, interactiveOnly: intOnly }) => {
|
||||
const a11y = (
|
||||
globalThis as {
|
||||
__a11y?: {
|
||||
renderA11yLabels?: (labels: typeof entries) => number
|
||||
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => {
|
||||
labelCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
).__a11y
|
||||
if (a11y?.renderA11yLabels) {
|
||||
return a11y.renderA11yLabels(entries)
|
||||
}
|
||||
}).__a11y
|
||||
if (a11y?.renderA11yLabels) {
|
||||
return a11y.renderA11yLabels(entries)
|
||||
}
|
||||
if (a11y?.computeA11ySnapshot) {
|
||||
const rootElement = root || document.body
|
||||
return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true }).labelCount
|
||||
}
|
||||
throw new Error('a11y client not loaded')
|
||||
}, { entries: shortLabels, root: rootHandle, interactiveOnly })
|
||||
if (a11y?.computeA11ySnapshot) {
|
||||
const rootElement = root || document.body
|
||||
return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true })
|
||||
.labelCount
|
||||
}
|
||||
throw new Error('a11y client not loaded')
|
||||
},
|
||||
{ entries: shortLabels, root: rootHandle, interactiveOnly },
|
||||
)
|
||||
|
||||
log(`[showAriaRefLabels] renderA11yLabels: ${Date.now() - renderStart}ms (${labelCount} labels)`)
|
||||
log(`[showAriaRefLabels] total: ${Date.now() - startTime}ms`)
|
||||
@@ -1324,7 +1406,13 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
|
||||
* await page.locator('[data-testid="submit-btn"]').click()
|
||||
* ```
|
||||
*/
|
||||
export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, collector, logger }: {
|
||||
export async function screenshotWithAccessibilityLabels({
|
||||
page,
|
||||
locator,
|
||||
interactiveOnly = true,
|
||||
collector,
|
||||
logger,
|
||||
}: {
|
||||
page: Page
|
||||
locator?: Locator
|
||||
interactiveOnly?: boolean
|
||||
@@ -1351,7 +1439,10 @@ export async function screenshotWithAccessibilityLabels({ page, locator, interac
|
||||
const screenshotPath = path.join(tmpDir, filename)
|
||||
|
||||
// Get viewport size to clip screenshot to visible area
|
||||
const viewport = await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })') as { width: number; height: number }
|
||||
const viewport = (await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })')) as {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// Max 1568px on any edge (larger gets auto-resized by Claude, adding latency)
|
||||
// Token formula: tokens = (width * height) / 750
|
||||
|
||||
@@ -29,61 +29,85 @@ describe('aria-snapshot tree filters', () => {
|
||||
const buttonId = '8' as Protocol.Accessibility.AXNodeId
|
||||
|
||||
const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>([
|
||||
[rootId, {
|
||||
nodeId: rootId,
|
||||
ignored: false,
|
||||
role: roleValue('rootwebarea'),
|
||||
childIds: [mainId, navId],
|
||||
}],
|
||||
[mainId, {
|
||||
nodeId: mainId,
|
||||
ignored: false,
|
||||
role: roleValue('main'),
|
||||
childIds: [headingId, buttonId],
|
||||
backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[navId, {
|
||||
nodeId: navId,
|
||||
ignored: false,
|
||||
role: roleValue('navigation'),
|
||||
childIds: [listId],
|
||||
backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[listId, {
|
||||
nodeId: listId,
|
||||
ignored: false,
|
||||
role: roleValue('list'),
|
||||
childIds: [listItemId],
|
||||
backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[listItemId, {
|
||||
nodeId: listItemId,
|
||||
ignored: false,
|
||||
role: roleValue('listitem'),
|
||||
childIds: [linkId],
|
||||
backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[linkId, {
|
||||
nodeId: linkId,
|
||||
ignored: false,
|
||||
role: roleValue('link'),
|
||||
name: nameValue('Docs'),
|
||||
backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[headingId, {
|
||||
nodeId: headingId,
|
||||
ignored: false,
|
||||
role: roleValue('heading'),
|
||||
name: nameValue('Title'),
|
||||
backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[buttonId, {
|
||||
nodeId: buttonId,
|
||||
ignored: false,
|
||||
role: roleValue('button'),
|
||||
name: nameValue('Submit'),
|
||||
backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[
|
||||
rootId,
|
||||
{
|
||||
nodeId: rootId,
|
||||
ignored: false,
|
||||
role: roleValue('rootwebarea'),
|
||||
childIds: [mainId, navId],
|
||||
},
|
||||
],
|
||||
[
|
||||
mainId,
|
||||
{
|
||||
nodeId: mainId,
|
||||
ignored: false,
|
||||
role: roleValue('main'),
|
||||
childIds: [headingId, buttonId],
|
||||
backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId,
|
||||
},
|
||||
],
|
||||
[
|
||||
navId,
|
||||
{
|
||||
nodeId: navId,
|
||||
ignored: false,
|
||||
role: roleValue('navigation'),
|
||||
childIds: [listId],
|
||||
backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId,
|
||||
},
|
||||
],
|
||||
[
|
||||
listId,
|
||||
{
|
||||
nodeId: listId,
|
||||
ignored: false,
|
||||
role: roleValue('list'),
|
||||
childIds: [listItemId],
|
||||
backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId,
|
||||
},
|
||||
],
|
||||
[
|
||||
listItemId,
|
||||
{
|
||||
nodeId: listItemId,
|
||||
ignored: false,
|
||||
role: roleValue('listitem'),
|
||||
childIds: [linkId],
|
||||
backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId,
|
||||
},
|
||||
],
|
||||
[
|
||||
linkId,
|
||||
{
|
||||
nodeId: linkId,
|
||||
ignored: false,
|
||||
role: roleValue('link'),
|
||||
name: nameValue('Docs'),
|
||||
backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId,
|
||||
},
|
||||
],
|
||||
[
|
||||
headingId,
|
||||
{
|
||||
nodeId: headingId,
|
||||
ignored: false,
|
||||
role: roleValue('heading'),
|
||||
name: nameValue('Title'),
|
||||
backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId,
|
||||
},
|
||||
],
|
||||
[
|
||||
buttonId,
|
||||
{
|
||||
nodeId: buttonId,
|
||||
ignored: false,
|
||||
role: roleValue('button'),
|
||||
name: nameValue('Submit'),
|
||||
backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const allowed = new Set<Protocol.DOM.BackendNodeId>([204 as Protocol.DOM.BackendNodeId])
|
||||
@@ -145,25 +169,19 @@ describe('aria-snapshot tree filters', () => {
|
||||
role: 'navigation',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'link', name: 'Home', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
],
|
||||
children: [{ role: 'link', name: 'Home', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] }],
|
||||
},
|
||||
{
|
||||
role: 'labeltext',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'statictext', name: 'Email', ignored: false, children: [] },
|
||||
],
|
||||
children: [{ role: 'statictext', name: 'Email', ignored: false, children: [] }],
|
||||
},
|
||||
{
|
||||
role: 'generic',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'button', name: 'Save', backendNodeId: 1 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
],
|
||||
children: [{ role: 'button', name: 'Save', backendNodeId: 1 as Protocol.DOM.BackendNodeId, children: [] }],
|
||||
},
|
||||
{
|
||||
role: 'generic',
|
||||
@@ -186,35 +204,51 @@ describe('aria-snapshot tree filters', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>([
|
||||
[1 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 10 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 1 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-btn']]),
|
||||
}],
|
||||
[2 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 11 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'A',
|
||||
attributes: new Map([['data-testid', 'nav-home']]),
|
||||
}],
|
||||
[3 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 12 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 3 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'ignored-action']]),
|
||||
}],
|
||||
const domByBackendId = new Map<
|
||||
Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}
|
||||
>([
|
||||
[
|
||||
1 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 10 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 1 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-btn']]),
|
||||
},
|
||||
],
|
||||
[
|
||||
2 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 11 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'A',
|
||||
attributes: new Map([['data-testid', 'nav-home']]),
|
||||
},
|
||||
],
|
||||
[
|
||||
3 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 12 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 3 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'ignored-action']]),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
let refCounter = 0
|
||||
const createRefForNode = (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }): string => {
|
||||
const createRefForNode = (options: {
|
||||
backendNodeId?: Protocol.DOM.BackendNodeId
|
||||
role: string
|
||||
name: string
|
||||
}): string => {
|
||||
refCounter += 1
|
||||
return `${options.role}-${options.name}-${refCounter}`
|
||||
}
|
||||
@@ -317,31 +351,43 @@ describe('aria-snapshot tree filters', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>([
|
||||
[2 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 20 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'INPUT',
|
||||
attributes: new Map([['data-testid', 'email-input']]),
|
||||
}],
|
||||
[3 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 21 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 3 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-primary']]),
|
||||
}],
|
||||
[4 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 22 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 4 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-secondary']]),
|
||||
}],
|
||||
const domByBackendId = new Map<
|
||||
Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}
|
||||
>([
|
||||
[
|
||||
2 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 20 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'INPUT',
|
||||
attributes: new Map([['data-testid', 'email-input']]),
|
||||
},
|
||||
],
|
||||
[
|
||||
3 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 21 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 3 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-primary']]),
|
||||
},
|
||||
],
|
||||
[
|
||||
4 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 22 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 4 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-secondary']]),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
let refCounter = 0
|
||||
@@ -432,13 +478,16 @@ describe('aria-snapshot tree filters', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>()
|
||||
const domByBackendId = new Map<
|
||||
Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}
|
||||
>()
|
||||
|
||||
const createRefForNode = (): string | null => {
|
||||
return null
|
||||
@@ -491,25 +540,34 @@ describe('aria-snapshot tree filters', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>([
|
||||
[5 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 30 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 5 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'delete']]),
|
||||
}],
|
||||
[6 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 31 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 6 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save']]),
|
||||
}],
|
||||
const domByBackendId = new Map<
|
||||
Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}
|
||||
>([
|
||||
[
|
||||
5 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 30 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 5 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'delete']]),
|
||||
},
|
||||
],
|
||||
[
|
||||
6 as Protocol.DOM.BackendNodeId,
|
||||
{
|
||||
nodeId: 31 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 6 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save']]),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
let refCounter = 0
|
||||
|
||||
@@ -41,7 +41,10 @@ function createTruncatingReplacer({ maxStringLength }: { maxStringLength: number
|
||||
}
|
||||
}
|
||||
|
||||
export function createCdpLogger({ logFilePath, maxStringLength }: { logFilePath?: string; maxStringLength?: number } = {}): CdpLogger {
|
||||
export function createCdpLogger({
|
||||
logFilePath,
|
||||
maxStringLength,
|
||||
}: { logFilePath?: string; maxStringLength?: number } = {}): CdpLogger {
|
||||
const resolvedLogFilePath = logFilePath || LOG_CDP_FILE_PATH
|
||||
const logDir = path.dirname(resolvedLogFilePath)
|
||||
if (!fs.existsSync(logDir)) {
|
||||
|
||||
+684
-606
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,15 @@ export interface ICDPSession {
|
||||
sessionId?: string | null,
|
||||
): Promise<ProtocolMapping.Commands[K]['returnType']>
|
||||
|
||||
on<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): unknown
|
||||
on<K extends keyof ProtocolMapping.Events>(
|
||||
event: K,
|
||||
callback: (params: ProtocolMapping.Events[K][0]) => void,
|
||||
): unknown
|
||||
|
||||
off<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): unknown
|
||||
off<K extends keyof ProtocolMapping.Events>(
|
||||
event: K,
|
||||
callback: (params: ProtocolMapping.Events[K][0]) => void,
|
||||
): unknown
|
||||
|
||||
detach(): Promise<void>
|
||||
getSessionId?(): string | null
|
||||
@@ -46,7 +52,10 @@ export class PlaywrightCDPSessionAdapter implements ICDPSession {
|
||||
return this
|
||||
}
|
||||
|
||||
off<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): this {
|
||||
off<K extends keyof ProtocolMapping.Events>(
|
||||
event: K,
|
||||
callback: (params: ProtocolMapping.Events[K][0]) => void,
|
||||
): this {
|
||||
this._playwrightSession.off(event as never, callback as never)
|
||||
return this
|
||||
}
|
||||
|
||||
+51
-51
@@ -1,55 +1,55 @@
|
||||
import type { Protocol } from 'devtools-protocol';
|
||||
import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js';
|
||||
import type { Protocol } from 'devtools-protocol'
|
||||
import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'
|
||||
|
||||
export type CDPCommandSource = 'playwriter';
|
||||
export type CDPCommandSource = 'playwriter'
|
||||
|
||||
export type CDPCommandFor<T extends keyof ProtocolMapping.Commands> = {
|
||||
id: number;
|
||||
sessionId?: string;
|
||||
method: T;
|
||||
params?: ProtocolMapping.Commands[T]['paramsType'][0];
|
||||
source?: CDPCommandSource;
|
||||
};
|
||||
id: number
|
||||
sessionId?: string
|
||||
method: T
|
||||
params?: ProtocolMapping.Commands[T]['paramsType'][0]
|
||||
source?: CDPCommandSource
|
||||
}
|
||||
|
||||
export type CDPCommand = {
|
||||
[K in keyof ProtocolMapping.Commands]: CDPCommandFor<K>;
|
||||
}[keyof ProtocolMapping.Commands];
|
||||
[K in keyof ProtocolMapping.Commands]: CDPCommandFor<K>
|
||||
}[keyof ProtocolMapping.Commands]
|
||||
|
||||
export type CDPResponseFor<T extends keyof ProtocolMapping.Commands> = {
|
||||
id: number;
|
||||
sessionId?: string;
|
||||
result?: ProtocolMapping.Commands[T]['returnType'];
|
||||
error?: { code?: number; message: string };
|
||||
};
|
||||
id: number
|
||||
sessionId?: string
|
||||
result?: ProtocolMapping.Commands[T]['returnType']
|
||||
error?: { code?: number; message: string }
|
||||
}
|
||||
|
||||
export type CDPResponse = {
|
||||
[K in keyof ProtocolMapping.Commands]: CDPResponseFor<K>;
|
||||
}[keyof ProtocolMapping.Commands];
|
||||
[K in keyof ProtocolMapping.Commands]: CDPResponseFor<K>
|
||||
}[keyof ProtocolMapping.Commands]
|
||||
|
||||
export type CDPEventFor<T extends keyof ProtocolMapping.Events> = {
|
||||
method: T;
|
||||
sessionId?: string;
|
||||
params?: ProtocolMapping.Events[T][0];
|
||||
};
|
||||
method: T
|
||||
sessionId?: string
|
||||
params?: ProtocolMapping.Events[T][0]
|
||||
}
|
||||
|
||||
export type CDPEvent = {
|
||||
[K in keyof ProtocolMapping.Events]: CDPEventFor<K>;
|
||||
}[keyof ProtocolMapping.Events];
|
||||
[K in keyof ProtocolMapping.Events]: CDPEventFor<K>
|
||||
}[keyof ProtocolMapping.Events]
|
||||
|
||||
export type CDPResponseBase = {
|
||||
id: number;
|
||||
sessionId?: string;
|
||||
result?: unknown;
|
||||
error?: { code?: number; message: string };
|
||||
};
|
||||
id: number
|
||||
sessionId?: string
|
||||
result?: unknown
|
||||
error?: { code?: number; message: string }
|
||||
}
|
||||
|
||||
export type CDPEventBase = {
|
||||
method: string;
|
||||
sessionId?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
method: string
|
||||
sessionId?: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export type CDPMessage = CDPCommand | CDPResponse | CDPEvent;
|
||||
export type CDPMessage = CDPCommand | CDPResponse | CDPEvent
|
||||
|
||||
export type RelayServerEvents = {
|
||||
'cdp:command': (data: { clientId: string; command: CDPCommand }) => void
|
||||
@@ -57,14 +57,14 @@ export type RelayServerEvents = {
|
||||
'cdp:response': (data: { clientId: string; response: CDPResponseBase; command: CDPCommand }) => void
|
||||
}
|
||||
|
||||
export { Protocol, ProtocolMapping };
|
||||
export { Protocol, ProtocolMapping }
|
||||
|
||||
// types tests. to see if types are right with some simple examples
|
||||
if (false as any) {
|
||||
const browserVersionCommand = {
|
||||
id: 1,
|
||||
method: 'Browser.getVersion',
|
||||
} satisfies CDPCommand;
|
||||
} satisfies CDPCommand
|
||||
|
||||
const browserVersionResponse = {
|
||||
id: 1,
|
||||
@@ -74,8 +74,8 @@ if (false as any) {
|
||||
revision: '123',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
jsVersion: 'V8',
|
||||
}
|
||||
} satisfies CDPResponse;
|
||||
},
|
||||
} satisfies CDPResponse
|
||||
|
||||
const targetAttachCommand = {
|
||||
id: 2,
|
||||
@@ -83,13 +83,13 @@ if (false as any) {
|
||||
params: {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: false,
|
||||
}
|
||||
} satisfies CDPCommand;
|
||||
},
|
||||
} satisfies CDPCommand
|
||||
|
||||
const targetAttachResponse = {
|
||||
id: 2,
|
||||
result: undefined,
|
||||
} satisfies CDPResponse;
|
||||
} satisfies CDPResponse
|
||||
|
||||
const attachedToTargetEvent = {
|
||||
method: 'Target.attachedToTarget',
|
||||
@@ -104,8 +104,8 @@ if (false as any) {
|
||||
canAccessOpener: false,
|
||||
},
|
||||
waitingForDebugger: false,
|
||||
}
|
||||
} satisfies CDPEvent;
|
||||
},
|
||||
} satisfies CDPEvent
|
||||
|
||||
const consoleMessageEvent = {
|
||||
method: 'Runtime.consoleAPICalled',
|
||||
@@ -114,23 +114,23 @@ if (false as any) {
|
||||
args: [],
|
||||
executionContextId: 1,
|
||||
timestamp: 123456789,
|
||||
}
|
||||
} satisfies CDPEvent;
|
||||
},
|
||||
} satisfies CDPEvent
|
||||
|
||||
const pageNavigateCommand = {
|
||||
id: 3,
|
||||
method: 'Page.navigate',
|
||||
params: {
|
||||
url: 'https://example.com',
|
||||
}
|
||||
} satisfies CDPCommand;
|
||||
},
|
||||
} satisfies CDPCommand
|
||||
|
||||
const pageNavigateResponse = {
|
||||
id: 3,
|
||||
result: {
|
||||
frameId: 'frame-1',
|
||||
}
|
||||
} satisfies CDPResponse;
|
||||
},
|
||||
} satisfies CDPResponse
|
||||
|
||||
const networkRequestEvent = {
|
||||
method: 'Network.requestWillBeSent',
|
||||
@@ -153,6 +153,6 @@ if (false as any) {
|
||||
},
|
||||
redirectHasExtraInfo: false,
|
||||
type: 'XHR',
|
||||
}
|
||||
} satisfies CDPEvent;
|
||||
},
|
||||
} satisfies CDPEvent
|
||||
}
|
||||
|
||||
+49
-37
@@ -12,7 +12,13 @@ Buffer.prototype[util.inspect.custom] = function () {
|
||||
}
|
||||
import { killPortProcess } from './kill-port.js'
|
||||
import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js'
|
||||
import { ensureRelayServer, RELAY_PORT, waitForExtension, getExtensionOutdatedWarning, getExtensionStatus } from './relay-client.js'
|
||||
import {
|
||||
ensureRelayServer,
|
||||
RELAY_PORT,
|
||||
waitForExtension,
|
||||
getExtensionOutdatedWarning,
|
||||
getExtensionStatus,
|
||||
} from './relay-client.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -76,7 +82,7 @@ async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]>
|
||||
if (!fallback.ok) {
|
||||
return []
|
||||
}
|
||||
const fallbackData = await fallback.json() as {
|
||||
const fallbackData = (await fallback.json()) as {
|
||||
connected: boolean
|
||||
activeTargets: number
|
||||
browser: string | null
|
||||
@@ -86,16 +92,18 @@ async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]>
|
||||
if (!fallbackData?.connected) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
extensionId: 'default',
|
||||
stableKey: undefined,
|
||||
browser: fallbackData?.browser,
|
||||
profile: fallbackData?.profile,
|
||||
activeTargets: fallbackData?.activeTargets,
|
||||
playwriterVersion: fallbackData?.playwriterVersion || null,
|
||||
}]
|
||||
return [
|
||||
{
|
||||
extensionId: 'default',
|
||||
stableKey: undefined,
|
||||
browser: fallbackData?.browser,
|
||||
profile: fallbackData?.profile,
|
||||
activeTargets: fallbackData?.activeTargets,
|
||||
playwriterVersion: fallbackData?.playwriterVersion || null,
|
||||
},
|
||||
]
|
||||
}
|
||||
const data = await response.json() as {
|
||||
const data = (await response.json()) as {
|
||||
extensions: ExtensionStatus[]
|
||||
}
|
||||
return data?.extensions || []
|
||||
@@ -150,7 +158,9 @@ async function executeCode(options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token || process.env.PLAYWRITER_TOKEN ? { 'Authorization': `Bearer ${token || process.env.PLAYWRITER_TOKEN}` } : {}),
|
||||
...(token || process.env.PLAYWRITER_TOKEN
|
||||
? { Authorization: `Bearer ${token || process.env.PLAYWRITER_TOKEN}` }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({ sessionId, code, timeout, cwd }),
|
||||
})
|
||||
@@ -161,7 +171,11 @@ async function executeCode(options: {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const result = await response.json() as { text: string; images: Array<{ data: string; mimeType: string }>; isError: boolean }
|
||||
const result = (await response.json()) as {
|
||||
text: string
|
||||
images: Array<{ data: string; mimeType: string }>
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
// Print output
|
||||
if (result.text) {
|
||||
@@ -206,7 +220,6 @@ cli
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const extensions = await fetchExtensionsStatus(options.host)
|
||||
if (extensions.length === 0) {
|
||||
console.error('No connected browsers detected. Click the Playwriter extension icon.')
|
||||
@@ -253,9 +266,10 @@ cli
|
||||
|
||||
try {
|
||||
const serverUrl = await getServerUrl(options.host)
|
||||
const extensionId = selectedExtension.extensionId === 'default'
|
||||
? null
|
||||
: (selectedExtension.stableKey || selectedExtension.extensionId)
|
||||
const extensionId =
|
||||
selectedExtension.extensionId === 'default'
|
||||
? null
|
||||
: selectedExtension.stableKey || selectedExtension.extensionId
|
||||
const cwd = process.cwd()
|
||||
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
||||
method: 'POST',
|
||||
@@ -267,7 +281,7 @@ cli
|
||||
console.error(`Error: ${response.status} ${text}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const result = await response.json() as { id: string; extensionId: string | null }
|
||||
const result = (await response.json()) as { id: string; extensionId: string | null }
|
||||
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
|
||||
} catch (error: any) {
|
||||
console.error(`Error: ${error.message}`)
|
||||
@@ -300,7 +314,7 @@ cli
|
||||
console.error(`Error: ${response.status} ${await response.text()}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const result = await response.json() as {
|
||||
const result = (await response.json()) as {
|
||||
sessions: Array<{
|
||||
id: string
|
||||
stateKeys: string[]
|
||||
@@ -335,7 +349,7 @@ cli
|
||||
' ' +
|
||||
'EXT'.padEnd(extensionWidth) +
|
||||
' ' +
|
||||
'STATE KEYS'
|
||||
'STATE KEYS',
|
||||
)
|
||||
console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8))
|
||||
|
||||
@@ -351,7 +365,7 @@ cli
|
||||
' ' +
|
||||
(session.extensionId || '-').padEnd(extensionWidth) +
|
||||
' ' +
|
||||
stateStr
|
||||
stateStr,
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -374,7 +388,7 @@ cli
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json() as { error: string }
|
||||
const result = (await response.json()) as { error: string }
|
||||
console.error(`Error: ${result.error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -410,8 +424,10 @@ cli
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const result = await response.json() as { success: boolean; pageUrl: string; pagesCount: number }
|
||||
console.log(`Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`)
|
||||
const result = (await response.json()) as { success: boolean; pageUrl: string; pagesCount: number }
|
||||
console.log(
|
||||
`Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`,
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error(`Error: ${error.message}`)
|
||||
process.exit(1)
|
||||
@@ -512,20 +528,16 @@ cli
|
||||
})
|
||||
})
|
||||
|
||||
cli
|
||||
.command('logfile', 'Print the path to the relay server log file')
|
||||
.action(() => {
|
||||
console.log(`relay: ${LOG_FILE_PATH}`)
|
||||
console.log(`cdp: ${LOG_CDP_FILE_PATH}`)
|
||||
})
|
||||
cli.command('logfile', 'Print the path to the relay server log file').action(() => {
|
||||
console.log(`relay: ${LOG_FILE_PATH}`)
|
||||
console.log(`cdp: ${LOG_CDP_FILE_PATH}`)
|
||||
})
|
||||
|
||||
cli
|
||||
.command('skill', 'Print the full playwriter usage instructions')
|
||||
.action(() => {
|
||||
const skillPath = path.join(__dirname, '..', 'src', 'skill.md')
|
||||
const content = fs.readFileSync(skillPath, 'utf-8')
|
||||
console.log(content)
|
||||
})
|
||||
cli.command('skill', 'Print the full playwriter usage instructions').action(() => {
|
||||
const skillPath = path.join(__dirname, '..', 'src', 'skill.md')
|
||||
const content = fs.readFileSync(skillPath, 'utf-8')
|
||||
console.log(content)
|
||||
})
|
||||
|
||||
cli.help()
|
||||
cli.version(VERSION)
|
||||
|
||||
@@ -21,9 +21,11 @@ export function createFileLogger({ logFilePath }: { logFilePath?: string } = {})
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
|
||||
const log = (...args: unknown[]): Promise<void> => {
|
||||
const message = args.map(arg =>
|
||||
typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false, maxStringLength: 1000 })
|
||||
).join(' ')
|
||||
const message = args
|
||||
.map((arg) =>
|
||||
typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false, maxStringLength: 1000 }),
|
||||
)
|
||||
.join(' ')
|
||||
queue = queue.then(() => fs.promises.appendFile(resolvedLogFilePath, stripAnsi(message) + '\n'))
|
||||
return queue
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ export declare const page: Page
|
||||
export declare const getCDPSession: (options: { page: Page }) => Promise<ICDPSession>
|
||||
export declare const createDebugger: (options: { cdp: ICDPSession }) => Debugger
|
||||
export declare const createEditor: (options: { cdp: ICDPSession }) => Editor
|
||||
export declare const getStylesForLocator: (options: { locator: Locator; includeUserAgentStyles?: boolean }) => Promise<StylesResult>
|
||||
export declare const getStylesForLocator: (options: {
|
||||
locator: Locator
|
||||
includeUserAgentStyles?: boolean
|
||||
}) => Promise<StylesResult>
|
||||
export declare const formatStylesAsText: (styles: StylesResult) => string
|
||||
export declare const console: { log: (...args: unknown[]) => void }
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface EvaluateResult {
|
||||
value: unknown
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface ScriptInfo {
|
||||
scriptId: string
|
||||
url: string
|
||||
@@ -533,9 +531,7 @@ export class Debugger {
|
||||
async listScripts({ search }: { search?: string } = {}): Promise<ScriptInfo[]> {
|
||||
await this.enable()
|
||||
const scripts = Array.from(this.scripts.values())
|
||||
const filtered = search
|
||||
? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase()))
|
||||
: scripts
|
||||
const filtered = search ? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase())) : scripts
|
||||
return filtered.slice(0, 20)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface CreateSmartDiffOptions {
|
||||
|
||||
/**
|
||||
* Creates a smart diff that returns full content when changes exceed threshold.
|
||||
*
|
||||
*
|
||||
* When more than `threshold` (default 50%) of lines have changed, showing a diff
|
||||
* is not useful - we return the full new content instead.
|
||||
*/
|
||||
@@ -54,10 +54,7 @@ export function createSmartDiff(options: CreateSmartDiffOptions): SmartDiffResul
|
||||
const changeRatio = Math.min(changedLines / maxLines, 1) // Cap at 100%
|
||||
|
||||
// Build unified diff string from structured patch
|
||||
const diffLines: string[] = [
|
||||
`--- ${label} (previous)`,
|
||||
`+++ ${label} (current)`,
|
||||
]
|
||||
const diffLines: string[] = [`--- ${label} (previous)`, `+++ ${label} (current)`]
|
||||
for (const hunk of patch.hunks) {
|
||||
diffLines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`)
|
||||
diffLines.push(...hunk.lines)
|
||||
|
||||
@@ -145,4 +145,14 @@ async function searchStyles() {
|
||||
console.log(matches)
|
||||
}
|
||||
|
||||
export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript, readStylesheet, editStylesheet, searchStyles }
|
||||
export {
|
||||
listScripts,
|
||||
readScript,
|
||||
editScript,
|
||||
searchScripts,
|
||||
writeScript,
|
||||
editInlineScript,
|
||||
readStylesheet,
|
||||
editStylesheet,
|
||||
searchStyles,
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ export class Editor {
|
||||
private async setSource(
|
||||
id: { scriptId: string } | { styleSheetId: string },
|
||||
content: string,
|
||||
dryRun = false
|
||||
dryRun = false,
|
||||
): Promise<EditResult> {
|
||||
if ('styleSheetId' in id) {
|
||||
await this.cdp.send('CSS.setStyleSheetText', { styleSheetId: id.styleSheetId, text: content })
|
||||
@@ -410,7 +410,15 @@ export class Editor {
|
||||
* @param options.content - New content
|
||||
* @param options.dryRun - If true, validate without applying (default false, only works for JS)
|
||||
*/
|
||||
async write({ url, content, dryRun = false }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult> {
|
||||
async write({
|
||||
url,
|
||||
content,
|
||||
dryRun = false,
|
||||
}: {
|
||||
url: string
|
||||
content: string
|
||||
dryRun?: boolean
|
||||
}): Promise<EditResult> {
|
||||
await this.enable()
|
||||
const id = this.getIdByUrl(url)
|
||||
return this.setSource(id, content, dryRun)
|
||||
|
||||
+52
-14
@@ -452,7 +452,7 @@ export class PlaywrightExecutor {
|
||||
|
||||
private setupPageConsoleListener(page: Page) {
|
||||
// Use targetId() if available, fallback to internal _guid for CDP connections
|
||||
const targetId = page.targetId() || (page as any)._guid as string | undefined
|
||||
const targetId = page.targetId() || ((page as any)._guid as string | undefined)
|
||||
if (!targetId) {
|
||||
return
|
||||
}
|
||||
@@ -488,7 +488,11 @@ export class PlaywrightExecutor {
|
||||
})
|
||||
}
|
||||
|
||||
private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null }> {
|
||||
private async checkExtensionStatus(): Promise<{
|
||||
connected: boolean
|
||||
activeTargets: number
|
||||
playwriterVersion: string | null
|
||||
}> {
|
||||
const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
|
||||
const { httpBaseUrl } = parseRelayHost(host, port)
|
||||
const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
|
||||
@@ -504,10 +508,19 @@ export class PlaywrightExecutor {
|
||||
if (!fallback.ok) {
|
||||
return notConnected
|
||||
}
|
||||
return (await fallback.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
|
||||
return (await fallback.json()) as {
|
||||
connected: boolean
|
||||
activeTargets: number
|
||||
playwriterVersion: string | null
|
||||
}
|
||||
}
|
||||
const data = await response.json() as {
|
||||
extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number; playwriterVersion?: string | null }>
|
||||
const data = (await response.json()) as {
|
||||
extensions: Array<{
|
||||
extensionId: string
|
||||
stableKey?: string
|
||||
activeTargets: number
|
||||
playwriterVersion?: string | null
|
||||
}>
|
||||
}
|
||||
const extension = data.extensions.find((item) => {
|
||||
return item.extensionId === extensionId || item.stableKey === extensionId
|
||||
@@ -515,7 +528,11 @@ export class PlaywrightExecutor {
|
||||
if (!extension) {
|
||||
return notConnected
|
||||
}
|
||||
return { connected: true, activeTargets: extension.activeTargets, playwriterVersion: extension?.playwriterVersion || null }
|
||||
return {
|
||||
connected: true,
|
||||
activeTargets: extension.activeTargets,
|
||||
playwriterVersion: extension?.playwriterVersion || null,
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${httpBaseUrl}/extension/status`, {
|
||||
@@ -663,7 +680,13 @@ export class PlaywrightExecutor {
|
||||
const formattedArgs = args
|
||||
.map((arg) => {
|
||||
if (typeof arg === 'string') return arg
|
||||
return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
|
||||
return util.inspect(arg, {
|
||||
depth: 4,
|
||||
colors: false,
|
||||
maxArrayLength: 100,
|
||||
maxStringLength: 1000,
|
||||
breakLength: 80,
|
||||
})
|
||||
})
|
||||
.join(' ')
|
||||
text += `[${method}] ${formattedArgs}\n`
|
||||
@@ -710,14 +733,25 @@ export class PlaywrightExecutor {
|
||||
/** Only include interactive elements (default: true) */
|
||||
interactiveOnly?: boolean
|
||||
}) => {
|
||||
const { page: targetPage, frame, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options
|
||||
const {
|
||||
page: targetPage,
|
||||
frame,
|
||||
locator,
|
||||
search,
|
||||
showDiffSinceLastCall = true,
|
||||
interactiveOnly = false,
|
||||
} = options
|
||||
const resolvedPage = targetPage || page
|
||||
if (!resolvedPage) {
|
||||
throw new Error('snapshot requires a page')
|
||||
}
|
||||
|
||||
// Use new in-page implementation via getAriaSnapshot
|
||||
const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
|
||||
const {
|
||||
snapshot: rawSnapshot,
|
||||
refs,
|
||||
getSelectorForRef,
|
||||
} = await getAriaSnapshot({
|
||||
page: resolvedPage,
|
||||
frame,
|
||||
locator,
|
||||
@@ -829,7 +863,7 @@ export class PlaywrightExecutor {
|
||||
|
||||
if (filterPage) {
|
||||
// Use targetId() if available, fallback to internal _guid for CDP connections
|
||||
const targetId = filterPage.targetId() || (filterPage as any)._guid as string | undefined
|
||||
const targetId = filterPage.targetId() || ((filterPage as any)._guid as string | undefined)
|
||||
if (!targetId) {
|
||||
throw new Error('Could not get page targetId')
|
||||
}
|
||||
@@ -984,9 +1018,7 @@ export class PlaywrightExecutor {
|
||||
|
||||
const vmContext = vm.createContext(vmContextObj)
|
||||
const autoReturn = shouldAutoReturn(code)
|
||||
const wrappedCode = autoReturn
|
||||
? `(async () => { return await (${code}) })()`
|
||||
: `(async () => { ${code} })()`
|
||||
const wrappedCode = autoReturn ? `(async () => { return await (${code}) })()` : `(async () => { ${code} })()`
|
||||
const hasExplicitReturn = autoReturn || /\breturn\b/.test(code)
|
||||
|
||||
const result = await Promise.race([
|
||||
@@ -1003,7 +1035,13 @@ export class PlaywrightExecutor {
|
||||
const formatted =
|
||||
typeof resolvedResult === 'string'
|
||||
? resolvedResult
|
||||
: util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
|
||||
: util.inspect(resolvedResult, {
|
||||
depth: 4,
|
||||
colors: false,
|
||||
maxArrayLength: 100,
|
||||
maxStringLength: 1000,
|
||||
breakLength: 80,
|
||||
})
|
||||
if (formatted.trim()) {
|
||||
responseText += `[return value] ${formatted}\n`
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,9 +40,7 @@ export type GhostBrowserCommandParams = {
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
export type GhostBrowserCommandResult =
|
||||
| { success: true; result: unknown }
|
||||
| { success: false; error: string }
|
||||
export type GhostBrowserCommandResult = { success: true; result: unknown } | { success: false; error: string }
|
||||
|
||||
/**
|
||||
* Function signature for sending ghost-browser commands.
|
||||
@@ -52,7 +50,7 @@ export type GhostBrowserCommandResult =
|
||||
export type SendGhostBrowserCommand = (
|
||||
namespace: GhostBrowserNamespace,
|
||||
method: string,
|
||||
args: unknown[]
|
||||
args: unknown[],
|
||||
) => Promise<unknown>
|
||||
|
||||
// =============================================================================
|
||||
@@ -66,7 +64,7 @@ export type SendGhostBrowserCommand = (
|
||||
function createGhostBrowserProxy(
|
||||
namespace: GhostBrowserNamespace,
|
||||
constants: Record<string, unknown>,
|
||||
sendCommand: SendGhostBrowserCommand
|
||||
sendCommand: SendGhostBrowserCommand,
|
||||
) {
|
||||
return new Proxy(constants, {
|
||||
get(target, prop: string) {
|
||||
@@ -108,7 +106,7 @@ export function createGhostBrowserChrome(sendCommand: SendGhostBrowserCommand) {
|
||||
*/
|
||||
export async function handleGhostBrowserCommand(
|
||||
params: GhostBrowserCommandParams,
|
||||
chromeApi: typeof chrome
|
||||
chromeApi: typeof chrome,
|
||||
): Promise<GhostBrowserCommandResult> {
|
||||
const { namespace, method, args } = params
|
||||
|
||||
|
||||
@@ -7977,8 +7977,12 @@ test('processes x.com.html with size savings', async () => {
|
||||
|
||||
console.log(`\n📊 x.com.html processing stats:`)
|
||||
console.log(` Original: ${originalSize.toLocaleString()} chars (${originalTokens.toLocaleString()} tokens)`)
|
||||
console.log(` Without styles: ${processedSize.toLocaleString()} chars (${processedTokens.toLocaleString()} tokens) - ${savingsPercent}% savings`)
|
||||
console.log(` With styles: ${withStylesSize.toLocaleString()} chars (${withStylesTokens.toLocaleString()} tokens) - ${withStylesPercent}% savings`)
|
||||
console.log(
|
||||
` Without styles: ${processedSize.toLocaleString()} chars (${processedTokens.toLocaleString()} tokens) - ${savingsPercent}% savings`,
|
||||
)
|
||||
console.log(
|
||||
` With styles: ${withStylesSize.toLocaleString()} chars (${withStylesTokens.toLocaleString()} tokens) - ${withStylesPercent}% savings`,
|
||||
)
|
||||
|
||||
await expect(result).toMatchFileSnapshot('./__snapshots__/x.com.processed.html')
|
||||
await expect(resultWithStyles).toMatchFileSnapshot('./__snapshots__/x.com.processed.withStyles.html')
|
||||
|
||||
+361
-399
@@ -2,427 +2,389 @@ import posthtml from 'posthtml'
|
||||
import beautify from 'posthtml-beautify'
|
||||
|
||||
export interface FormatHtmlOptions {
|
||||
html: string
|
||||
keepStyles?: boolean
|
||||
maxAttrLen?: number
|
||||
maxContentLen?: number
|
||||
html: string
|
||||
keepStyles?: boolean
|
||||
maxAttrLen?: number
|
||||
maxContentLen?: number
|
||||
}
|
||||
|
||||
export async function formatHtmlForPrompt({
|
||||
html,
|
||||
keepStyles = false,
|
||||
maxAttrLen = 200,
|
||||
maxContentLen = 500,
|
||||
html,
|
||||
keepStyles = false,
|
||||
maxAttrLen = 200,
|
||||
maxContentLen = 500,
|
||||
}: FormatHtmlOptions) {
|
||||
const tagsToRemove = [
|
||||
'hint',
|
||||
'style',
|
||||
'link',
|
||||
'script',
|
||||
'meta',
|
||||
'noscript',
|
||||
'svg',
|
||||
'head',
|
||||
]
|
||||
const tagsToRemove = ['hint', 'style', 'link', 'script', 'meta', 'noscript', 'svg', 'head']
|
||||
|
||||
const attributesToKeep = [
|
||||
// Standard descriptive attributes
|
||||
'label',
|
||||
'title',
|
||||
'alt',
|
||||
'href',
|
||||
'name',
|
||||
'value',
|
||||
'checked',
|
||||
'placeholder',
|
||||
'type',
|
||||
'role',
|
||||
'target',
|
||||
// Descriptive aria attributes (text content)
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-valuetext',
|
||||
'aria-roledescription',
|
||||
// Useful aria state attributes
|
||||
'aria-hidden',
|
||||
'aria-expanded',
|
||||
'aria-checked',
|
||||
'aria-selected',
|
||||
'aria-disabled',
|
||||
'aria-pressed',
|
||||
'aria-required',
|
||||
'aria-current',
|
||||
// Test IDs (data-testid, data-test, data-cy, data-qa are covered by data-* prefix)
|
||||
'testid',
|
||||
'test-id',
|
||||
'tid',
|
||||
'qa',
|
||||
'qa-id',
|
||||
'e2e',
|
||||
'e2e-id',
|
||||
'automation-id',
|
||||
'automationid',
|
||||
'selenium',
|
||||
'pw',
|
||||
'vimium-label',
|
||||
// Conditionally added: 'style', 'class'
|
||||
]
|
||||
const attributesToKeep = [
|
||||
// Standard descriptive attributes
|
||||
'label',
|
||||
'title',
|
||||
'alt',
|
||||
'href',
|
||||
'name',
|
||||
'value',
|
||||
'checked',
|
||||
'placeholder',
|
||||
'type',
|
||||
'role',
|
||||
'target',
|
||||
// Descriptive aria attributes (text content)
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-valuetext',
|
||||
'aria-roledescription',
|
||||
// Useful aria state attributes
|
||||
'aria-hidden',
|
||||
'aria-expanded',
|
||||
'aria-checked',
|
||||
'aria-selected',
|
||||
'aria-disabled',
|
||||
'aria-pressed',
|
||||
'aria-required',
|
||||
'aria-current',
|
||||
// Test IDs (data-testid, data-test, data-cy, data-qa are covered by data-* prefix)
|
||||
'testid',
|
||||
'test-id',
|
||||
'tid',
|
||||
'qa',
|
||||
'qa-id',
|
||||
'e2e',
|
||||
'e2e-id',
|
||||
'automation-id',
|
||||
'automationid',
|
||||
'selenium',
|
||||
'pw',
|
||||
'vimium-label',
|
||||
// Conditionally added: 'style', 'class'
|
||||
]
|
||||
|
||||
if (keepStyles) {
|
||||
attributesToKeep.push('style', 'class')
|
||||
}
|
||||
if (keepStyles) {
|
||||
attributesToKeep.push('style', 'class')
|
||||
}
|
||||
|
||||
const truncate = (str: string, maxLen: number): string => {
|
||||
if (str.length <= maxLen) return str
|
||||
const remaining = str.length - maxLen
|
||||
return str.slice(0, maxLen) + `...${remaining} more characters`
|
||||
}
|
||||
const truncate = (str: string, maxLen: number): string => {
|
||||
if (str.length <= maxLen) return str
|
||||
const remaining = str.length - maxLen
|
||||
return str.slice(0, maxLen) + `...${remaining} more characters`
|
||||
}
|
||||
|
||||
// Create a custom plugin to remove tags and filter attributes
|
||||
const removeTagsAndAttrsPlugin = () => {
|
||||
return (tree) => {
|
||||
// Remove comments at root level
|
||||
tree = tree.filter((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const trimmed = item.trim()
|
||||
return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->'))
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Process each node recursively
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') {
|
||||
// Truncate text content
|
||||
const trimmed = node.trim()
|
||||
if (trimmed.length === 0) return node
|
||||
return truncate(node, maxContentLen)
|
||||
}
|
||||
|
||||
// Remove unwanted tags
|
||||
if (node.tag && tagsToRemove.includes(node.tag.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Filter attributes
|
||||
if (node.attrs) {
|
||||
const newAttrs: typeof node.attrs = {}
|
||||
for (const [attr, value] of Object.entries(node.attrs)) {
|
||||
const shouldKeep =
|
||||
attr.startsWith('data-') ||
|
||||
attributesToKeep.includes(attr)
|
||||
|
||||
if (shouldKeep) {
|
||||
// Truncate attribute values
|
||||
newAttrs[attr] = typeof value === 'string'
|
||||
? truncate(value, maxAttrLen)
|
||||
: value
|
||||
}
|
||||
}
|
||||
node.attrs = newAttrs
|
||||
}
|
||||
|
||||
// Process content recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content
|
||||
.map(processNode)
|
||||
.filter(item => {
|
||||
if (item === null) return false
|
||||
if (typeof item === 'string') {
|
||||
const trimmed = item.trim()
|
||||
return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->'))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// Process all root nodes
|
||||
return tree.map(processNode).filter(item => item !== null)
|
||||
// Create a custom plugin to remove tags and filter attributes
|
||||
const removeTagsAndAttrsPlugin = () => {
|
||||
return (tree) => {
|
||||
// Remove comments at root level
|
||||
tree = tree.filter((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const trimmed = item.trim()
|
||||
return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->'))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Plugin to remove aria-hidden="true" subtrees entirely
|
||||
// These are hidden from assistive tech and usually decorative
|
||||
const removeAriaHiddenPlugin = () => {
|
||||
return (tree) => {
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
// Remove if aria-hidden="true"
|
||||
if (node.attrs?.['aria-hidden'] === 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content
|
||||
.map(processNode)
|
||||
.filter((item) => item !== null)
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return tree.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to remove images with empty alt text (purely decorative)
|
||||
// Runs before decorative subtree pruning so containers become empty
|
||||
const removeEmptyAltImagesPlugin = () => {
|
||||
return (tree) => {
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
// Remove img with empty or missing alt
|
||||
if (node.tag.toLowerCase() === 'img') {
|
||||
const alt = node.attrs?.alt
|
||||
if (alt === '' || alt === undefined) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content
|
||||
.map(processNode)
|
||||
.filter((item) => item !== null)
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return tree.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to remove decorative subtrees that have no useful content for agents
|
||||
// A subtree is decorative if it has:
|
||||
// - No text content (leaf text nodes)
|
||||
// - No actionable elements with meaningful attributes
|
||||
const removeDecorativeSubtreesPlugin = () => {
|
||||
const actionableTags = ['button', 'a', 'input', 'select', 'textarea']
|
||||
const meaningfulAttrs = [
|
||||
'aria-label',
|
||||
'title',
|
||||
'alt',
|
||||
'value',
|
||||
'placeholder',
|
||||
'href',
|
||||
'name',
|
||||
]
|
||||
|
||||
// Form elements are always actionable, keep unconditionally
|
||||
const formTags = ['input', 'select', 'textarea']
|
||||
|
||||
// Check if a subtree has any useful content
|
||||
const hasUsefulContent = (node): boolean => {
|
||||
if (typeof node === 'string') {
|
||||
return node.trim().length > 0
|
||||
}
|
||||
if (!node.tag) return false
|
||||
|
||||
// Form elements are always useful for agents to interact with
|
||||
if (formTags.includes(node.tag.toLowerCase())) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Images with non-empty alt text are useful (descriptive content)
|
||||
if (node.tag.toLowerCase() === 'img') {
|
||||
const alt = node.attrs?.alt
|
||||
if (typeof alt === 'string' && alt.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an actionable element with meaningful attributes
|
||||
if (actionableTags.includes(node.tag.toLowerCase())) {
|
||||
if (node.attrs) {
|
||||
for (const attr of meaningfulAttrs) {
|
||||
const value = node.attrs[attr]
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check children recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
for (const child of node.content) {
|
||||
if (hasUsefulContent(child)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
// Process each node recursively
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') {
|
||||
// Truncate text content
|
||||
const trimmed = node.trim()
|
||||
if (trimmed.length === 0) return node
|
||||
return truncate(node, maxContentLen)
|
||||
}
|
||||
|
||||
return (tree) => {
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
// First process children
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content
|
||||
.map(processNode)
|
||||
.filter((item) => item !== null)
|
||||
}
|
||||
|
||||
// After processing children, check if this subtree is now decorative
|
||||
// Skip root-level semantic elements (body, main, etc.)
|
||||
const semanticTags = [
|
||||
'html',
|
||||
'body',
|
||||
'main',
|
||||
'header',
|
||||
'footer',
|
||||
'nav',
|
||||
'section',
|
||||
'article',
|
||||
'aside',
|
||||
]
|
||||
if (semanticTags.includes(node.tag.toLowerCase())) {
|
||||
return node
|
||||
}
|
||||
|
||||
// If no useful content in this subtree, remove it
|
||||
if (!hasUsefulContent(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return tree.map(processNode).filter((item) => item !== null)
|
||||
// Remove unwanted tags
|
||||
if (node.tag && tagsToRemove.includes(node.tag.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Filter attributes
|
||||
if (node.attrs) {
|
||||
const newAttrs: typeof node.attrs = {}
|
||||
for (const [attr, value] of Object.entries(node.attrs)) {
|
||||
const shouldKeep = attr.startsWith('data-') || attributesToKeep.includes(attr)
|
||||
|
||||
if (shouldKeep) {
|
||||
// Truncate attribute values
|
||||
newAttrs[attr] = typeof value === 'string' ? truncate(value, maxAttrLen) : value
|
||||
}
|
||||
}
|
||||
node.attrs = newAttrs
|
||||
}
|
||||
|
||||
// Process content recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content.map(processNode).filter((item) => {
|
||||
if (item === null) return false
|
||||
if (typeof item === 'string') {
|
||||
const trimmed = item.trim()
|
||||
return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->'))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// Process all root nodes
|
||||
return tree.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to remove aria-hidden="true" subtrees entirely
|
||||
// These are hidden from assistive tech and usually decorative
|
||||
const removeAriaHiddenPlugin = () => {
|
||||
return (tree) => {
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
// Remove if aria-hidden="true"
|
||||
if (node.attrs?.['aria-hidden'] === 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return tree.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to remove images with empty alt text (purely decorative)
|
||||
// Runs before decorative subtree pruning so containers become empty
|
||||
const removeEmptyAltImagesPlugin = () => {
|
||||
return (tree) => {
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
// Remove img with empty or missing alt
|
||||
if (node.tag.toLowerCase() === 'img') {
|
||||
const alt = node.attrs?.alt
|
||||
if (alt === '' || alt === undefined) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return tree.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to remove decorative subtrees that have no useful content for agents
|
||||
// A subtree is decorative if it has:
|
||||
// - No text content (leaf text nodes)
|
||||
// - No actionable elements with meaningful attributes
|
||||
const removeDecorativeSubtreesPlugin = () => {
|
||||
const actionableTags = ['button', 'a', 'input', 'select', 'textarea']
|
||||
const meaningfulAttrs = ['aria-label', 'title', 'alt', 'value', 'placeholder', 'href', 'name']
|
||||
|
||||
// Form elements are always actionable, keep unconditionally
|
||||
const formTags = ['input', 'select', 'textarea']
|
||||
|
||||
// Check if a subtree has any useful content
|
||||
const hasUsefulContent = (node): boolean => {
|
||||
if (typeof node === 'string') {
|
||||
return node.trim().length > 0
|
||||
}
|
||||
if (!node.tag) return false
|
||||
|
||||
// Form elements are always useful for agents to interact with
|
||||
if (formTags.includes(node.tag.toLowerCase())) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Images with non-empty alt text are useful (descriptive content)
|
||||
if (node.tag.toLowerCase() === 'img') {
|
||||
const alt = node.attrs?.alt
|
||||
if (typeof alt === 'string' && alt.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an actionable element with meaningful attributes
|
||||
if (actionableTags.includes(node.tag.toLowerCase())) {
|
||||
if (node.attrs) {
|
||||
for (const attr of meaningfulAttrs) {
|
||||
const value = node.attrs[attr]
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check children recursively
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
for (const child of node.content) {
|
||||
if (hasUsefulContent(child)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Plugin to unwrap unnecessary nested wrapper elements
|
||||
// e.g., <div><div><div><p>text</p></div></div></div> -> <div><p>text</p></div>
|
||||
const unwrapNestedWrappersPlugin = () => {
|
||||
return (tree) => {
|
||||
const isWhitespaceOnly = (node) => {
|
||||
return typeof node === 'string' && node.trim().length === 0
|
||||
}
|
||||
return (tree) => {
|
||||
const processNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
const hasNoAttrs = (node) => {
|
||||
return !node.attrs || Object.keys(node.attrs).length === 0
|
||||
}
|
||||
|
||||
const unwrapNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
// First, recursively process children
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content.map(unwrapNode)
|
||||
}
|
||||
|
||||
// Check if this node is an unnecessary wrapper:
|
||||
// - has no attributes
|
||||
// - has exactly one non-whitespace child that is an element
|
||||
if (hasNoAttrs(node) && node.content && Array.isArray(node.content)) {
|
||||
const nonWhitespaceChildren = node.content.filter(c => !isWhitespaceOnly(c))
|
||||
|
||||
if (nonWhitespaceChildren.length === 1) {
|
||||
const onlyChild = nonWhitespaceChildren[0]
|
||||
// If the only child is also an element (not text), unwrap
|
||||
if (typeof onlyChild !== 'string' && onlyChild.tag) {
|
||||
// Replace this node with its child
|
||||
return onlyChild
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// Apply multiple passes until stable (handles deeply nested wrappers)
|
||||
let result = tree.map(unwrapNode)
|
||||
let prevJson = ''
|
||||
let currJson = JSON.stringify(result)
|
||||
while (prevJson !== currJson) {
|
||||
prevJson = currJson
|
||||
result = result.map(unwrapNode)
|
||||
currJson = JSON.stringify(result)
|
||||
}
|
||||
|
||||
return result
|
||||
// First process children
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to remove empty elements (no attrs, no content)
|
||||
// Runs repeatedly until no more empty elements exist
|
||||
const removeEmptyElementsPlugin = () => {
|
||||
return (tree) => {
|
||||
const isEmptyElement = (node) => {
|
||||
if (typeof node === 'string') return false
|
||||
if (!node.tag) return false
|
||||
const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0
|
||||
const hasContent = node.content && node.content.some(c =>
|
||||
typeof c === 'string' ? c.trim().length > 0 : true
|
||||
)
|
||||
return !hasAttrs && !hasContent
|
||||
}
|
||||
|
||||
const removeEmpty = (content) => {
|
||||
if (!content || !Array.isArray(content)) return content
|
||||
|
||||
return content
|
||||
.map(node => {
|
||||
if (typeof node === 'string') return node
|
||||
if (node.content) {
|
||||
node.content = removeEmpty(node.content)
|
||||
}
|
||||
return node
|
||||
})
|
||||
.filter(node => !isEmptyElement(node))
|
||||
}
|
||||
|
||||
// Apply multiple passes until stable
|
||||
let result = removeEmpty(tree)
|
||||
let prevJson = ''
|
||||
let currJson = JSON.stringify(result)
|
||||
while (prevJson !== currJson) {
|
||||
prevJson = currJson
|
||||
result = removeEmpty(result)
|
||||
currJson = JSON.stringify(result)
|
||||
}
|
||||
|
||||
return result
|
||||
// After processing children, check if this subtree is now decorative
|
||||
// Skip root-level semantic elements (body, main, etc.)
|
||||
const semanticTags = ['html', 'body', 'main', 'header', 'footer', 'nav', 'section', 'article', 'aside']
|
||||
if (semanticTags.includes(node.tag.toLowerCase())) {
|
||||
return node
|
||||
}
|
||||
|
||||
// If no useful content in this subtree, remove it
|
||||
if (!hasUsefulContent(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return tree.map(processNode).filter((item) => item !== null)
|
||||
}
|
||||
}
|
||||
|
||||
// Process HTML
|
||||
const processor = posthtml()
|
||||
.use(removeTagsAndAttrsPlugin())
|
||||
.use(removeAriaHiddenPlugin())
|
||||
.use(removeEmptyAltImagesPlugin())
|
||||
.use(removeDecorativeSubtreesPlugin())
|
||||
.use(removeEmptyElementsPlugin())
|
||||
.use(unwrapNestedWrappersPlugin())
|
||||
.use(beautify({
|
||||
rules: {
|
||||
indent: 1, // 1-space indent
|
||||
blankLines: false, // no extra blank lines
|
||||
maxlen: 100000 // effectively never wrap by content length
|
||||
},
|
||||
jsBeautifyOptions: {
|
||||
wrap_line_length: 0, // disable js-beautify wrapping
|
||||
preserve_newlines: false // reduce stray newlines
|
||||
// Plugin to unwrap unnecessary nested wrapper elements
|
||||
// e.g., <div><div><div><p>text</p></div></div></div> -> <div><p>text</p></div>
|
||||
const unwrapNestedWrappersPlugin = () => {
|
||||
return (tree) => {
|
||||
const isWhitespaceOnly = (node) => {
|
||||
return typeof node === 'string' && node.trim().length === 0
|
||||
}
|
||||
|
||||
const hasNoAttrs = (node) => {
|
||||
return !node.attrs || Object.keys(node.attrs).length === 0
|
||||
}
|
||||
|
||||
const unwrapNode = (node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (!node.tag) return node
|
||||
|
||||
// First, recursively process children
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content = node.content.map(unwrapNode)
|
||||
}
|
||||
|
||||
// Check if this node is an unnecessary wrapper:
|
||||
// - has no attributes
|
||||
// - has exactly one non-whitespace child that is an element
|
||||
if (hasNoAttrs(node) && node.content && Array.isArray(node.content)) {
|
||||
const nonWhitespaceChildren = node.content.filter((c) => !isWhitespaceOnly(c))
|
||||
|
||||
if (nonWhitespaceChildren.length === 1) {
|
||||
const onlyChild = nonWhitespaceChildren[0]
|
||||
// If the only child is also an element (not text), unwrap
|
||||
if (typeof onlyChild !== 'string' && onlyChild.tag) {
|
||||
// Replace this node with its child
|
||||
return onlyChild
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Process with await
|
||||
const result = await processor.process(html)
|
||||
return node
|
||||
}
|
||||
|
||||
return result.html
|
||||
// Apply multiple passes until stable (handles deeply nested wrappers)
|
||||
let result = tree.map(unwrapNode)
|
||||
let prevJson = ''
|
||||
let currJson = JSON.stringify(result)
|
||||
while (prevJson !== currJson) {
|
||||
prevJson = currJson
|
||||
result = result.map(unwrapNode)
|
||||
currJson = JSON.stringify(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to remove empty elements (no attrs, no content)
|
||||
// Runs repeatedly until no more empty elements exist
|
||||
const removeEmptyElementsPlugin = () => {
|
||||
return (tree) => {
|
||||
const isEmptyElement = (node) => {
|
||||
if (typeof node === 'string') return false
|
||||
if (!node.tag) return false
|
||||
const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0
|
||||
const hasContent =
|
||||
node.content && node.content.some((c) => (typeof c === 'string' ? c.trim().length > 0 : true))
|
||||
return !hasAttrs && !hasContent
|
||||
}
|
||||
|
||||
const removeEmpty = (content) => {
|
||||
if (!content || !Array.isArray(content)) return content
|
||||
|
||||
return content
|
||||
.map((node) => {
|
||||
if (typeof node === 'string') return node
|
||||
if (node.content) {
|
||||
node.content = removeEmpty(node.content)
|
||||
}
|
||||
return node
|
||||
})
|
||||
.filter((node) => !isEmptyElement(node))
|
||||
}
|
||||
|
||||
// Apply multiple passes until stable
|
||||
let result = removeEmpty(tree)
|
||||
let prevJson = ''
|
||||
let currJson = JSON.stringify(result)
|
||||
while (prevJson !== currJson) {
|
||||
prevJson = currJson
|
||||
result = removeEmpty(result)
|
||||
currJson = JSON.stringify(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Process HTML
|
||||
const processor = posthtml()
|
||||
.use(removeTagsAndAttrsPlugin())
|
||||
.use(removeAriaHiddenPlugin())
|
||||
.use(removeEmptyAltImagesPlugin())
|
||||
.use(removeDecorativeSubtreesPlugin())
|
||||
.use(removeEmptyElementsPlugin())
|
||||
.use(unwrapNestedWrappersPlugin())
|
||||
.use(
|
||||
beautify({
|
||||
rules: {
|
||||
indent: 1, // 1-space indent
|
||||
blankLines: false, // no extra blank lines
|
||||
maxlen: 100000, // effectively never wrap by content length
|
||||
},
|
||||
jsBeautifyOptions: {
|
||||
wrap_line_length: 0, // disable js-beautify wrapping
|
||||
preserve_newlines: false, // reduce stray newlines
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Process with await
|
||||
const result = await processor.process(html)
|
||||
|
||||
return result.html
|
||||
}
|
||||
|
||||
@@ -148,9 +148,7 @@ export async function getListeningPidsForPort({ port }: { port: number }): Promi
|
||||
throw new Error(`Invalid port: ${port}`)
|
||||
}
|
||||
|
||||
return os.platform() === 'win32'
|
||||
? await getPidsForPortWindows(port)
|
||||
: await getPidsForPortUnix(port)
|
||||
return os.platform() === 'win32' ? await getPidsForPortWindows(port) : await getPidsForPortUnix(port)
|
||||
}
|
||||
|
||||
function toError(value: unknown): Error {
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function createTransport({ args = [], port }: { args?: string[]; po
|
||||
stderr: Stream | null
|
||||
}> {
|
||||
const env: Record<string, string> = {
|
||||
...process.env as Record<string, string>,
|
||||
...(process.env as Record<string, string>),
|
||||
DEBUG: 'playwriter:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
|
||||
+16
-13
@@ -91,12 +91,12 @@ async function getOrCreateExecutor(): Promise<PlaywrightExecutor> {
|
||||
if (executor) {
|
||||
return executor
|
||||
}
|
||||
|
||||
|
||||
const remote = getRemoteConfig()
|
||||
if (!remote) {
|
||||
await ensureRelayServerForMcp()
|
||||
}
|
||||
|
||||
|
||||
// Pass config instead of pre-generated URL so executor can generate unique URLs for each connection
|
||||
const cdpConfig = remote || { port: RELAY_PORT }
|
||||
executor = new PlaywrightExecutor({
|
||||
@@ -104,7 +104,7 @@ async function getOrCreateExecutor(): Promise<PlaywrightExecutor> {
|
||||
logger: mcpLogger,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
|
||||
return executor
|
||||
}
|
||||
|
||||
@@ -198,37 +198,37 @@ server.tool(
|
||||
if (!remote) {
|
||||
await ensureRelayServerForMcp()
|
||||
}
|
||||
|
||||
|
||||
const exec = await getOrCreateExecutor()
|
||||
const result = await exec.execute(code, timeout)
|
||||
|
||||
|
||||
// Transform executor result to MCP format
|
||||
const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
|
||||
{ type: 'text', text: result.text },
|
||||
]
|
||||
|
||||
|
||||
for (const image of result.images) {
|
||||
content.push({ type: 'image', data: image.data, mimeType: image.mimeType })
|
||||
}
|
||||
|
||||
|
||||
if (result.isError) {
|
||||
return { content, isError: true }
|
||||
}
|
||||
|
||||
|
||||
return { content }
|
||||
} catch (error: any) {
|
||||
const errorStack = error.stack || error.message
|
||||
const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
|
||||
|
||||
|
||||
console.error('Error in execute tool:', errorStack)
|
||||
if (!isTimeoutError) {
|
||||
sendLogToRelayServer('error', 'Error in execute tool:', errorStack)
|
||||
}
|
||||
|
||||
|
||||
const resetHint = isTimeoutError
|
||||
? ''
|
||||
: '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]'
|
||||
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }],
|
||||
isError: true,
|
||||
@@ -256,13 +256,16 @@ server.tool(
|
||||
if (!remote) {
|
||||
await ensureRelayServerForMcp()
|
||||
}
|
||||
|
||||
|
||||
const exec = await getOrCreateExecutor()
|
||||
const { page, context } = await exec.reset()
|
||||
const pagesCount = context.pages().length
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}` },
|
||||
{
|
||||
type: 'text',
|
||||
text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Extract page content as markdown using Mozilla Readability.
|
||||
*
|
||||
*
|
||||
* This utility injects the Readability library into the page and extracts
|
||||
* the main content, similar to Firefox Reader View.
|
||||
*/
|
||||
@@ -65,7 +65,7 @@ function isRegExp(value: unknown): value is RegExp {
|
||||
|
||||
/**
|
||||
* Extract page content as markdown using Mozilla Readability.
|
||||
*
|
||||
*
|
||||
* Injects Readability into the page if not already present, then extracts
|
||||
* the main content. Returns plain text content (no HTML).
|
||||
*/
|
||||
@@ -81,7 +81,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
|
||||
}
|
||||
|
||||
// Extract content using Readability
|
||||
const result = await page.evaluate(() => {
|
||||
const result = (await page.evaluate(() => {
|
||||
const readability = (globalThis as any).__readability
|
||||
if (!readability) {
|
||||
throw new Error('Readability not loaded')
|
||||
@@ -131,11 +131,11 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
|
||||
publishedTime: article.publishedTime || null,
|
||||
wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length,
|
||||
}
|
||||
}) as PageMarkdownResult & { _notReadable?: boolean }
|
||||
})) as PageMarkdownResult & { _notReadable?: boolean }
|
||||
|
||||
// Format output
|
||||
const lines: string[] = []
|
||||
|
||||
|
||||
if (result.title) {
|
||||
lines.push(`# ${result.title}`)
|
||||
lines.push('')
|
||||
|
||||
+69
-57
@@ -2,19 +2,18 @@ import { CDPEventFor, ProtocolMapping } from './cdp-types.js'
|
||||
|
||||
export const VERSION = 1
|
||||
|
||||
type ForwardCDPCommand =
|
||||
{
|
||||
[K in keyof ProtocolMapping.Commands]: {
|
||||
id: number
|
||||
method: 'forwardCDPCommand'
|
||||
params: {
|
||||
method: K
|
||||
sessionId?: string
|
||||
params?: ProtocolMapping.Commands[K]['paramsType'][0]
|
||||
source?: 'playwriter'
|
||||
}
|
||||
type ForwardCDPCommand = {
|
||||
[K in keyof ProtocolMapping.Commands]: {
|
||||
id: number
|
||||
method: 'forwardCDPCommand'
|
||||
params: {
|
||||
method: K
|
||||
sessionId?: string
|
||||
params?: ProtocolMapping.Commands[K]['paramsType'][0]
|
||||
source?: 'playwriter'
|
||||
}
|
||||
}[keyof ProtocolMapping.Commands]
|
||||
}
|
||||
}[keyof ProtocolMapping.Commands]
|
||||
|
||||
export type ExtensionCommandMessage = ForwardCDPCommand
|
||||
|
||||
@@ -29,18 +28,17 @@ export type ExtensionResponseMessage = {
|
||||
* This produces a discriminated union for narrowing, similar to ForwardCDPCommand,
|
||||
* but for forwarded CDP events. Uses CDPEvent to maintain proper type extraction.
|
||||
*/
|
||||
export type ExtensionEventMessage =
|
||||
{
|
||||
[K in keyof ProtocolMapping.Events]: {
|
||||
id?: undefined
|
||||
method: 'forwardCDPEvent'
|
||||
params: {
|
||||
method: CDPEventFor<K>['method']
|
||||
sessionId?: string
|
||||
params?: CDPEventFor<K>['params']
|
||||
}
|
||||
export type ExtensionEventMessage = {
|
||||
[K in keyof ProtocolMapping.Events]: {
|
||||
id?: undefined
|
||||
method: 'forwardCDPEvent'
|
||||
params: {
|
||||
method: CDPEventFor<K>['method']
|
||||
sessionId?: string
|
||||
params?: CDPEventFor<K>['params']
|
||||
}
|
||||
}[keyof ProtocolMapping.Events]
|
||||
}
|
||||
}[keyof ProtocolMapping.Events]
|
||||
|
||||
export type ExtensionLogMessage = {
|
||||
id?: undefined
|
||||
@@ -78,7 +76,13 @@ export type RecordingCancelledMessage = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage | ExtensionLogMessage | ExtensionPongMessage | RecordingDataMessage | RecordingCancelledMessage
|
||||
export type ExtensionMessage =
|
||||
| ExtensionResponseMessage
|
||||
| ExtensionEventMessage
|
||||
| ExtensionLogMessage
|
||||
| ExtensionPongMessage
|
||||
| RecordingDataMessage
|
||||
| RecordingCancelledMessage
|
||||
|
||||
// Recording command messages (MCP -> Extension via relay)
|
||||
export type StartRecordingParams = {
|
||||
@@ -137,36 +141,42 @@ export type RecordingCommandMessage =
|
||||
| CancelRecordingMessage
|
||||
|
||||
// Recording result types
|
||||
export type StartRecordingResult = {
|
||||
success: true
|
||||
tabId: number
|
||||
startedAt: number
|
||||
} | {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
export type StartRecordingResult =
|
||||
| {
|
||||
success: true
|
||||
tabId: number
|
||||
startedAt: number
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
|
||||
/** Result from extension - doesn't include path/size since relay writes the file */
|
||||
export type ExtensionStopRecordingResult = {
|
||||
success: true
|
||||
tabId: number
|
||||
duration: number
|
||||
} | {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
export type ExtensionStopRecordingResult =
|
||||
| {
|
||||
success: true
|
||||
tabId: number
|
||||
duration: number
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
|
||||
/** Final result from relay - includes path/size after file is written */
|
||||
export type StopRecordingResult = {
|
||||
success: true
|
||||
tabId: number
|
||||
duration: number
|
||||
path: string
|
||||
size: number
|
||||
} | {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
export type StopRecordingResult =
|
||||
| {
|
||||
success: true
|
||||
tabId: number
|
||||
duration: number
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
|
||||
export type IsRecordingResult = {
|
||||
isRecording: boolean
|
||||
@@ -193,10 +203,12 @@ export type GhostBrowserCommandMessage = {
|
||||
}
|
||||
}
|
||||
|
||||
export type GhostBrowserCommandResult = {
|
||||
success: true
|
||||
result: unknown
|
||||
} | {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
export type GhostBrowserCommandResult =
|
||||
| {
|
||||
success: true
|
||||
result: unknown
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
// Recording state - tracks active recordings and their accumulated chunks
|
||||
export interface ActiveRecording {
|
||||
tabId: number
|
||||
sessionId?: string // The sessionId used to start this recording, for lookup when stopping
|
||||
sessionId?: string // The sessionId used to start this recording, for lookup when stopping
|
||||
outputPath: string
|
||||
chunks: Buffer[]
|
||||
startedAt: number
|
||||
@@ -40,7 +40,7 @@ export class RecordingRelay {
|
||||
constructor(
|
||||
sendToExtension: (params: { method: string; params?: unknown; timeout?: number }) => Promise<unknown>,
|
||||
isExtensionConnected: () => boolean,
|
||||
logger?: { log(...args: unknown[]): void; error(...args: unknown[]): void }
|
||||
logger?: { log(...args: unknown[]): void; error(...args: unknown[]): void },
|
||||
) {
|
||||
this.sendToExtension = sendToExtension
|
||||
this.isExtensionConnected = isExtensionConnected
|
||||
@@ -58,7 +58,11 @@ export class RecordingRelay {
|
||||
const recording = this.activeRecordings.get(tabId)
|
||||
if (recording) {
|
||||
recording.chunks.push(buffer)
|
||||
this.logger?.log(pc.blue(`Received recording chunk for tab ${tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`))
|
||||
this.logger?.log(
|
||||
pc.blue(
|
||||
`Received recording chunk for tab ${tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.logger?.log(pc.yellow(`Received recording chunk for unknown tab ${tabId}, ignoring`))
|
||||
}
|
||||
@@ -140,11 +144,11 @@ export class RecordingRelay {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.sendToExtension({
|
||||
const result = (await this.sendToExtension({
|
||||
method: 'startRecording',
|
||||
params: recordingParams,
|
||||
timeout: 10000,
|
||||
}) as StartRecordingResult
|
||||
})) as StartRecordingResult
|
||||
|
||||
if (!result) {
|
||||
return { success: false, error: 'Extension returned empty result' }
|
||||
@@ -158,7 +162,11 @@ export class RecordingRelay {
|
||||
chunks: [],
|
||||
startedAt: result.startedAt,
|
||||
})
|
||||
this.logger?.log(pc.green(`Recording started for tab ${result.tabId} (sessionId: ${recordingParams.sessionId || 'none'}), output: ${outputPath}`))
|
||||
this.logger?.log(
|
||||
pc.green(
|
||||
`Recording started for tab ${result.tabId} (sessionId: ${recordingParams.sessionId || 'none'}), output: ${outputPath}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -211,11 +219,11 @@ export class RecordingRelay {
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await this.sendToExtension({
|
||||
const result = (await this.sendToExtension({
|
||||
method: 'stopRecording',
|
||||
params,
|
||||
timeout: 10000,
|
||||
}) as StopRecordingResult
|
||||
})) as StopRecordingResult
|
||||
|
||||
if (!result.success) {
|
||||
recording.resolveStop = undefined
|
||||
@@ -237,11 +245,11 @@ export class RecordingRelay {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.sendToExtension({
|
||||
return (await this.sendToExtension({
|
||||
method: 'isRecording',
|
||||
params,
|
||||
timeout: 5000,
|
||||
}) as IsRecordingResult
|
||||
})) as IsRecordingResult
|
||||
} catch {
|
||||
return { isRecording: false }
|
||||
}
|
||||
@@ -253,11 +261,11 @@ export class RecordingRelay {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.sendToExtension({
|
||||
return (await this.sendToExtension({
|
||||
method: 'cancelRecording',
|
||||
params,
|
||||
timeout: 5000,
|
||||
}) as CancelRecordingResult
|
||||
})) as CancelRecordingResult
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.logger?.error('Cancel recording error:', error)
|
||||
|
||||
@@ -30,7 +30,9 @@ export async function getRelayServerVersion(port: number = RELAY_PORT): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> {
|
||||
export async function getExtensionStatus(
|
||||
port: number = RELAY_PORT,
|
||||
): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/extension/status`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
@@ -38,7 +40,7 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
return await response.json() as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
|
||||
return (await response.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -48,11 +50,13 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c
|
||||
* Wait for the extension to connect to the relay server.
|
||||
* Returns true if connected within timeout, false otherwise.
|
||||
*/
|
||||
export async function waitForExtension(options: {
|
||||
port?: number
|
||||
timeoutMs?: number
|
||||
logger?: { log: (...args: any[]) => void }
|
||||
} = {}): Promise<boolean> {
|
||||
export async function waitForExtension(
|
||||
options: {
|
||||
port?: number
|
||||
timeoutMs?: number
|
||||
logger?: { log: (...args: any[]) => void }
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const { port = RELAY_PORT, timeoutMs = 5000, logger } = options
|
||||
const startTime = Date.now()
|
||||
|
||||
@@ -155,7 +159,9 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
|
||||
|
||||
if (serverVersion !== null) {
|
||||
if (restartOnVersionMismatch) {
|
||||
logger?.log(pc.yellow(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`))
|
||||
logger?.log(
|
||||
pc.yellow(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`),
|
||||
)
|
||||
await killRelayServer({ port: RELAY_PORT })
|
||||
} else {
|
||||
// Server is running but different version, just use it
|
||||
@@ -164,7 +170,11 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
|
||||
} else {
|
||||
const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => [])
|
||||
if (listeningPids.length > 0) {
|
||||
logger?.log(pc.yellow(`Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`))
|
||||
logger?.log(
|
||||
pc.yellow(
|
||||
`Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`,
|
||||
),
|
||||
)
|
||||
await killRelayServer({ port: RELAY_PORT })
|
||||
}
|
||||
|
||||
|
||||
+560
-551
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+21
-49
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
You can also find `getByRole` to get elements on the page.
|
||||
|
||||
```javascript
|
||||
@@ -14,9 +12,7 @@ await page.getByRole('link', { name: 'About' }).click()
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com')
|
||||
|
||||
// For a heading with { "role": "heading", "name": "Welcome to Example.com" }
|
||||
const headingText = await page
|
||||
.getByRole('heading', { name: 'Welcome to Example.com' })
|
||||
.textContent()
|
||||
const headingText = await page.getByRole('heading', { name: 'Welcome to Example.com' }).textContent()
|
||||
console.log('Heading text:', headingText)
|
||||
```
|
||||
|
||||
@@ -223,17 +219,14 @@ const pageTitle = await page.evaluate(() => document.title)
|
||||
|
||||
// Modify page
|
||||
await page.evaluate(() => {
|
||||
document.body.style.backgroundColor = 'red'
|
||||
document.body.style.backgroundColor = 'red'
|
||||
})
|
||||
|
||||
// Pass arguments to page context
|
||||
const sum = await page.evaluate(([a, b]) => a + b, [5, 3])
|
||||
|
||||
// Work with elements
|
||||
const elementText = await page.evaluate(
|
||||
(el) => el.textContent,
|
||||
await page.getByRole('heading'),
|
||||
)
|
||||
const elementText = await page.evaluate((el) => el.textContent, await page.getByRole('heading'))
|
||||
```
|
||||
|
||||
### Execute JavaScript on Element
|
||||
@@ -244,8 +237,8 @@ const href = await page.getByRole('link').evaluate((el) => el.href)
|
||||
|
||||
// Modify element
|
||||
await page.getByRole('button').evaluate((el) => {
|
||||
el.style.backgroundColor = 'green'
|
||||
el.disabled = true
|
||||
el.style.backgroundColor = 'green'
|
||||
el.disabled = true
|
||||
})
|
||||
|
||||
// Scroll element into view
|
||||
@@ -261,9 +254,7 @@ await page.getByText('Section').evaluate((el) => el.scrollIntoView())
|
||||
await page.getByLabel('Upload file').setInputFiles('/path/to/file.pdf')
|
||||
|
||||
// Upload multiple files
|
||||
await page
|
||||
.getByLabel('Upload files')
|
||||
.setInputFiles(['/path/to/file1.pdf', '/path/to/file2.pdf'])
|
||||
await page.getByLabel('Upload files').setInputFiles(['/path/to/file1.pdf', '/path/to/file2.pdf'])
|
||||
|
||||
// Clear file input
|
||||
await page.getByLabel('Upload file').setInputFiles([])
|
||||
@@ -280,8 +271,7 @@ await page.locator('input[type="file"]').setInputFiles('/path/to/file.pdf')
|
||||
```javascript
|
||||
// Wait for a specific request to complete and get its response
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/user') && response.status() === 200,
|
||||
(response) => response.url().includes('/api/user') && response.status() === 200,
|
||||
)
|
||||
|
||||
// Get response data
|
||||
@@ -295,11 +285,11 @@ console.log('Request method:', request.method())
|
||||
|
||||
// Get all resources loaded by the page
|
||||
const resources = await page.evaluate(() =>
|
||||
performance.getEntriesByType('resource').map((r) => ({
|
||||
name: r.name,
|
||||
duration: r.duration,
|
||||
size: r.transferSize,
|
||||
})),
|
||||
performance.getEntriesByType('resource').map((r) => ({
|
||||
name: r.name,
|
||||
duration: r.duration,
|
||||
size: r.transferSize,
|
||||
})),
|
||||
)
|
||||
console.log('Page resources:', resources)
|
||||
```
|
||||
@@ -314,9 +304,9 @@ console.log('Page resources:', resources)
|
||||
|
||||
// To trigger console messages from the page:
|
||||
await page.evaluate(() => {
|
||||
console.log('This message will be captured')
|
||||
console.error('This error will be captured')
|
||||
console.warn('This warning will be captured')
|
||||
console.log('This message will be captured')
|
||||
console.error('This error will be captured')
|
||||
console.warn('This warning will be captured')
|
||||
})
|
||||
|
||||
// Then use the console_logs MCP tool to retrieve all captured messages
|
||||
@@ -338,10 +328,7 @@ await page.waitForURL(/github\.com.*\/pull/)
|
||||
await page.waitForURL(/\/new-org/)
|
||||
|
||||
// Wait for text to appear
|
||||
await page.waitForFunction(
|
||||
(text) => document.body.textContent.includes(text),
|
||||
'Success!',
|
||||
)
|
||||
await page.waitForFunction((text) => document.body.textContent.includes(text), 'Success!')
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL('**/success')
|
||||
@@ -350,10 +337,7 @@ await page.waitForURL('**/success')
|
||||
await waitForPageLoad({ page })
|
||||
|
||||
// Wait for specific condition
|
||||
await page.waitForFunction(
|
||||
(text) => document.querySelector('.status')?.textContent === text,
|
||||
'Ready',
|
||||
)
|
||||
await page.waitForFunction((text) => document.querySelector('.status')?.textContent === text, 'Ready')
|
||||
```
|
||||
|
||||
### Wait for Text to Appear or Disappear
|
||||
@@ -374,30 +358,18 @@ await page.getByText('Success!').first().waitFor({ state: 'visible' })
|
||||
console.log('Processing finished and success message appeared')
|
||||
|
||||
// Example: Wait for error message to disappear before proceeding
|
||||
await page
|
||||
.getByText('Error: Please try again')
|
||||
.first()
|
||||
.waitFor({ state: 'hidden' })
|
||||
await page.getByText('Error: Please try again').first().waitFor({ state: 'hidden' })
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
|
||||
// Example: Wait for confirmation text after form submission
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await page
|
||||
.getByText('Your changes have been saved')
|
||||
.first()
|
||||
.waitFor({ state: 'visible' })
|
||||
await page.getByText('Your changes have been saved').first().waitFor({ state: 'visible' })
|
||||
console.log('Save confirmed')
|
||||
|
||||
// Example: Wait for dynamic content to load
|
||||
await page.getByRole('button', { name: 'Load More' }).click()
|
||||
await page
|
||||
.getByText('Loading more items...')
|
||||
.first()
|
||||
.waitFor({ state: 'visible' })
|
||||
await page
|
||||
.getByText('Loading more items...')
|
||||
.first()
|
||||
.waitFor({ state: 'hidden' })
|
||||
await page.getByText('Loading more items...').first().waitFor({ state: 'visible' })
|
||||
await page.getByText('Loading more items...').first().waitFor({ state: 'hidden' })
|
||||
console.log('Additional items loaded')
|
||||
```
|
||||
|
||||
|
||||
@@ -150,7 +150,9 @@ export class ScopedFS {
|
||||
// Verify the real path is also within allowed directories (handles symlinks)
|
||||
const realStr = real.toString()
|
||||
if (!this.isPathAllowed(realStr)) {
|
||||
const error = new Error(`EPERM: operation not permitted, realpath escapes allowed directories`) as NodeJS.ErrnoException
|
||||
const error = new Error(
|
||||
`EPERM: operation not permitted, realpath escapes allowed directories`,
|
||||
) as NodeJS.ErrnoException
|
||||
error.code = 'EPERM'
|
||||
throw error
|
||||
}
|
||||
@@ -168,7 +170,9 @@ export class ScopedFS {
|
||||
const linkDir = path.dirname(resolvedLink)
|
||||
const resolvedTarget = path.resolve(linkDir, target.toString())
|
||||
if (!this.isPathAllowed(resolvedTarget)) {
|
||||
const error = new Error(`EPERM: operation not permitted, symlink target outside allowed directories`) as NodeJS.ErrnoException
|
||||
const error = new Error(
|
||||
`EPERM: operation not permitted, symlink target outside allowed directories`,
|
||||
) as NodeJS.ErrnoException
|
||||
error.code = 'EPERM'
|
||||
throw error
|
||||
}
|
||||
@@ -368,7 +372,9 @@ export class ScopedFS {
|
||||
const real = await fs.promises.realpath(resolved, options)
|
||||
const realStr = real.toString()
|
||||
if (!self.isPathAllowed(realStr)) {
|
||||
const error = new Error(`EPERM: operation not permitted, realpath escapes allowed directories`) as NodeJS.ErrnoException
|
||||
const error = new Error(
|
||||
`EPERM: operation not permitted, realpath escapes allowed directories`,
|
||||
) as NodeJS.ErrnoException
|
||||
error.code = 'EPERM'
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Screen recording utility for playwriter using chrome.tabCapture.
|
||||
* Recording happens in the extension context, so it survives page navigation.
|
||||
*
|
||||
*
|
||||
* This module communicates with the relay server which forwards commands to the extension.
|
||||
* SessionId (pw-tab-X format) is used to identify which tab to record.
|
||||
*/
|
||||
@@ -9,26 +9,22 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@xmorse/playwright-core'
|
||||
import type {
|
||||
StartRecordingResult,
|
||||
StopRecordingResult,
|
||||
IsRecordingResult,
|
||||
CancelRecordingResult,
|
||||
} from './protocol.js'
|
||||
import type { StartRecordingResult, StopRecordingResult, IsRecordingResult, CancelRecordingResult } from './protocol.js'
|
||||
import { EXTENSION_IDS } from './utils.js'
|
||||
|
||||
/**
|
||||
* Generate a shell command to quit and restart Chrome with flags that allow automatic tab capture.
|
||||
* This enables screen recording without user interaction (clicking extension icon).
|
||||
*
|
||||
*
|
||||
* Required flags:
|
||||
* - --allowlisted-extension-id=<id> - grants the extension special privileges (one per extension)
|
||||
* - --auto-accept-this-tab-capture - auto-accepts tab capture permission requests
|
||||
*/
|
||||
export function getChromeRestartCommand(): string {
|
||||
const platform = os.platform()
|
||||
const flags = EXTENSION_IDS.map(id => `--allowlisted-extension-id=${id}`).join(' ') + ' --auto-accept-this-tab-capture'
|
||||
|
||||
const flags =
|
||||
EXTENSION_IDS.map((id) => `--allowlisted-extension-id=${id}`).join(' ') + ' --auto-accept-this-tab-capture'
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return `osascript -e 'quit app "Google Chrome"' && sleep 1 && open -a "Google Chrome" --args ${flags}`
|
||||
}
|
||||
@@ -43,9 +39,11 @@ export function getChromeRestartCommand(): string {
|
||||
* Check if an error is related to missing activeTab permission for recording.
|
||||
*/
|
||||
function isActiveTabPermissionError(error: string): boolean {
|
||||
return error.includes('Extension has not been invoked') ||
|
||||
error.includes('activeTab') ||
|
||||
error.includes('enable recording')
|
||||
return (
|
||||
error.includes('Extension has not been invoked') ||
|
||||
error.includes('activeTab') ||
|
||||
error.includes('enable recording')
|
||||
)
|
||||
}
|
||||
|
||||
export interface StartRecordingOptions {
|
||||
@@ -96,34 +94,41 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
||||
outputPath,
|
||||
relayPort = 19988,
|
||||
} = options
|
||||
|
||||
|
||||
// Resolve relative paths to absolute using the caller's cwd.
|
||||
// The relay server may have a different cwd, so we must resolve here.
|
||||
const absoluteOutputPath = path.resolve(outputPath)
|
||||
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, frameRate, videoBitsPerSecond, audioBitsPerSecond, audio, outputPath: absoluteOutputPath }),
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
frameRate,
|
||||
videoBitsPerSecond,
|
||||
audioBitsPerSecond,
|
||||
audio,
|
||||
outputPath: absoluteOutputPath,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json() as StartRecordingResult
|
||||
const result = (await response.json()) as StartRecordingResult
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error || 'Unknown error'
|
||||
|
||||
|
||||
// If the error is about missing activeTab permission, provide helpful guidance
|
||||
if (isActiveTabPermissionError(errorMsg)) {
|
||||
const restartCmd = getChromeRestartCommand()
|
||||
throw new Error(
|
||||
`Failed to start recording: ${errorMsg}\n\n` +
|
||||
`For automated recording, Chrome must be restarted with special flags.\n` +
|
||||
`WARNING: This will close all Chrome windows. Save your work first!\n\n` +
|
||||
` ${restartCmd}\n\n` +
|
||||
`Or click the Playwriter extension icon on the tab once to grant permission.`
|
||||
`For automated recording, Chrome must be restarted with special flags.\n` +
|
||||
`WARNING: This will close all Chrome windows. Save your work first!\n\n` +
|
||||
` ${restartCmd}\n\n` +
|
||||
`Or click the Playwriter extension icon on the tab once to grant permission.`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`Failed to start recording: ${errorMsg}`)
|
||||
}
|
||||
|
||||
@@ -138,7 +143,9 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
||||
* Stop recording and save to file.
|
||||
* Returns the path to the saved video file.
|
||||
*/
|
||||
export async function stopRecording(options: StopRecordingOptions): Promise<{ path: string; duration: number; size: number }> {
|
||||
export async function stopRecording(
|
||||
options: StopRecordingOptions,
|
||||
): Promise<{ path: string; duration: number; size: number }> {
|
||||
const { sessionId, relayPort = 19988 } = options
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
|
||||
@@ -147,7 +154,7 @@ export async function stopRecording(options: StopRecordingOptions): Promise<{ pa
|
||||
body: JSON.stringify({ sessionId }),
|
||||
})
|
||||
|
||||
const result = await response.json() as StopRecordingResult
|
||||
const result = (await response.json()) as StopRecordingResult
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to stop recording: ${result.error}`)
|
||||
@@ -159,14 +166,18 @@ export async function stopRecording(options: StopRecordingOptions): Promise<{ pa
|
||||
/**
|
||||
* Check if recording is currently active.
|
||||
*/
|
||||
export async function isRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise<RecordingState> {
|
||||
export async function isRecording(options: {
|
||||
page: Page
|
||||
sessionId?: string
|
||||
relayPort?: number
|
||||
}): Promise<RecordingState> {
|
||||
const { sessionId, relayPort = 19988 } = options
|
||||
|
||||
const url = sessionId
|
||||
const url = sessionId
|
||||
? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}`
|
||||
: `http://127.0.0.1:${relayPort}/recording/status`
|
||||
const response = await fetch(url)
|
||||
const result = await response.json() as IsRecordingResult
|
||||
const result = (await response.json()) as IsRecordingResult
|
||||
|
||||
return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
|
||||
}
|
||||
@@ -183,7 +194,7 @@ export async function cancelRecording(options: { page: Page; sessionId?: string;
|
||||
body: JSON.stringify({ sessionId }),
|
||||
})
|
||||
|
||||
const result = await response.json() as CancelRecordingResult
|
||||
const result = (await response.json()) as CancelRecordingResult
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to cancel recording: ${result.error}`)
|
||||
|
||||
+249
-181
@@ -14,6 +14,7 @@ If using npx or bunx always use @latest for the first session command. so we are
|
||||
### Session management
|
||||
|
||||
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
|
||||
|
||||
- Keep state separate between different tasks or agents
|
||||
- Persist data (pages, variables) across multiple execute calls
|
||||
- Avoid interference when multiple agents use playwriter simultaneously
|
||||
@@ -216,30 +217,33 @@ Each step is a separate execute call. Notice how every action is followed by a s
|
||||
|
||||
```js
|
||||
// 1. Open page and observe — always print URL first
|
||||
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
|
||||
await state.page.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
|
||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page }).then(console.log)
|
||||
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
||||
await state.page.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' })
|
||||
console.log('URL:', state.page.url())
|
||||
await snapshot({ page: state.page }).then(console.log)
|
||||
```
|
||||
|
||||
```js
|
||||
// 2. Act: open command palette → observe result
|
||||
await state.page.keyboard.press('Meta+k');
|
||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /dialog|Search/ }).then(console.log)
|
||||
await state.page.keyboard.press('Meta+k')
|
||||
console.log('URL:', state.page.url())
|
||||
await snapshot({ page: state.page, search: /dialog|Search/ }).then(console.log)
|
||||
// If dialog didn't appear, observe again before retrying
|
||||
```
|
||||
|
||||
```js
|
||||
// 3. Act: type search query → observe result
|
||||
await state.page.keyboard.type('MCP');
|
||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /MCP/ }).then(console.log)
|
||||
await state.page.keyboard.type('MCP')
|
||||
console.log('URL:', state.page.url())
|
||||
await snapshot({ page: state.page, search: /MCP/ }).then(console.log)
|
||||
```
|
||||
|
||||
```js
|
||||
// 4. Act: press Enter → observe plugin loaded
|
||||
await state.page.keyboard.press('Enter');
|
||||
await state.page.waitForTimeout(1000);
|
||||
console.log('URL:', state.page.url());
|
||||
const frame = state.page.frames().find(f => f.url().includes('plugins.framercdn.com'));
|
||||
await state.page.keyboard.press('Enter')
|
||||
await state.page.waitForTimeout(1000)
|
||||
console.log('URL:', state.page.url())
|
||||
const frame = state.page.frames().find((f) => f.url().includes('plugins.framercdn.com'))
|
||||
await snapshot({ page: state.page, frame: frame || undefined }).then(console.log)
|
||||
// If frame not found, wait and observe again — plugin may still be loading
|
||||
```
|
||||
@@ -254,7 +258,11 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
|
||||
```
|
||||
- **Network requests** — verify API calls were made after a form submit or button click:
|
||||
```js
|
||||
state.page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
|
||||
state.page.on('response', async (res) => {
|
||||
if (res.url().includes('/api/')) {
|
||||
console.log(res.status(), res.url())
|
||||
}
|
||||
})
|
||||
```
|
||||
- **URL changes** — confirm navigation happened:
|
||||
```js
|
||||
@@ -266,28 +274,31 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
|
||||
|
||||
**1. Not verifying actions succeeded**
|
||||
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
|
||||
|
||||
```js
|
||||
await state.page.keyboard.type('my text');
|
||||
await state.page.keyboard.type('my text')
|
||||
await snapshot({ page: state.page, search: /my text/ })
|
||||
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
|
||||
```
|
||||
|
||||
**2. Assuming paste/upload worked**
|
||||
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
|
||||
|
||||
```js
|
||||
// Reliable: use file input
|
||||
const fileInput = state.page.locator('input[type="file"]').first();
|
||||
await fileInput.setInputFiles('/path/to/image.png');
|
||||
const fileInput = state.page.locator('input[type="file"]').first()
|
||||
await fileInput.setInputFiles('/path/to/image.png')
|
||||
|
||||
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
|
||||
await state.page.keyboard.press('Meta+v'); // always verify with screenshot!
|
||||
await state.page.keyboard.press('Meta+v') // always verify with screenshot!
|
||||
```
|
||||
|
||||
**3. Using stale locators from old snapshots**
|
||||
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
|
||||
|
||||
```js
|
||||
// BAD: using ref from minutes ago
|
||||
await state.page.locator('[id="old-id"]').click(); // element may have changed
|
||||
await state.page.locator('[id="old-id"]').click() // element may have changed
|
||||
|
||||
// GOOD: get fresh snapshot, then immediately use locators from it
|
||||
await snapshot({ page: state.page, showDiffSinceLastCall: true })
|
||||
@@ -296,28 +307,31 @@ await snapshot({ page: state.page, showDiffSinceLastCall: true })
|
||||
|
||||
**4. Wrong assumptions about current page/element**
|
||||
Before destructive actions (delete, submit), verify you're targeting the right thing:
|
||||
|
||||
```js
|
||||
// Before deleting, verify it's the right item
|
||||
await screenshotWithAccessibilityLabels({ page: state.page });
|
||||
await screenshotWithAccessibilityLabels({ page: state.page })
|
||||
// READ the screenshot to confirm, THEN proceed with delete
|
||||
```
|
||||
|
||||
**5. Text concatenation without line breaks**
|
||||
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
|
||||
|
||||
```js
|
||||
// BAD: newlines in string don't create line breaks
|
||||
await state.page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
|
||||
await state.page.keyboard.type('Line 1\nLine 2') // becomes "Line 1Line 2"
|
||||
|
||||
// GOOD: use Enter key for line breaks
|
||||
await state.page.keyboard.type('Line 1');
|
||||
await state.page.keyboard.press('Enter');
|
||||
await state.page.keyboard.type('Line 2');
|
||||
await state.page.keyboard.type('Line 1')
|
||||
await state.page.keyboard.press('Enter')
|
||||
await state.page.keyboard.type('Line 2')
|
||||
```
|
||||
|
||||
**6. Quote escaping in $'...' syntax**
|
||||
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
|
||||
|
||||
```bash
|
||||
# BAD: nested double quotes break $'...'
|
||||
# BAD: nested double quotes break $'...'
|
||||
playwriter -s 1 -e $'await state.page.locator("[id=\"_r_a_\"]").click()'
|
||||
|
||||
# GOOD: use single quotes inside, or template strings
|
||||
@@ -332,61 +346,65 @@ EOF
|
||||
|
||||
**7. Using screenshots when snapshots suffice**
|
||||
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
|
||||
|
||||
```js
|
||||
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
|
||||
await state.page.screenshot({ path: 'check.png', scale: 'css' });
|
||||
await state.page.screenshot({ path: 'check.png', scale: 'css' })
|
||||
|
||||
// GOOD: snapshot is text — fast, cheap, searchable
|
||||
await snapshot({ page: state.page, search: /expected text/i })
|
||||
|
||||
// GOOD: evaluate DOM directly for content checks
|
||||
const text = await state.page.evaluate(() => document.querySelector('.message')?.textContent);
|
||||
const text = await state.page.evaluate(() => document.querySelector('.message')?.textContent)
|
||||
```
|
||||
|
||||
**8. Assuming page content loaded**
|
||||
Even after `goto()`, dynamic content may not be ready:
|
||||
|
||||
```js
|
||||
await state.page.goto('https://example.com');
|
||||
await state.page.goto('https://example.com')
|
||||
// Content may still be loading via JavaScript!
|
||||
await state.page.waitForSelector('article', { timeout: 10000 });
|
||||
await state.page.waitForSelector('article', { timeout: 10000 })
|
||||
// Or use waitForPageLoad utility
|
||||
await waitForPageLoad({ page: state.page, timeout: 5000 });
|
||||
await waitForPageLoad({ page: state.page, timeout: 5000 })
|
||||
```
|
||||
|
||||
**9. Not using playwriter for JS-rendered sites**
|
||||
Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These sites return empty HTML shells — the real content is rendered by JavaScript. Use playwriter with a real browser session instead:
|
||||
|
||||
```js
|
||||
// BAD: webfetch/curl on Instagram returns empty HTML, grep finds nothing, huge context wasted
|
||||
// BAD: Playwright CLI screenshot needs browser install, produces blank/modal-blocked images
|
||||
|
||||
// GOOD: use playwriter — real browser, full JS rendering, interactive
|
||||
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
|
||||
await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' });
|
||||
await waitForPageLoad({ page: state.page, timeout: 8000 });
|
||||
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
||||
await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' })
|
||||
await waitForPageLoad({ page: state.page, timeout: 8000 })
|
||||
await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log)
|
||||
// Now you can see modals, dismiss them, navigate carousels, extract content
|
||||
```
|
||||
|
||||
**10. Login buttons that open popups**
|
||||
Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead:
|
||||
|
||||
```js
|
||||
// BAD: popup window is not controllable by playwriter
|
||||
await state.page.click('button:has-text("Login with Google")');
|
||||
await state.page.click('button:has-text("Login with Google")')
|
||||
|
||||
// GOOD: cmd+click opens in new tab that playwriter can control
|
||||
await state.page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
|
||||
await state.page.waitForTimeout(2000);
|
||||
await state.page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
|
||||
await state.page.waitForTimeout(2000)
|
||||
|
||||
// Verify new tab opened - last page should be the login page
|
||||
const pages = context.pages();
|
||||
const loginPage = pages[pages.length - 1];
|
||||
const pages = context.pages()
|
||||
const loginPage = pages[pages.length - 1]
|
||||
if (loginPage.url() === state.page.url()) {
|
||||
throw new Error('Cmd+click did not open new tab - login may have opened as popup');
|
||||
throw new Error('Cmd+click did not open new tab - login may have opened as popup')
|
||||
}
|
||||
|
||||
// Complete login flow in loginPage, cookies are shared with original page
|
||||
await loginPage.locator('[data-email]').first().click();
|
||||
await loginPage.waitForURL('**/callback**');
|
||||
await loginPage.locator('[data-email]').first().click()
|
||||
await loginPage.waitForURL('**/callback**')
|
||||
// Original page should now be authenticated
|
||||
```
|
||||
|
||||
@@ -396,10 +414,12 @@ After any action (click, submit, navigate), verify what happened. Always print U
|
||||
|
||||
```js
|
||||
// Always print URL first, then snapshot
|
||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page }).then(console.log)
|
||||
console.log('URL:', state.page.url())
|
||||
await snapshot({ page: state.page }).then(console.log)
|
||||
|
||||
// Filter for specific content when snapshot is large
|
||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /dialog|button|error/i }).then(console.log)
|
||||
console.log('URL:', state.page.url())
|
||||
await snapshot({ page: state.page, search: /dialog|button|error/i }).then(console.log)
|
||||
```
|
||||
|
||||
If nothing changed, try `await waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
|
||||
@@ -421,10 +441,10 @@ Example output:
|
||||
|
||||
```md
|
||||
- banner:
|
||||
- link "Home" [id="nav-home"]
|
||||
- navigation:
|
||||
- link "Docs" [data-testid="docs-link"]
|
||||
- link "Blog" role=link[name="Blog"]
|
||||
- link "Home" [id="nav-home"]
|
||||
- navigation:
|
||||
- link "Docs" [data-testid="docs-link"]
|
||||
- link "Blog" role=link[name="Blog"]
|
||||
```
|
||||
|
||||
Each interactive line ends with a Playwright locator you can pass to `state.page.locator()`.
|
||||
@@ -454,11 +474,12 @@ const snap = await snapshot({ page: state.page, search: /button|submit/i })
|
||||
**Filtering large snapshots in JS** — when the built-in `search` isn't enough (e.g., you need multiple patterns or custom logic), filter the snapshot string directly:
|
||||
|
||||
```js
|
||||
const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false });
|
||||
const relevant = snap.split('\n').filter(l =>
|
||||
l.includes('dialog') || l.includes('error') || l.includes('button')
|
||||
).join('\n');
|
||||
console.log(relevant);
|
||||
const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false })
|
||||
const relevant = snap
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('dialog') || l.includes('error') || l.includes('button'))
|
||||
.join('\n')
|
||||
console.log(relevant)
|
||||
```
|
||||
|
||||
This is much cheaper than taking a screenshot — use it as your primary debugging tool for verifying text content, checking if elements exist, or confirming state changes.
|
||||
@@ -468,12 +489,14 @@ This is much cheaper than taking a screenshot — use it as your primary debuggi
|
||||
Both `snapshot` and `screenshotWithAccessibilityLabels` use the same ref system, so you can combine them effectively.
|
||||
|
||||
**Use `snapshot` when:**
|
||||
|
||||
- Page has simple, semantic structure (articles, forms, lists)
|
||||
- You need to search for specific text or patterns
|
||||
- Token usage matters (text is smaller than images)
|
||||
- You need to process the output programmatically
|
||||
|
||||
**Use `screenshotWithAccessibilityLabels` when:**
|
||||
|
||||
- Page has complex visual layout (grids, galleries, dashboards, maps)
|
||||
- Spatial position matters (e.g., "first image", "top-left button")
|
||||
- DOM order doesn't match visual order
|
||||
@@ -504,9 +527,9 @@ state.page.locator('button').nth(2).click()
|
||||
If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`:
|
||||
|
||||
```js
|
||||
await state.page.locator('button').first().click() // first match
|
||||
await state.page.locator('.item').last().click() // last match
|
||||
await state.page.locator('li').nth(3).click() // 4th item (0-indexed)
|
||||
await state.page.locator('button').first().click() // first match
|
||||
await state.page.locator('.item').last().click() // last match
|
||||
await state.page.locator('li').nth(3).click() // 4th item (0-indexed)
|
||||
```
|
||||
|
||||
## working with pages
|
||||
@@ -521,8 +544,8 @@ On your very first execute call, reuse an existing empty tab or create a new one
|
||||
// Reuse an empty about:blank tab if available, otherwise create a new one.
|
||||
// IMPORTANT: always navigate immediately in the same call to avoid another
|
||||
// agent grabbing the same about:blank tab between execute calls.
|
||||
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
|
||||
await state.page.goto('https://example.com');
|
||||
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
||||
await state.page.goto('https://example.com')
|
||||
// Use state.page for ALL subsequent operations
|
||||
```
|
||||
|
||||
@@ -532,9 +555,9 @@ The user may close your page by accident (e.g., closing a tab in Chrome). Always
|
||||
|
||||
```js
|
||||
if (!state.page || state.page.isClosed()) {
|
||||
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
|
||||
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
||||
}
|
||||
await state.page.goto('https://example.com');
|
||||
await state.page.goto('https://example.com')
|
||||
```
|
||||
|
||||
**Use an existing page only when the user asks:**
|
||||
@@ -542,16 +565,16 @@ await state.page.goto('https://example.com');
|
||||
Only use a page from `context.pages()` if the user explicitly asks you to control a specific tab they already opened (e.g., they're logged into an app). Find it by URL pattern and store it in state:
|
||||
|
||||
```js
|
||||
const pages = context.pages().filter(x => x.url().includes('myapp.com'));
|
||||
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.');
|
||||
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`);
|
||||
state.targetPage = pages[0];
|
||||
const pages = context.pages().filter((x) => x.url().includes('myapp.com'))
|
||||
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.')
|
||||
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
|
||||
state.targetPage = pages[0]
|
||||
```
|
||||
|
||||
**List all available pages:**
|
||||
|
||||
```js
|
||||
context.pages().map(p => p.url())
|
||||
context.pages().map((p) => p.url())
|
||||
```
|
||||
|
||||
## navigation
|
||||
@@ -559,8 +582,8 @@ context.pages().map(p => p.url())
|
||||
**Use `domcontentloaded`** for `page.goto()`:
|
||||
|
||||
```js
|
||||
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
|
||||
await waitForPageLoad({ page: state.page, timeout: 5000 });
|
||||
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
|
||||
await waitForPageLoad({ page: state.page, timeout: 5000 })
|
||||
```
|
||||
|
||||
## common patterns
|
||||
@@ -573,9 +596,9 @@ await waitForPageLoad({ page: state.page, timeout: 5000 });
|
||||
|
||||
// GOOD: fetch inside state.page.evaluate uses browser's full session
|
||||
const data = await state.page.evaluate(async (url) => {
|
||||
const resp = await fetch(url);
|
||||
return await resp.text();
|
||||
}, 'https://example.com/protected/resource');
|
||||
const resp = await fetch(url)
|
||||
return await resp.text()
|
||||
}, 'https://example.com/protected/resource')
|
||||
```
|
||||
|
||||
**Downloading large data** - console output truncates large strings. Trigger a browser download instead:
|
||||
@@ -583,18 +606,19 @@ const data = await state.page.evaluate(async (url) => {
|
||||
```js
|
||||
// Fetch protected data and trigger download to user's Downloads folder
|
||||
await state.page.evaluate(async (url) => {
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.text();
|
||||
const blob = new Blob([data], { type: 'application/octet-stream' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'data.json';
|
||||
a.click();
|
||||
}, 'https://example.com/protected/large-file');
|
||||
const resp = await fetch(url)
|
||||
const data = await resp.text()
|
||||
const blob = new Blob([data], { type: 'application/octet-stream' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = 'data.json'
|
||||
a.click()
|
||||
}, 'https://example.com/protected/large-file')
|
||||
// File saves to ~/Downloads - read it from there
|
||||
```
|
||||
|
||||
**Avoid permission-gated browser APIs** - some APIs require user permission prompts or special browser flags. These often fail silently or hang. Examples to avoid:
|
||||
|
||||
- `navigator.clipboard.writeText()` - requires permission
|
||||
- Multiple concurrent downloads - browser may block
|
||||
- `window.showSaveFilePicker()` - requires user gesture
|
||||
@@ -605,46 +629,52 @@ Instead, use simpler alternatives (single download via `a.click()`, store data i
|
||||
**Links that open new tabs** - playwriter cannot control popup windows opened via `window.open`. Use cmd+click to open in a controllable new tab instead (see mistake #9 above for a full example):
|
||||
|
||||
```js
|
||||
await state.page.locator('a[target=_blank]').click({ modifiers: ['Meta'] });
|
||||
await state.page.waitForTimeout(1000);
|
||||
const pages = context.pages();
|
||||
const newTab = pages[pages.length - 1];
|
||||
console.log('New tab URL:', newTab.url());
|
||||
await state.page.locator('a[target=_blank]').click({ modifiers: ['Meta'] })
|
||||
await state.page.waitForTimeout(1000)
|
||||
const pages = context.pages()
|
||||
const newTab = pages[pages.length - 1]
|
||||
console.log('New tab URL:', newTab.url())
|
||||
```
|
||||
|
||||
**Downloads** - capture and save:
|
||||
|
||||
```js
|
||||
const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')]);
|
||||
await download.saveAs(`/tmp/${download.suggestedFilename()}`);
|
||||
const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')])
|
||||
await download.saveAs(`/tmp/${download.suggestedFilename()}`)
|
||||
```
|
||||
|
||||
**iFrames** - two approaches depending on what you need:
|
||||
|
||||
```js
|
||||
// frameLocator: for chaining locator operations (click, fill, etc.)
|
||||
const frame = state.page.frameLocator('#my-iframe');
|
||||
await frame.locator('button').click();
|
||||
const frame = state.page.frameLocator('#my-iframe')
|
||||
await frame.locator('button').click()
|
||||
|
||||
// contentFrame: returns a Frame object, needed for snapshot({ frame })
|
||||
const frame2 = await state.page.locator('iframe').contentFrame();
|
||||
const frame2 = await state.page.locator('iframe').contentFrame()
|
||||
await snapshot({ frame: frame2 })
|
||||
```
|
||||
|
||||
**Dialogs** - handle alerts/confirms/prompts:
|
||||
|
||||
```js
|
||||
state.page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); });
|
||||
await state.page.click('button.trigger-alert');
|
||||
state.page.on('dialog', async (dialog) => {
|
||||
console.log(dialog.message())
|
||||
await dialog.accept()
|
||||
})
|
||||
await state.page.click('button.trigger-alert')
|
||||
```
|
||||
|
||||
**Handling page obstacles (cookie modals, login walls, age gates)** - most major websites show blocking overlays. Always check for these with `snapshot()` right after navigation and dismiss them before doing anything else:
|
||||
|
||||
```js
|
||||
// After navigating, check for common obstacles
|
||||
await waitForPageLoad({ page: state.page, timeout: 5000 });
|
||||
const snap = await snapshot({ page: state.page, search: /cookie|consent|accept|reject|decline|allow|age|verify|login|sign.in/i });
|
||||
console.log(snap);
|
||||
await waitForPageLoad({ page: state.page, timeout: 5000 })
|
||||
const snap = await snapshot({
|
||||
page: state.page,
|
||||
search: /cookie|consent|accept|reject|decline|allow|age|verify|login|sign.in/i,
|
||||
})
|
||||
console.log(snap)
|
||||
// Look for dismiss/accept/decline buttons in the snapshot, then click them:
|
||||
// await state.page.locator('button:has-text("Accept")').click();
|
||||
// await state.page.locator('button:has-text("Decline optional")').click();
|
||||
@@ -658,18 +688,20 @@ If the page requires login and the user is already logged into Chrome, their ses
|
||||
```js
|
||||
// Extract all image URLs from rendered DOM
|
||||
const images = await state.page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('img[src]')).map(img => ({
|
||||
src: img.src, alt: img.alt, width: img.naturalWidth
|
||||
}))
|
||||
);
|
||||
console.log(JSON.stringify(images, null, 2));
|
||||
Array.from(document.querySelectorAll('img[src]')).map((img) => ({
|
||||
src: img.src,
|
||||
alt: img.alt,
|
||||
width: img.naturalWidth,
|
||||
})),
|
||||
)
|
||||
console.log(JSON.stringify(images, null, 2))
|
||||
|
||||
// Download a specific image to disk
|
||||
const fs = require('node:fs');
|
||||
const resp = await fetch(images[0].src);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
fs.writeFileSync('./downloaded-image.jpg', buf);
|
||||
console.log('Saved', buf.length, 'bytes');
|
||||
const fs = require('node:fs')
|
||||
const resp = await fetch(images[0].src)
|
||||
const buf = Buffer.from(await resp.arrayBuffer())
|
||||
fs.writeFileSync('./downloaded-image.jpg', buf)
|
||||
console.log('Saved', buf.length, 'bytes')
|
||||
```
|
||||
|
||||
For carousels or lazy-loaded galleries, you may need to click navigation arrows or scroll first, then re-extract. Use network interception (see "network interception" section) to capture high-resolution CDN URLs that may differ from the `img.src` thumbnails.
|
||||
@@ -698,6 +730,7 @@ const fullHtml = await getCleanHTML({ locator: state.page, showDiffSinceLastCall
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `locator` - Playwright Locator or Page to get HTML from
|
||||
- `search` - string/regex to filter results (returns first 10 matching lines with 5 lines context)
|
||||
- `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full HTML.
|
||||
@@ -705,12 +738,14 @@ const fullHtml = await getCleanHTML({ locator: state.page, showDiffSinceLastCall
|
||||
|
||||
**HTML processing:**
|
||||
The function cleans HTML for compact, readable output:
|
||||
|
||||
- **Removes tags**: script, style, link, meta, noscript, svg, head
|
||||
- **Unwraps nested wrappers**: Empty divs/spans with no attributes that only wrap a single child are collapsed (e.g., `<div><div><div><p>text</p></div></div></div>` → `<div><p>text</p></div>`)
|
||||
- **Removes empty elements**: Elements with no attributes and no content are removed
|
||||
- **Truncates long values**: Attribute values >200 chars and text content >500 chars are truncated
|
||||
|
||||
**Attributes kept (summary):**
|
||||
|
||||
- Common semantic and ARIA attributes (e.g., `href`, `name`, `type`, `aria-*`)
|
||||
- All `data-*` test attributes
|
||||
- Frequently used test IDs and special attributes (e.g., `testid`, `qa`, `e2e`, `vimium-label`)
|
||||
@@ -725,6 +760,7 @@ const matches = await getPageMarkdown({ page: state.page, search: /API/i }) //
|
||||
```
|
||||
|
||||
**Output format:**
|
||||
|
||||
```
|
||||
# Article Title
|
||||
|
||||
@@ -736,11 +772,13 @@ The main article content as plain text, with paragraphs preserved...
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `page` - Playwright Page to extract content from
|
||||
- `search` - string/regex to filter content (returns first 10 matching lines with 5 lines context)
|
||||
- `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full content.
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Extract article text for LLM processing without HTML noise
|
||||
- Get readable content from news sites, blogs, documentation
|
||||
- Compare content changes after interactions
|
||||
@@ -755,46 +793,53 @@ await waitForPageLoad({ page: state.page, timeout?, pollInterval?, minWait? })
|
||||
**getCDPSession** - send raw CDP commands:
|
||||
|
||||
```js
|
||||
const cdp = await getCDPSession({ page: state.page });
|
||||
const metrics = await cdp.send('Page.getLayoutMetrics');
|
||||
const cdp = await getCDPSession({ page: state.page })
|
||||
const metrics = await cdp.send('Page.getLayoutMetrics')
|
||||
```
|
||||
|
||||
**getLocatorStringForElement** - get stable Playwright selector from an element:
|
||||
|
||||
```js
|
||||
const selector = await getLocatorStringForElement(state.page.locator('[id="submit-btn"]'));
|
||||
const selector = await getLocatorStringForElement(state.page.locator('[id="submit-btn"]'))
|
||||
// => "getByRole('button', { name: 'Save' })"
|
||||
```
|
||||
|
||||
**getReactSource** - get React component source location (dev mode only):
|
||||
|
||||
```js
|
||||
const source = await getReactSource({ locator: state.page.locator('[data-testid="submit-btn"]') });
|
||||
const source = await getReactSource({ locator: state.page.locator('[data-testid="submit-btn"]') })
|
||||
// => { fileName, lineNumber, columnNumber, componentName }
|
||||
```
|
||||
|
||||
**getStylesForLocator** - inspect CSS styles applied to an element, like browser DevTools "Styles" panel. Useful for debugging styling issues, finding where a CSS property is defined (file:line), and checking inherited styles. Returns selector, source location, and declarations for each matching rule. ALWAYS fetch `https://playwriter.dev/resources/styles-api.md` first with curl or webfetch tool.
|
||||
|
||||
```js
|
||||
const styles = await getStylesForLocator({ locator: state.page.locator('.btn'), cdp: await getCDPSession({ page: state.page }) });
|
||||
console.log(formatStylesAsText(styles));
|
||||
const styles = await getStylesForLocator({
|
||||
locator: state.page.locator('.btn'),
|
||||
cdp: await getCDPSession({ page: state.page }),
|
||||
})
|
||||
console.log(formatStylesAsText(styles))
|
||||
```
|
||||
|
||||
**createDebugger** - set breakpoints, step through code, inspect variables at runtime. Useful for debugging issues that only reproduce in browser, understanding code flow, and inspecting state at specific points. Can pause on exceptions, evaluate expressions in scope, and blackbox framework code. ALWAYS fetch `https://playwriter.dev/resources/debugger-api.md` first.
|
||||
|
||||
```js
|
||||
const cdp = await getCDPSession({ page: state.page }); const dbg = createDebugger({ cdp }); await dbg.enable();
|
||||
const scripts = await dbg.listScripts({ search: 'app' });
|
||||
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 });
|
||||
const cdp = await getCDPSession({ page: state.page })
|
||||
const dbg = createDebugger({ cdp })
|
||||
await dbg.enable()
|
||||
const scripts = await dbg.listScripts({ search: 'app' })
|
||||
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 })
|
||||
// when paused: dbg.inspectLocalVariables(), dbg.stepOver(), dbg.resume()
|
||||
```
|
||||
|
||||
**createEditor** - view and live-edit page scripts and CSS at runtime. Edits are in-memory (persist until reload). Useful for testing quick fixes, searching page scripts with grep, and toggling debug flags. ALWAYS read `https://playwriter.dev/resources/editor-api.md` first.
|
||||
|
||||
```js
|
||||
const cdp = await getCDPSession({ page: state.page }); const editor = createEditor({ cdp }); await editor.enable();
|
||||
const matches = await editor.grep({ regex: /console\.log/ });
|
||||
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' });
|
||||
const cdp = await getCDPSession({ page: state.page })
|
||||
const editor = createEditor({ cdp })
|
||||
await editor.enable()
|
||||
const matches = await editor.grep({ regex: /console\.log/ })
|
||||
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' })
|
||||
```
|
||||
|
||||
**screenshotWithAccessibilityLabels** - take a screenshot with Vimium-style visual labels overlaid on interactive elements. Shows labels, captures screenshot, then removes labels. The image and accessibility snapshot are automatically included in the response. Can be called multiple times to capture multiple screenshots. Use a timeout of **20 seconds** for complex pages.
|
||||
@@ -802,15 +847,15 @@ await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString:
|
||||
Prefer this for pages with grids, image galleries, maps, or complex visual layouts where spatial position matters. For simple text-heavy pages, `snapshot` with search is faster and uses fewer tokens.
|
||||
|
||||
```js
|
||||
await screenshotWithAccessibilityLabels({ page: state.page });
|
||||
await screenshotWithAccessibilityLabels({ page: state.page })
|
||||
// Image and accessibility snapshot are automatically included in response
|
||||
// Use refs from snapshot to interact with elements
|
||||
await state.page.locator('[id="submit-btn"]').click();
|
||||
await state.page.locator('[id="submit-btn"]').click()
|
||||
|
||||
// Can take multiple screenshots in one execution
|
||||
await screenshotWithAccessibilityLabels({ page: state.page });
|
||||
await state.page.click('button');
|
||||
await screenshotWithAccessibilityLabels({ page: state.page });
|
||||
await screenshotWithAccessibilityLabels({ page: state.page })
|
||||
await state.page.click('button')
|
||||
await screenshotWithAccessibilityLabels({ page: state.page })
|
||||
// Both images are included in the response
|
||||
```
|
||||
|
||||
@@ -822,31 +867,32 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
|
||||
|
||||
```js
|
||||
// Start recording - outputPath must be specified upfront
|
||||
await startRecording({
|
||||
await startRecording({
|
||||
page: state.page,
|
||||
outputPath: './recording.mp4',
|
||||
frameRate: 30, // default: 30
|
||||
audio: false, // default: false (tab audio)
|
||||
videoBitsPerSecond: 2500000 // 2.5 Mbps
|
||||
});
|
||||
frameRate: 30, // default: 30
|
||||
audio: false, // default: false (tab audio)
|
||||
videoBitsPerSecond: 2500000, // 2.5 Mbps
|
||||
})
|
||||
|
||||
// Navigate around - recording continues!
|
||||
await state.page.click('a');
|
||||
await state.page.waitForLoadState('domcontentloaded');
|
||||
await state.page.goBack();
|
||||
await state.page.click('a')
|
||||
await state.page.waitForLoadState('domcontentloaded')
|
||||
await state.page.goBack()
|
||||
|
||||
// Stop and get result
|
||||
const { path, duration, size } = await stopRecording({ page: state.page });
|
||||
console.log(`Saved ${size} bytes, duration: ${duration}ms`);
|
||||
const { path, duration, size } = await stopRecording({ page: state.page })
|
||||
console.log(`Saved ${size} bytes, duration: ${duration}ms`)
|
||||
```
|
||||
|
||||
Additional recording utilities:
|
||||
|
||||
```js
|
||||
// Check if recording is active
|
||||
const { isRecording, startedAt } = await isRecording({ page: state.page });
|
||||
const { isRecording, startedAt } = await isRecording({ page: state.page })
|
||||
|
||||
// Cancel recording without saving
|
||||
await cancelRecording({ page: state.page });
|
||||
await cancelRecording({ page: state.page })
|
||||
```
|
||||
|
||||
**Key difference from getDisplayMedia**: This approach uses `chrome.tabCapture` which runs in the extension context, not the page. The recording persists across navigations because the extension holds the `MediaRecorder`, not the page's JavaScript context.
|
||||
@@ -856,8 +902,8 @@ await cancelRecording({ page: state.page });
|
||||
Users can right-click → "Copy Playwriter Element Reference" to store elements in `globalThis.playwriterPinnedElem1` (increments for each pin). The reference is copied to clipboard:
|
||||
|
||||
```js
|
||||
const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1);
|
||||
await el.click();
|
||||
const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
|
||||
await el.click()
|
||||
```
|
||||
|
||||
## taking screenshots
|
||||
@@ -865,7 +911,7 @@ await el.click();
|
||||
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
|
||||
|
||||
```js
|
||||
await state.page.screenshot({ path: 'shot.png', scale: 'css' });
|
||||
await state.page.screenshot({ path: 'shot.png', scale: 'css' })
|
||||
```
|
||||
|
||||
If you want to read back the image file into context make sure to resize it first, scaling down the image to make sure max size is 1500px. for example with `sips --resampleHeightWidthMax 1500 input.png --out output.png` on macOS.
|
||||
@@ -875,14 +921,14 @@ If you want to read back the image file into context make sure to resize it firs
|
||||
Code inside `page.evaluate()` runs in the browser - use plain JavaScript only, no TypeScript syntax. Return values and log outside (console.log inside evaluate runs in browser, not visible):
|
||||
|
||||
```js
|
||||
const title = await state.page.evaluate(() => document.title);
|
||||
console.log('Title:', title);
|
||||
const title = await state.page.evaluate(() => document.title)
|
||||
console.log('Title:', title)
|
||||
|
||||
const info = await state.page.evaluate(() => ({
|
||||
url: location.href,
|
||||
buttons: document.querySelectorAll('button').length,
|
||||
}));
|
||||
console.log(info);
|
||||
url: location.href,
|
||||
buttons: document.querySelectorAll('button').length,
|
||||
}))
|
||||
console.log(info)
|
||||
```
|
||||
|
||||
## loading files
|
||||
@@ -890,7 +936,9 @@ console.log(info);
|
||||
Fill inputs with file content:
|
||||
|
||||
```js
|
||||
const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'utf-8'); await state.page.locator('textarea').fill(content);
|
||||
const fs = require('node:fs')
|
||||
const content = fs.readFileSync('./data.txt', 'utf-8')
|
||||
await state.page.locator('textarea').fill(content)
|
||||
```
|
||||
|
||||
## network interception
|
||||
@@ -898,31 +946,46 @@ const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'ut
|
||||
For scraping or reverse-engineering APIs, intercept network requests instead of scrolling DOM. Store in `state` to analyze across calls:
|
||||
|
||||
```js
|
||||
state.requests = []; state.responses = [];
|
||||
state.page.on('request', req => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }); });
|
||||
state.page.on('response', async res => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }); } catch {} } });
|
||||
state.requests = []
|
||||
state.responses = []
|
||||
state.page.on('request', (req) => {
|
||||
if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() })
|
||||
})
|
||||
state.page.on('response', async (res) => {
|
||||
if (res.url().includes('/api/')) {
|
||||
try {
|
||||
state.responses.push({ url: res.url(), status: res.status(), body: await res.json() })
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Then trigger actions (scroll, click, navigate) and analyze captured data:
|
||||
|
||||
```js
|
||||
console.log('Captured', state.responses.length, 'API calls');
|
||||
state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)));
|
||||
console.log('Captured', state.responses.length, 'API calls')
|
||||
state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
|
||||
```
|
||||
|
||||
Inspect a specific response to understand schema:
|
||||
|
||||
```js
|
||||
const resp = state.responses.find(r => r.url.includes('users'));
|
||||
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000));
|
||||
const resp = state.responses.find((r) => r.url.includes('users'))
|
||||
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
|
||||
```
|
||||
|
||||
Replay API directly (useful for pagination):
|
||||
|
||||
```js
|
||||
const { url, headers } = state.requests.find(r => r.url.includes('feed'));
|
||||
const data = await state.page.evaluate(async ({ url, headers }) => { const res = await fetch(url, { headers }); return res.json(); }, { url, headers });
|
||||
console.log(data);
|
||||
const { url, headers } = state.requests.find((r) => r.url.includes('feed'))
|
||||
const data = await state.page.evaluate(
|
||||
async ({ url, headers }) => {
|
||||
const res = await fetch(url, { headers })
|
||||
return res.json()
|
||||
},
|
||||
{ url, headers },
|
||||
)
|
||||
console.log(data)
|
||||
```
|
||||
|
||||
Clean up listeners when done: `state.page.removeAllListeners('request'); state.page.removeAllListeners('response');`
|
||||
@@ -934,38 +997,39 @@ When debugging why a web app isn't working (e.g., content not rendering, API err
|
||||
**1. Console logs** — use `getLatestLogs` to check for errors:
|
||||
|
||||
```js
|
||||
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 });
|
||||
const appLogs = await getLatestLogs({ page: state.page, search: /myComponent|state/i });
|
||||
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
|
||||
const appLogs = await getLatestLogs({ page: state.page, search: /myComponent|state/i })
|
||||
```
|
||||
|
||||
**2. DOM inspection via evaluate** — check content directly without screenshots:
|
||||
|
||||
```js
|
||||
const info = await state.page.evaluate(() => {
|
||||
const msgs = document.querySelectorAll('.message');
|
||||
return Array.from(msgs).map(m => ({
|
||||
const msgs = document.querySelectorAll('.message')
|
||||
return Array.from(msgs).map((m) => ({
|
||||
text: m.textContent?.slice(0, 200),
|
||||
visible: m.offsetHeight > 0,
|
||||
}));
|
||||
});
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
}))
|
||||
})
|
||||
console.log(JSON.stringify(info, null, 2))
|
||||
```
|
||||
|
||||
**3. Combine snapshot + logs for full picture:**
|
||||
|
||||
```js
|
||||
await state.page.keyboard.press('Enter');
|
||||
await state.page.waitForTimeout(2000);
|
||||
await state.page.keyboard.press('Enter')
|
||||
await state.page.waitForTimeout(2000)
|
||||
|
||||
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ });
|
||||
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 });
|
||||
console.log('UI:', snap);
|
||||
console.log('Logs:', logs);
|
||||
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
|
||||
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
|
||||
console.log('UI:', snap)
|
||||
console.log('Logs:', logs)
|
||||
```
|
||||
|
||||
## capabilities
|
||||
|
||||
Examples of what playwriter can do:
|
||||
|
||||
- Monitor console logs while user reproduces a bug
|
||||
- Intercept network requests to reverse-engineer APIs and build SDKs
|
||||
- Scrape data by replaying paginated API calls instead of scrolling DOM
|
||||
@@ -975,7 +1039,6 @@ Examples of what playwriter can do:
|
||||
- Handle popups, downloads, iframes, and dialog boxes
|
||||
- Record videos of browser sessions that survive page navigation
|
||||
|
||||
|
||||
## computer use
|
||||
|
||||
Playwriter provides the same browser control as Anthropic's `computer_20250124` tool and the Claude Chrome extension, using Playwright APIs instead of screenshot-based coordinate clicking. No computer use beta needed.
|
||||
@@ -989,21 +1052,24 @@ This section covers low-level mouse/keyboard APIs not documented elsewhere. For
|
||||
await state.page.locator('button[name="Submit"]').click()
|
||||
await state.page.locator('text=Login').click({ button: 'right' })
|
||||
await state.page.locator('text=Login').dblclick()
|
||||
await state.page.locator('a').first().click({ modifiers: ['Meta'] }) // cmd+click opens new tab
|
||||
await state.page
|
||||
.locator('a')
|
||||
.first()
|
||||
.click({ modifiers: ['Meta'] }) // cmd+click opens new tab
|
||||
|
||||
// By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets)
|
||||
await state.page.mouse.click(450, 320) // left click
|
||||
await state.page.mouse.click(450, 320, { button: 'right' }) // right click
|
||||
await state.page.mouse.dblclick(450, 320) // double click
|
||||
await state.page.mouse.click(450, 320, { clickCount: 3 }) // triple click
|
||||
await state.page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
|
||||
await state.page.mouse.click(450, 320) // left click
|
||||
await state.page.mouse.click(450, 320, { button: 'right' }) // right click
|
||||
await state.page.mouse.dblclick(450, 320) // double click
|
||||
await state.page.mouse.click(450, 320, { clickCount: 3 }) // triple click
|
||||
await state.page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
|
||||
```
|
||||
|
||||
### hover
|
||||
|
||||
```js
|
||||
await state.page.locator('.tooltip-trigger').hover() // by locator (preferred)
|
||||
await state.page.mouse.move(450, 320) // by coordinates
|
||||
await state.page.locator('.tooltip-trigger').hover() // by locator (preferred)
|
||||
await state.page.mouse.move(450, 320) // by coordinates
|
||||
```
|
||||
|
||||
### scroll
|
||||
@@ -1013,17 +1079,19 @@ await state.page.mouse.move(450, 320) // by coordinates
|
||||
await state.page.locator('#footer').scrollIntoViewIfNeeded()
|
||||
|
||||
// By pixel (for canvas, maps, infinite scroll)
|
||||
await state.page.mouse.wheel(0, 300) // scroll down 300px
|
||||
await state.page.mouse.wheel(0, -300) // scroll up
|
||||
await state.page.mouse.wheel(300, 0) // scroll right
|
||||
await state.page.mouse.wheel(-300, 0) // scroll left
|
||||
await state.page.mouse.wheel(0, 300) // scroll down 300px
|
||||
await state.page.mouse.wheel(0, -300) // scroll up
|
||||
await state.page.mouse.wheel(300, 0) // scroll right
|
||||
await state.page.mouse.wheel(-300, 0) // scroll left
|
||||
|
||||
// Scroll at a specific position
|
||||
await state.page.mouse.move(450, 320)
|
||||
await state.page.mouse.wheel(0, 500)
|
||||
|
||||
// Scroll inside a container
|
||||
await state.page.locator('.scrollable-list').evaluate(el => { el.scrollTop += 500 })
|
||||
await state.page.locator('.scrollable-list').evaluate((el) => {
|
||||
el.scrollTop += 500
|
||||
})
|
||||
```
|
||||
|
||||
### drag
|
||||
@@ -1035,7 +1103,7 @@ await state.page.locator('#item').dragTo(state.page.locator('#target'))
|
||||
// By coordinates (for canvas, sliders, custom drag targets)
|
||||
await state.page.mouse.move(100, 200)
|
||||
await state.page.mouse.down()
|
||||
await state.page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
|
||||
await state.page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
|
||||
await state.page.mouse.up()
|
||||
```
|
||||
|
||||
@@ -1071,12 +1139,12 @@ Playwriter supports [Ghost Browser](https://ghostbrowser.com/) for multi-identit
|
||||
|
||||
```js
|
||||
// List identities and open tabs in different ones
|
||||
const identities = await chrome.projects.getIdentitiesList();
|
||||
await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id });
|
||||
const identities = await chrome.projects.getIdentitiesList()
|
||||
await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id })
|
||||
|
||||
// Assign proxies per tab or identity
|
||||
const proxies = await chrome.ghostProxies.getList();
|
||||
await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id);
|
||||
const proxies = await chrome.ghostProxies.getList()
|
||||
await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id)
|
||||
```
|
||||
|
||||
For complete API reference with all methods, types, and examples, read:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,21 +7,24 @@ process.title = 'playwriter-ws-server'
|
||||
const logger = createFileLogger()
|
||||
|
||||
process.on('uncaughtException', async (err) => {
|
||||
await logger.error('Uncaught Exception:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
await logger.error('Uncaught Exception:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', async (reason) => {
|
||||
await logger.error('Unhandled Rejection:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
await logger.error('Unhandled Rejection:', reason)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.on('exit', async (code) => {
|
||||
await logger.log(`Process exiting with code: ${code}`);
|
||||
});
|
||||
await logger.log(`Process exiting with code: ${code}`)
|
||||
})
|
||||
|
||||
|
||||
export async function startServer({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) {
|
||||
export async function startServer({
|
||||
port = 19988,
|
||||
host = '127.0.0.1',
|
||||
token,
|
||||
}: { port?: number; host?: string; token?: string } = {}) {
|
||||
const server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
|
||||
|
||||
console.log('CDP Relay Server running. Press Ctrl+C to stop.')
|
||||
|
||||
@@ -74,4 +74,11 @@ async function compareStyles() {
|
||||
console.log(formatStylesAsText(secondary))
|
||||
}
|
||||
|
||||
export { getElementStyles, inspectButtonStyles, getStylesWithUserAgent, findPropertySource, checkInheritedStyles, compareStyles }
|
||||
export {
|
||||
getElementStyles,
|
||||
inspectButtonStyles,
|
||||
getStylesWithUserAgent,
|
||||
findPropertySource,
|
||||
checkInheritedStyles,
|
||||
compareStyles,
|
||||
}
|
||||
|
||||
+20
-21
@@ -120,12 +120,12 @@ export async function getStylesForLocator({
|
||||
const matchedStyles = await cdp.send('CSS.getMatchedStylesForNode', { nodeId })
|
||||
|
||||
const stylesheetUrls = new Map<string, string>()
|
||||
|
||||
|
||||
const processStyleSheetId = async (styleSheetId: string | undefined): Promise<StyleSource | null> => {
|
||||
if (!styleSheetId) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
if (!stylesheetUrls.has(styleSheetId)) {
|
||||
try {
|
||||
const header = await cdp.send('CSS.getStyleSheetText', { styleSheetId })
|
||||
@@ -134,7 +134,7 @@ export async function getStylesForLocator({
|
||||
stylesheetUrls.set(styleSheetId, '')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -145,14 +145,15 @@ export async function getStylesForLocator({
|
||||
const rule = ruleMatch.rule
|
||||
const sourceRange = (rule as any).selectorList?.range as SourceRange | undefined
|
||||
const styleSheetId = rule.styleSheetId
|
||||
|
||||
|
||||
let source: StyleSource | null = null
|
||||
if (styleSheetId && sourceRange) {
|
||||
const styleSheet = (matchedStyles as any).cssStyleSheetHeaders?.find(
|
||||
(h: CSSStyleSheetHeader) => h.styleSheetId === styleSheetId
|
||||
(h: CSSStyleSheetHeader) => h.styleSheetId === styleSheetId,
|
||||
)
|
||||
const url = styleSheet?.sourceURL || (rule as any).origin === 'user-agent' ? 'user-agent' : `stylesheet:${styleSheetId}`
|
||||
|
||||
const url =
|
||||
styleSheet?.sourceURL || (rule as any).origin === 'user-agent' ? 'user-agent' : `stylesheet:${styleSheetId}`
|
||||
|
||||
source = {
|
||||
url: (rule as any).styleSheetId ? await getStylesheetUrl(cdp, styleSheetId) : 'user-agent',
|
||||
line: sourceRange.startLine + 1,
|
||||
@@ -224,9 +225,7 @@ export async function getStylesForLocator({
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRules = includeUserAgentStyles
|
||||
? rules
|
||||
: rules.filter((r) => r.origin !== 'user-agent')
|
||||
const filteredRules = includeUserAgentStyles ? rules : rules.filter((r) => r.origin !== 'user-agent')
|
||||
|
||||
return {
|
||||
element: elementDescription,
|
||||
@@ -239,7 +238,7 @@ function extractDeclarations(style: CSSStyle): StyleDeclarations {
|
||||
if (!style?.cssProperties) {
|
||||
return {}
|
||||
}
|
||||
|
||||
|
||||
const result: StyleDeclarations = {}
|
||||
for (const prop of style.cssProperties) {
|
||||
if (!prop.value || prop.value === 'initial' || prop.name.startsWith('-webkit-')) {
|
||||
@@ -253,13 +252,13 @@ function extractDeclarations(style: CSSStyle): StyleDeclarations {
|
||||
|
||||
function formatElementDescription(node: any): string {
|
||||
let desc = node.localName || node.nodeName?.toLowerCase() || 'element'
|
||||
|
||||
|
||||
if (node.attributes) {
|
||||
const attrs: Record<string, string> = {}
|
||||
for (let i = 0; i < node.attributes.length; i += 2) {
|
||||
attrs[node.attributes[i]] = node.attributes[i + 1]
|
||||
}
|
||||
|
||||
|
||||
if (attrs.id) {
|
||||
desc += `#${attrs.id}`
|
||||
}
|
||||
@@ -267,7 +266,7 @@ function formatElementDescription(node: any): string {
|
||||
desc += `.${attrs.class.split(' ').join('.')}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return desc
|
||||
}
|
||||
|
||||
@@ -282,10 +281,10 @@ async function getStylesheetUrl(cdp: ICDPSession, styleSheetId: string): Promise
|
||||
|
||||
export function formatStylesAsText(styles: StylesResult): string {
|
||||
const lines: string[] = []
|
||||
|
||||
|
||||
lines.push(`Element: ${styles.element}`)
|
||||
lines.push('')
|
||||
|
||||
|
||||
if (styles.inlineStyle) {
|
||||
lines.push('Inline styles:')
|
||||
for (const [prop, value] of Object.entries(styles.inlineStyle)) {
|
||||
@@ -293,10 +292,10 @@ export function formatStylesAsText(styles: StylesResult): string {
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
|
||||
const directRules = styles.rules.filter((r) => !r.inheritedFrom)
|
||||
const inheritedRules = styles.rules.filter((r) => r.inheritedFrom)
|
||||
|
||||
|
||||
if (directRules.length > 0) {
|
||||
lines.push('Matched rules:')
|
||||
for (const rule of directRules) {
|
||||
@@ -312,7 +311,7 @@ export function formatStylesAsText(styles: StylesResult): string {
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
|
||||
if (inheritedRules.length > 0) {
|
||||
const byAncestor = new Map<string, StyleRule[]>()
|
||||
for (const rule of inheritedRules) {
|
||||
@@ -322,7 +321,7 @@ export function formatStylesAsText(styles: StylesResult): string {
|
||||
}
|
||||
byAncestor.get(key)!.push(rule)
|
||||
}
|
||||
|
||||
|
||||
for (const [ancestor, rules] of byAncestor) {
|
||||
lines.push(`Inherited from ${ancestor}:`)
|
||||
for (const rule of rules) {
|
||||
@@ -339,6 +338,6 @@ export function formatStylesAsText(styles: StylesResult): string {
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ExtensionState } from 'mcp-extension/src/types.js'
|
||||
|
||||
declare global {
|
||||
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
|
||||
var getExtensionState: () => ExtensionState;
|
||||
var disconnectEverything: () => Promise<void>;
|
||||
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>
|
||||
var getExtensionState: () => ExtensionState
|
||||
var disconnectEverything: () => Promise<void>
|
||||
|
||||
// Browser globals used in evaluate() calls
|
||||
var window: any;
|
||||
var document: any;
|
||||
// Browser globals used in evaluate() calls
|
||||
var window: any
|
||||
var document: any
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
+106
-93
@@ -21,10 +21,15 @@ async function buildExtension({ port, distDir }: { port: number; distDir: string
|
||||
})
|
||||
.then(async () => {
|
||||
// Build into a per-port dist to avoid parallel test runs overwriting each other.
|
||||
await execAsync(`TESTING=1 PLAYWRITER_PORT=${port} PLAYWRITER_EXTENSION_DIST=${distDir} pnpm build`, { cwd: '../extension' })
|
||||
await execAsync(`TESTING=1 PLAYWRITER_PORT=${port} PLAYWRITER_EXTENSION_DIST=${distDir} pnpm build`, {
|
||||
cwd: '../extension',
|
||||
})
|
||||
})
|
||||
|
||||
extensionBuildQueues.set(distDir, buildPromise.finally(() => {}))
|
||||
extensionBuildQueues.set(
|
||||
distDir,
|
||||
buildPromise.finally(() => {}),
|
||||
)
|
||||
await buildPromise
|
||||
}
|
||||
|
||||
@@ -103,7 +108,10 @@ export async function setupTestContext({
|
||||
return { browserContext, userDataDir, relayServer }
|
||||
}
|
||||
|
||||
export async function cleanupTestContext(ctx: TestContext | null, cleanup?: (() => Promise<void>) | null): Promise<void> {
|
||||
export async function cleanupTestContext(
|
||||
ctx: TestContext | null,
|
||||
cleanup?: (() => Promise<void>) | null,
|
||||
): Promise<void> {
|
||||
if (ctx?.browserContext) {
|
||||
await ctx.browserContext.close()
|
||||
}
|
||||
@@ -188,7 +196,7 @@ export async function createSseServer(): Promise<SseServer> {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
res.write('retry: 1000\n\n')
|
||||
res.write('data: hello\n\n')
|
||||
@@ -265,129 +273,134 @@ export async function createSseServer(): Promise<SseServer> {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTimeout<T>({ promise, timeoutMs, errorMessage }: { promise: Promise<T>; timeoutMs: number; errorMessage: string }): Promise<T> {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(errorMessage))
|
||||
}, timeoutMs)
|
||||
export async function withTimeout<T>({
|
||||
promise,
|
||||
timeoutMs,
|
||||
errorMessage,
|
||||
}: {
|
||||
promise: Promise<T>
|
||||
timeoutMs: number
|
||||
errorMessage: string
|
||||
}): Promise<T> {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(errorMessage))
|
||||
}, timeoutMs)
|
||||
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(value)
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(value)
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Tagged template for inline JS code strings used in MCP execute calls */
|
||||
export function js(strings: TemplateStringsArray, ...values: unknown[]): string {
|
||||
return strings.reduce(
|
||||
(result, str, i) => result + str + (values[i] || ''),
|
||||
'',
|
||||
)
|
||||
return strings.reduce((result, str, i) => result + str + (values[i] || ''), '')
|
||||
}
|
||||
|
||||
export function tryJsonParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely close a browser connected via connectOverCDP.
|
||||
*
|
||||
*
|
||||
* Playwright's CRConnection uses async message handling (messageWrap) that can cause
|
||||
* a race condition where _onClose() runs before all pending _onMessage() handlers complete.
|
||||
* This results in "Assertion error" from crConnection.js when a CDP response arrives
|
||||
* after callbacks were cleared by dispose().
|
||||
*
|
||||
*
|
||||
* This helper waits for the message queue to drain before closing, avoiding the race.
|
||||
*
|
||||
*
|
||||
* @param browser - Browser instance from chromium.connectOverCDP()
|
||||
* @param drainDelayMs - Time to wait for pending messages to be processed (default: 50ms)
|
||||
*/
|
||||
export async function safeCloseCDPBrowser(
|
||||
browser: Awaited<ReturnType<typeof import('@xmorse/playwright-core').chromium.connectOverCDP>>,
|
||||
drainDelayMs = 50
|
||||
browser: Awaited<ReturnType<typeof import('@xmorse/playwright-core').chromium.connectOverCDP>>,
|
||||
drainDelayMs = 50,
|
||||
): Promise<void> {
|
||||
// Wait for any queued message handlers to run
|
||||
// This gives Playwright's messageWrap time to process pending CDP responses
|
||||
await new Promise(r => setTimeout(r, drainDelayMs))
|
||||
await browser.close()
|
||||
// Wait for any queued message handlers to run
|
||||
// This gives Playwright's messageWrap time to process pending CDP responses
|
||||
await new Promise((r) => setTimeout(r, drainDelayMs))
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
export type SimpleServer = {
|
||||
baseUrl: string
|
||||
close: () => Promise<void>
|
||||
baseUrl: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Minimal local HTTP server for tests that need cross-origin iframes or custom routes */
|
||||
export async function createSimpleServer({ routes }: { routes: Record<string, string> }): Promise<SimpleServer> {
|
||||
const openSockets: Set<net.Socket> = new Set()
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = req.url || '/'
|
||||
const body = routes[url]
|
||||
if (!body) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||
res.end('not found')
|
||||
return
|
||||
const openSockets: Set<net.Socket> = new Set()
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = req.url || '/'
|
||||
const body = routes[url]
|
||||
if (!body) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
res.end(body)
|
||||
})
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
openSockets.add(socket)
|
||||
socket.on('close', () => {
|
||||
openSockets.delete(socket)
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
res.end(body)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
throw new Error('Failed to start test server')
|
||||
}
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
openSockets.add(socket)
|
||||
socket.on('close', () => {
|
||||
openSockets.delete(socket)
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => {
|
||||
for (const socket of openSockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
throw new Error('Failed to start test server')
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => {
|
||||
for (const socket of openSockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ export function getCdpUrl({
|
||||
// Use ~/.playwriter for logs so each OS user gets their own dir (avoids permission errors on shared machines, see #44)
|
||||
const LOG_BASE_DIR = path.join(os.homedir(), '.playwriter')
|
||||
export const LOG_FILE_PATH = process.env.PLAYWRITER_LOG_FILE_PATH || path.join(LOG_BASE_DIR, 'relay-server.log')
|
||||
export const LOG_CDP_FILE_PATH = process.env.PLAYWRITER_CDP_LOG_FILE_PATH || path.join(path.dirname(LOG_FILE_PATH), 'cdp.jsonl')
|
||||
export const LOG_CDP_FILE_PATH =
|
||||
process.env.PLAYWRITER_CDP_LOG_FILE_PATH || path.join(path.dirname(LOG_FILE_PATH), 'cdp.jsonl')
|
||||
|
||||
const packageJsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
||||
export const VERSION = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')).version as string
|
||||
|
||||
@@ -66,7 +66,12 @@ export async function waitForPageLoad(options: WaitForPageLoadOptions): Promise<
|
||||
|
||||
const checkPageReady = async (): Promise<{ ready: boolean; readyState: string; pendingRequests: string[] }> => {
|
||||
const result = await page.evaluate(
|
||||
({ filteredDomains, filteredExtensions, stuckThreshold, slowResourceThreshold }): {
|
||||
({
|
||||
filteredDomains,
|
||||
filteredExtensions,
|
||||
stuckThreshold,
|
||||
slowResourceThreshold,
|
||||
}): {
|
||||
ready: boolean
|
||||
readyState: string
|
||||
pendingRequests: string[]
|
||||
|
||||
+236
-235
@@ -1,4 +1,3 @@
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { startPlayWriterCDPRelayServer } from '../src/cdp-relay.js'
|
||||
import { WebSocket } from 'ws'
|
||||
@@ -8,271 +7,273 @@ import { killPortProcess } from '../src/kill-port.js'
|
||||
const TEST_PORT = 19999
|
||||
|
||||
async function killProcessOnPort(port: number): Promise<void> {
|
||||
try {
|
||||
await killPortProcess({ port })
|
||||
} catch (err) {
|
||||
// Ignore if no process is running
|
||||
}
|
||||
try {
|
||||
await killPortProcess({ port })
|
||||
} catch (err) {
|
||||
// Ignore if no process is running
|
||||
}
|
||||
}
|
||||
|
||||
describe('Security Tests', () => {
|
||||
let server: any = null
|
||||
let server: any = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
server.close()
|
||||
server = null
|
||||
}
|
||||
await killProcessOnPort(TEST_PORT)
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
server.close()
|
||||
server = null
|
||||
}
|
||||
await killProcessOnPort(TEST_PORT)
|
||||
})
|
||||
|
||||
it('should enforce token authentication for /cdp endpoint', async () => {
|
||||
const token = 'secret-token'
|
||||
const logger = createFileLogger()
|
||||
|
||||
server = await startPlayWriterCDPRelayServer({
|
||||
port: TEST_PORT,
|
||||
token,
|
||||
logger,
|
||||
})
|
||||
|
||||
it('should enforce token authentication for /cdp endpoint', async () => {
|
||||
const token = 'secret-token'
|
||||
const logger = createFileLogger()
|
||||
|
||||
server = await startPlayWriterCDPRelayServer({
|
||||
port: TEST_PORT,
|
||||
token,
|
||||
logger
|
||||
// Helper to try connecting
|
||||
const tryConnect = (tokenParam?: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const url = `ws://127.0.0.1:${TEST_PORT}/cdp${tokenParam ? `?token=${tokenParam}` : ''}`
|
||||
const ws = new WebSocket(url)
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
})
|
||||
|
||||
// Helper to try connecting
|
||||
const tryConnect = (tokenParam?: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const url = `ws://127.0.0.1:${TEST_PORT}/cdp${tokenParam ? `?token=${tokenParam}` : ''}`
|
||||
const ws = new WebSocket(url)
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
ws.on('unexpected-response', (req, res) => {
|
||||
reject(new Error(`Unexpected response: ${res.statusCode}`))
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 1. No token -> Should fail
|
||||
await expect(tryConnect()).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
|
||||
// 2. Wrong token -> Should fail
|
||||
await expect(tryConnect('wrong-token')).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
|
||||
// 3. Correct token -> Should succeed
|
||||
await expect(tryConnect(token)).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should enforce localhost restrictions for /extension endpoint', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({
|
||||
port: TEST_PORT,
|
||||
logger
|
||||
ws.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
const tryConnectExtension = (origin?: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const url = `ws://127.0.0.1:${TEST_PORT}/extension`
|
||||
const options = origin ? { headers: { Origin: origin } } : {}
|
||||
const ws = new WebSocket(url, options)
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
ws.on('unexpected-response', (req, res) => {
|
||||
reject(new Error(`Unexpected response: ${res.statusCode}`))
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Valid chrome-extension origin -> Should succeed
|
||||
// Use a valid extension ID from ALLOWED_EXTENSION_IDS in cdp-relay.ts
|
||||
await expect(tryConnectExtension('chrome-extension://jfeammnjpkecdekppnclgkkffahnhfhe')).resolves.not.toThrow()
|
||||
|
||||
// 2. Invalid origin (e.g., http://evil.com) -> Should fail
|
||||
await expect(tryConnectExtension('http://evil.com')).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
|
||||
// 3. No origin -> Should likely fail if strict checking is enabled, but typically extension connection requires specific origin handling.
|
||||
// Based on implementation, usually it checks if it starts with chrome-extension://
|
||||
await expect(tryConnectExtension()).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Privileged HTTP route hardening (/cli/*, /recording/*)
|
||||
//
|
||||
// These tests verify that cross-origin browser requests are blocked even
|
||||
// without CORS preflight (the "simple request" attack vector where POST +
|
||||
// Content-Type: text/plain bypasses CORS entirely).
|
||||
// =========================================================================
|
||||
|
||||
const httpRequest = ({ path, method = 'POST', headers = {} }: {
|
||||
path: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
}) => {
|
||||
return fetch(`http://127.0.0.1:${TEST_PORT}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: method === 'POST' ? JSON.stringify({ sessionId: '1', code: 'true' }) : undefined,
|
||||
ws.on('unexpected-response', (req, res) => {
|
||||
reject(new Error(`Unexpected response: ${res.statusCode}`))
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('should block cross-origin browser requests to /cli/* via Sec-Fetch-Site', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
// 1. No token -> Should fail
|
||||
await expect(tryConnect()).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
|
||||
// cross-site browser request → 403
|
||||
const crossSite = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' },
|
||||
})
|
||||
expect(crossSite.status).toBe(403)
|
||||
// 2. Wrong token -> Should fail
|
||||
await expect(tryConnect('wrong-token')).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
|
||||
// same-site but not same-origin → 403
|
||||
const sameSite = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' },
|
||||
})
|
||||
expect(sameSite.status).toBe(403)
|
||||
// 3. Correct token -> Should succeed
|
||||
await expect(tryConnect(token)).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should enforce localhost restrictions for /extension endpoint', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({
|
||||
port: TEST_PORT,
|
||||
logger,
|
||||
})
|
||||
|
||||
it('should block cross-origin browser requests to /recording/* via Sec-Fetch-Site', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
const tryConnectExtension = (origin?: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const url = `ws://127.0.0.1:${TEST_PORT}/extension`
|
||||
const options = origin ? { headers: { Origin: origin } } : {}
|
||||
const ws = new WebSocket(url, options)
|
||||
|
||||
const res = await httpRequest({
|
||||
path: '/recording/status',
|
||||
method: 'GET',
|
||||
headers: { 'Sec-Fetch-Site': 'cross-site' },
|
||||
ws.on('open', () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
ws.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
ws.on('unexpected-response', (req, res) => {
|
||||
reject(new Error(`Unexpected response: ${res.statusCode}`))
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Valid chrome-extension origin -> Should succeed
|
||||
// Use a valid extension ID from ALLOWED_EXTENSION_IDS in cdp-relay.ts
|
||||
await expect(tryConnectExtension('chrome-extension://jfeammnjpkecdekppnclgkkffahnhfhe')).resolves.not.toThrow()
|
||||
|
||||
// 2. Invalid origin (e.g., http://evil.com) -> Should fail
|
||||
await expect(tryConnectExtension('http://evil.com')).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
|
||||
// 3. No origin -> Should likely fail if strict checking is enabled, but typically extension connection requires specific origin handling.
|
||||
// Based on implementation, usually it checks if it starts with chrome-extension://
|
||||
await expect(tryConnectExtension()).rejects.toThrow(/Unexpected response: (400|401|403)/)
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Privileged HTTP route hardening (/cli/*, /recording/*)
|
||||
//
|
||||
// These tests verify that cross-origin browser requests are blocked even
|
||||
// without CORS preflight (the "simple request" attack vector where POST +
|
||||
// Content-Type: text/plain bypasses CORS entirely).
|
||||
// =========================================================================
|
||||
|
||||
const httpRequest = ({
|
||||
path,
|
||||
method = 'POST',
|
||||
headers = {},
|
||||
}: {
|
||||
path: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
}) => {
|
||||
return fetch(`http://127.0.0.1:${TEST_PORT}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: method === 'POST' ? JSON.stringify({ sessionId: '1', code: 'true' }) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
it('should block POST with non-JSON Content-Type (text/plain bypass)', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
it('should block cross-origin browser requests to /cli/* via Sec-Fetch-Site', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
// text/plain is the classic CORS preflight bypass
|
||||
const textPlain = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
expect(textPlain.status).toBe(415)
|
||||
|
||||
// form-urlencoded is another simple request type
|
||||
const formData = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(formData.status).toBe(415)
|
||||
|
||||
// missing Content-Type entirely
|
||||
const noContentType = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: {},
|
||||
})
|
||||
expect(noContentType.status).toBe(415)
|
||||
// cross-site browser request → 403
|
||||
const crossSite = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' },
|
||||
})
|
||||
expect(crossSite.status).toBe(403)
|
||||
|
||||
it('should allow requests without Sec-Fetch-Site (Node.js/CLI clients)', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
// Node.js clients don't send Sec-Fetch-Site, only Content-Type: application/json.
|
||||
// Request should pass the middleware (will 404 because no session exists, which is fine).
|
||||
const res = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
// 404 = passed middleware, session just doesn't exist
|
||||
expect(res.status).toBe(404)
|
||||
// same-site but not same-origin → 403
|
||||
const sameSite = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' },
|
||||
})
|
||||
expect(sameSite.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should allow same-origin browser requests', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
it('should block cross-origin browser requests to /recording/* via Sec-Fetch-Site', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
const res = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
})
|
||||
// 404 = passed middleware
|
||||
expect(res.status).toBe(404)
|
||||
const res = await httpRequest({
|
||||
path: '/recording/status',
|
||||
method: 'GET',
|
||||
headers: { 'Sec-Fetch-Site': 'cross-site' },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should enforce token on /cli/* and /recording/* when token mode is enabled', async () => {
|
||||
const secretToken = 'test-secret-token'
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, token: secretToken, logger })
|
||||
it('should block POST with non-JSON Content-Type (text/plain bypass)', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
// No token → 401
|
||||
const noToken = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
})
|
||||
expect(noToken.status).toBe(401)
|
||||
|
||||
// Wrong token → 401
|
||||
const wrongToken = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer wrong-token' },
|
||||
})
|
||||
expect(wrongToken.status).toBe(401)
|
||||
|
||||
// Correct token via Authorization header → pass middleware
|
||||
const bearerOk = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${secretToken}` },
|
||||
})
|
||||
expect(bearerOk.status).toBe(200)
|
||||
|
||||
// Correct token via query param → pass middleware
|
||||
const queryOk = await fetch(
|
||||
`http://127.0.0.1:${TEST_PORT}/cli/sessions?token=${secretToken}`,
|
||||
)
|
||||
expect(queryOk.status).toBe(200)
|
||||
|
||||
// Token also enforced on /recording/*
|
||||
const recordingNoToken = await httpRequest({
|
||||
path: '/recording/status',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
})
|
||||
expect(recordingNoToken.status).toBe(401)
|
||||
|
||||
const recordingWithToken = await httpRequest({
|
||||
path: '/recording/status',
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${secretToken}` },
|
||||
})
|
||||
expect(recordingWithToken.status).toBe(200)
|
||||
// text/plain is the classic CORS preflight bypass
|
||||
const textPlain = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
expect(textPlain.status).toBe(415)
|
||||
|
||||
it('should not require token on /cli/* when no token is configured', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
// Without token mode, /cli/sessions should work with just proper headers
|
||||
const res = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// form-urlencoded is another simple request type
|
||||
const formData = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(formData.status).toBe(415)
|
||||
|
||||
// missing Content-Type entirely
|
||||
const noContentType = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: {},
|
||||
})
|
||||
expect(noContentType.status).toBe(415)
|
||||
})
|
||||
|
||||
it('should allow requests without Sec-Fetch-Site (Node.js/CLI clients)', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
// Node.js clients don't send Sec-Fetch-Site, only Content-Type: application/json.
|
||||
// Request should pass the middleware (will 404 because no session exists, which is fine).
|
||||
const res = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
// 404 = passed middleware, session just doesn't exist
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should allow same-origin browser requests', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
const res = await httpRequest({
|
||||
path: '/cli/execute',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
})
|
||||
// 404 = passed middleware
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should enforce token on /cli/* and /recording/* when token mode is enabled', async () => {
|
||||
const secretToken = 'test-secret-token'
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, token: secretToken, logger })
|
||||
|
||||
// No token → 401
|
||||
const noToken = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
})
|
||||
expect(noToken.status).toBe(401)
|
||||
|
||||
// Wrong token → 401
|
||||
const wrongToken = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
})
|
||||
expect(wrongToken.status).toBe(401)
|
||||
|
||||
// Correct token via Authorization header → pass middleware
|
||||
const bearerOk = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${secretToken}` },
|
||||
})
|
||||
expect(bearerOk.status).toBe(200)
|
||||
|
||||
// Correct token via query param → pass middleware
|
||||
const queryOk = await fetch(`http://127.0.0.1:${TEST_PORT}/cli/sessions?token=${secretToken}`)
|
||||
expect(queryOk.status).toBe(200)
|
||||
|
||||
// Token also enforced on /recording/*
|
||||
const recordingNoToken = await httpRequest({
|
||||
path: '/recording/status',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
})
|
||||
expect(recordingNoToken.status).toBe(401)
|
||||
|
||||
const recordingWithToken = await httpRequest({
|
||||
path: '/recording/status',
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${secretToken}` },
|
||||
})
|
||||
expect(recordingWithToken.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should not require token on /cli/* when no token is configured', async () => {
|
||||
const logger = createFileLogger()
|
||||
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
||||
|
||||
// Without token mode, /cli/sessions should work with just proper headers
|
||||
const res = await httpRequest({
|
||||
path: '/cli/sessions',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Vitest setup - handles Playwright CDP disconnect race condition.
|
||||
*
|
||||
*
|
||||
* ROOT CAUSE (from Playwright source crConnection.ts:164):
|
||||
*
|
||||
*
|
||||
* _onMessage(object: ProtocolResponse) {
|
||||
* if (object.id && this._callbacks.has(object.id)) {
|
||||
* // Handle response with matching callback
|
||||
@@ -12,7 +12,7 @@
|
||||
* assert(!object.id); // ← FAILS: expects event, got orphaned response
|
||||
* }
|
||||
* }
|
||||
*
|
||||
*
|
||||
* WHY IT HAPPENS:
|
||||
* 1. Relay sends CDP response to Playwright
|
||||
* 2. Playwright's messageWrap() schedules _onMessage for next task
|
||||
@@ -20,11 +20,11 @@
|
||||
* 4. _onClose() fires IMMEDIATELY and clears callbacks via dispose()
|
||||
* 5. Scheduled _onMessage finally runs
|
||||
* 6. Looks for callback → NOT FOUND → assertion fails
|
||||
*
|
||||
*
|
||||
* This is a race condition in Playwright's async message handling that we cannot
|
||||
* fix without patching Playwright. The assertion error during disconnect is benign
|
||||
* and expected - it just means a CDP response arrived after we stopped caring.
|
||||
*
|
||||
*
|
||||
* See: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/chromium/crConnection.ts
|
||||
*/
|
||||
|
||||
@@ -37,7 +37,7 @@ process.on('unhandledRejection', (reason: any) => {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Re-throw other unhandled rejections to fail the test
|
||||
throw reason
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user