This commit is contained in:
Tommy D. Rossi
2026-02-22 15:21:38 +01:00
parent e475c2f582
commit f87b0243cd
101 changed files with 14997 additions and 11339 deletions
+4
View File
@@ -0,0 +1,4 @@
playwright
pnpm-lock.yaml
claude-extension
opensrc
+1
View File
@@ -5,6 +5,7 @@
"jsxSingleQuote": true,
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"trailingComma": "all"
}
+32 -28
View File
@@ -99,7 +99,7 @@ tests use these utilities from `test-utils.ts`:
const testCtx = await setupTestContext({
port: 19987,
tempDirPrefix: 'pw-test-',
toggleExtension: true // creates initial page with extension enabled
toggleExtension: true, // creates initial page with extension enabled
})
// get extension service worker to call extension functions
@@ -122,7 +122,7 @@ import { createMCPClient } from './mcp-client.js'
const { client, cleanup } = await createMCPClient({ port: 19987 })
const result = await client.callTool({
name: 'execute',
arguments: { code: 'await page.goto("https://example.com")' }
arguments: { code: 'await page.goto("https://example.com")' },
})
```
@@ -171,10 +171,8 @@ when you do an any change, update relevant CHANGELOG.md files for each package.
also bump package.json versions and IMPORTANTLY also the extension/manifest.json version!
you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version`
## debugging playwriter mcp issues
sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it
@@ -220,6 +218,7 @@ pnpm bootstrap
```
this does:
1. `git submodule update --init` - init the playwright submodule
2. `pnpm install` - install deps and link workspace packages
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/`
@@ -240,16 +239,18 @@ upstream playwright bundles all dependencies into single files (zero runtime dep
**1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps
**2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
```ts
// before (bundled)
export const ws = require('./utilsBundleImpl').ws;
export const ws = require('./utilsBundleImpl').ws
// after (direct)
import wsLibrary from 'ws';
export const ws = wsLibrary;
import wsLibrary from 'ws'
export const ws = wsLibrary
```
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
```bash
# transpile src/**/*.ts → lib/**/*.js (0.1s)
# copy third_party/lockfile.js, third_party/extract-zip.js
@@ -258,7 +259,7 @@ export const ws = wsLibrary;
**4. generated files** - `playwright/packages/playwright-core/src/generated/*.ts` are browser scripts created by `playwright/utils/generate_injected.js`. these only need regenerating if upstream changes injected scripts.
| | upstream | ours |
|---|---|---|
| ------------ | ----------- | -------------- |
| build time | ~30s | 0.1s |
| dependencies | 0 (bundled) | ~20 (external) |
| trace-viewer | built | skipped |
@@ -364,35 +365,36 @@ you can open files when i ask me "open in zed the line where ..." using the comm
- if you encounter typescript lint errors for an npm package, read the node_modules/package/\*.d.ts files to understand the typescript types of the package. if you cannot understand them, ask me to help you with it.
- NEVER silently suppress errors in catch {} blocks if they contain more than one function call
```ts
// BAD. DO NOT DO THIS
let favicon: string | undefined;
let favicon: string | undefined
if (docsConfig?.favicon) {
if (typeof docsConfig.favicon === "string") {
favicon = docsConfig.favicon;
if (typeof docsConfig.favicon === 'string') {
favicon = docsConfig.favicon
} else if (docsConfig.favicon?.light) {
// Use light favicon as default, could be enhanced with theme detection
favicon = docsConfig.favicon.light;
favicon = docsConfig.favicon.light
}
}
// DO THIS. use an iife. Immediately Invoked Function Expression
const favicon: string = (() => {
if (!docsConfig?.favicon) {
return "";
return ''
}
if (typeof docsConfig.favicon === "string") {
return docsConfig.favicon;
if (typeof docsConfig.favicon === 'string') {
return docsConfig.favicon
}
if (docsConfig.favicon?.light) {
// Use light favicon as default, could be enhanced with theme detection
return docsConfig.favicon.light;
return docsConfig.favicon.light
}
return "";
})();
return ''
})()
// if you already know the type use it:
const favicon: string = () => {
// ...
};
}
```
- when a package has to import files from another packages in the workspace never add a new tsconfig path, instead add that package as a workspace dependency using `pnpm i "package@workspace:*"`
@@ -409,12 +411,12 @@ always specify the type when creating arrays, especially for empty arrays. if yo
```ts
// BAD: Type will be never[]
const items = [];
const items = []
// GOOD: Specify the expected type
const items: string[] = [];
const numbers: number[] = [];
const users: User[] = [];
const items: string[] = []
const numbers: number[] = []
const users: User[] = []
```
remember to always add the explicit type to avoid unexpected type inference.
@@ -589,11 +591,12 @@ sometimes tests work directly on database data, using prisma. to run these tests
never write tests yourself that call prisma or interact with database or emails. for these, ask the user to write them for you.
changelogs.md
# writing docs
when generating a .md or .mdx file to document things, always add a frontmatter with title and description. also add a prompt field with the exact prompt used to generate the doc. use @ to reference files and urls and provide any context necessary to be able to recreate this file from scratch using a model. if you used urls also reference them. reference all files you had to read to create the doc. use yaml | syntax to add this prompt and never go over the column width of 80
# github
# github
you can use the `gh` cli to do operations on github for the current repository. For example: open issues, open PRs, check actions status, read workflow logs, etc.
@@ -677,6 +680,7 @@ This will download the source code in ./opensrc. which should be put in .gitigno
you can control the browser using the playwright mcp tools. these tools let you control the browser to get information or accomplish actions
if i ask you to test something in the browser, know that the website dev server is already running at http://localhost:7664 for website and :7777 for docs-website (but docs-website needs to use the website domain specifically, for example name-hash.localhost:7777)
# zod
when you need to create a complex type that comes from a prisma table, do not create a new schema that tries to recreate the prisma table structure. instead just use `z.any() as ZodType<PrismaTable>)` to get type safety but leave any in the schema. this gets most of the benefits of zod without having to define a new zod schema that can easily go out of sync.
@@ -686,17 +690,17 @@ when you need to create a complex type that comes from a prisma table, do not cr
you MUST use the built in zod v4 toJSONSchema and not the npm package `zod-to-json-schema` which is outdated and does not support zod v4.
```ts
import { toJSONSchema } from "zod";
import { toJSONSchema } from 'zod'
const mySchema = z.object({
id: z.string().uuid(),
name: z.string().min(3).max(100),
age: z.number().min(0).optional(),
});
})
const jsonSchema = toJSONSchema(mySchema, {
removeAdditionalStrategy: "strict",
});
removeAdditionalStrategy: 'strict',
})
```
github.md
+1
View File
@@ -28,6 +28,7 @@ npx -y @playwriter/install-mcp playwriter@latest
3. Use the `execute` tool to run Playwright code
The MCP exposes:
- `execute` tool - run Playwright code snippets
- `reset` tool - reconnect if connection issues occur
+9 -8
View File
@@ -97,7 +97,7 @@ tests use these utilities from `test-utils.ts`:
const testCtx = await setupTestContext({
port: 19987,
tempDirPrefix: 'pw-test-',
toggleExtension: true // creates initial page with extension enabled
toggleExtension: true, // creates initial page with extension enabled
})
// get extension service worker to call extension functions
@@ -120,7 +120,7 @@ import { createMCPClient } from './mcp-client.js'
const { client, cleanup } = await createMCPClient({ port: 19987 })
const result = await client.callTool({
name: 'execute',
arguments: { code: 'await page.goto("https://example.com")' }
arguments: { code: 'await page.goto("https://example.com")' },
})
```
@@ -169,10 +169,8 @@ when you do an any change, update relevant CHANGELOG.md files for each package.
also bump package.json versions and IMPORTANTLY also the extension/manifest.json version!
you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version`
## debugging playwriter mcp issues
sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it
@@ -218,6 +216,7 @@ pnpm bootstrap
```
this does:
1. `git submodule update --init` - init the playwright submodule
2. `pnpm install` - install deps and link workspace packages
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/`
@@ -238,16 +237,18 @@ upstream playwright bundles all dependencies into single files (zero runtime dep
**1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps
**2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
```ts
// before (bundled)
export const ws = require('./utilsBundleImpl').ws;
export const ws = require('./utilsBundleImpl').ws
// after (direct)
import wsLibrary from 'ws';
export const ws = wsLibrary;
import wsLibrary from 'ws'
export const ws = wsLibrary
```
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
```bash
# transpile src/**/*.ts → lib/**/*.js (0.1s)
# copy third_party/lockfile.js, third_party/extract-zip.js
@@ -256,7 +257,7 @@ export const ws = wsLibrary;
**4. generated files** - `playwright/packages/playwright-core/src/generated/*.ts` are browser scripts created by `playwright/utils/generate_injected.js`. these only need regenerating if upstream changes injected scripts.
| | upstream | ours |
|---|---|---|
| ------------ | ----------- | -------------- |
| build time | ~30s | 0.1s |
| dependencies | 0 (bundled) | ~20 (external) |
| trace-viewer | built | skipped |
+12 -4
View File
@@ -14,7 +14,7 @@
Other browser MCPs spawn a fresh Chrome — no logins, no extensions, instantly flagged by bot detectors, double the memory. Playwriter connects to **your running browser** instead. One Chrome extension, full Playwright API, everything you're already logged into.
| | Playwright MCP | Playwriter |
|---|---|---|
| ------------- | ----------------- | --------------------------------- |
| Browser | Spawns new Chrome | **Uses your Chrome** |
| Extensions | None | Your existing ones |
| Login state | Fresh | Already logged in |
@@ -28,6 +28,7 @@ Other browser MCPs spawn a fresh Chrome — no logins, no extensions, instantly
2. Click extension icon on a tab → turns green when connected
3. Install the CLI and start automating the browser:
```bash
npm i -g playwriter
playwriter -s 1 -e "await page.goto('https://example.com')"
@@ -83,12 +84,14 @@ console.log({ title, url: page.url() });
Variables in scope: `page`, `context`, `state` (persists between calls), `require`, and Node.js globals.
**Persist data in state:**
```bash
playwriter -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))"
playwriter -e "console.log(state.users)"
```
**Intercept network requests:**
```bash
playwriter -e "state.requests = []; page.on('response', r => { if (r.url().includes('/api/')) state.requests.push(r.url()) })"
playwriter -e "await Promise.all([page.waitForResponse(r => r.url().includes('/api/')), page.click('button')])"
@@ -96,6 +99,7 @@ playwriter -e "console.log(state.requests)"
```
**Set breakpoints and debug:**
```bash
playwriter -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()"
playwriter -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))"
@@ -103,12 +107,14 @@ playwriter -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line:
```
**Live edit page code:**
```bash
playwriter -e "state.cdp = await getCDPSession({ page }); state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()"
playwriter -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"
```
**Screenshot with labels:**
```bash
playwriter -e "await screenshotWithAccessibilityLabels({ page })"
```
@@ -134,7 +140,7 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=
### vs BrowserMCP
| | BrowserMCP | Playwriter |
|---|---|---|
| ------------- | ------------------- | ------------------------ |
| Tools | 12+ dedicated tools | 1 `execute` tool |
| API | Limited actions | Full Playwright |
| Context usage | High (tool schemas) | Low |
@@ -143,7 +149,7 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=
### vs Antigravity (Jetski)
| | Jetski | Playwriter |
|---|---|---|
| -------- | ---------------------------- | ---------------- |
| Tools | 17+ tools | 1 tool |
| Subagent | Spawns for each browser task | Direct execution |
| Latency | High (agent overhead) | Low |
@@ -151,7 +157,7 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=
### vs Claude Browser Extension
| | Claude Extension | Playwriter |
|---|---|---|
| -------------------- | -------------------- | ----------------------- |
| Agent support | Claude only | Any MCP client |
| Windows WSL | No | Yes |
| Context method | Screenshots (100KB+) | A11y snapshots (5-20KB) |
@@ -185,11 +191,13 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=
Control Chrome on a remote machine over the internet using [traforo](https://traforo.dev) tunnels:
**On host:**
```bash
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
```
**From remote:**
```bash
export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=<secret>
+8
View File
@@ -18,6 +18,7 @@ prompt: |
- Always reuse an existing Framer tab when possible (do not open a new page each run).
Use this pattern to pick an existing page first, then navigate only if needed:
```bash
playwriter -s 1 -e "const target = 'https://framer.com/projects/unframer-source--XOxwdyyCrFEE9uKnKFPq-6gX7n?node=augiA20Il'; const framerPage = context.pages().find((p) => p.url().includes('framer.com/projects/unframer-source')) || page; if (!framerPage.url().includes('framer.com/projects/unframer-source')) { await framerPage.goto(target, { waitUntil: 'domcontentloaded' }); } console.log(framerPage.url());"
```
@@ -32,6 +33,7 @@ playwriter -s 1 -e "const target = 'https://framer.com/projects/unframer-source-
- Press Command+K to open the command palette.
- Verify the palette is open (look for the command dialog and MCP entry in the snapshot output):
```bash
playwriter -s 1 -e "console.log(await snapshot({ page, search: /dialog|Search…|MCP/ }));"
```
@@ -39,31 +41,37 @@ playwriter -s 1 -e "console.log(await snapshot({ page, search: /dialog|Search…
- Search for **MCP**, press Enter, then wait about 1 second for the plugin iframe to appear.
- Verify the plugin iframe exists (should include `plugins.framercdn.com`):
```bash
playwriter -s 1 -e "const iframes = await page.locator('iframe').all(); for (const f of iframes) { console.log(await f.getAttribute('src')); }"
```
- Wait until the MCP iframe is present (verifies the action worked):
```bash
playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); await iframe.first().waitFor({ timeout: 10000 }); console.log('iframe ready');"
```
- Grab the iframes locator by URL:
```bash
playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await iframe.count());"
```
- Run the accessibility snapshot on that iframe using `contentFrame()` (FrameLocator is auto-resolved to Frame):
```bash
playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await snapshot({ page, frame }));"
```
- Alternative: use `page.frames()` to get the Frame directly:
```bash
playwriter -s 1 -e "const frame = page.frames().find(f => f.url().includes('plugins.framercdn.com')); console.log(await snapshot({ page, frame }));"
```
- Validate the snapshot contains MCP UI text (confirms the panel is actually loaded):
```bash
playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await snapshot({ page, frame, search: /Control Framer with MCP|Login With Google/ }));"
```
+2 -4
View File
@@ -134,9 +134,7 @@ The env vars tell the MCP to skip starting a local relay and connect to the remo
```typescript
import { chromium } from 'playwright-core'
const browser = await chromium.connectOverCDP(
'wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN'
)
const browser = await chromium.connectOverCDP('wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN')
const page = browser.contexts()[0].pages()[0]
await page.goto('https://example.com')
// Don't call browser.close() - it would close the user's Chrome
@@ -177,7 +175,7 @@ done
### Environment variables
| Variable | Description |
|---|---|
| ------------------ | ---------------------------------------------------------------------------------- |
| `PLAYWRITER_HOST` | Remote relay URL (e.g. `https://x-tunnel.traforo.dev`) or IP (e.g. `192.168.1.10`) |
| `PLAYWRITER_TOKEN` | Authentication token for the relay server |
| `PLAYWRITER_PORT` | Override relay port (default: `19988`, not needed with traforo) |
+10 -1
View File
@@ -3,7 +3,16 @@
"name": "Playwriter",
"version": "0.0.71",
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen", "identity", "identity.email"],
"permissions": [
"debugger",
"tabGroups",
"contextMenus",
"tabs",
"tabCapture",
"offscreen",
"identity",
"identity.email"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js",
+5
View File
@@ -19,10 +19,12 @@ Essential for core functionality. This permission allows the extension to attach
**Note: This permission is automatically removed during production builds and is only included in test builds.**
The tabs permission is only needed during development/testing to:
- Access the URL property of tabs for test identification (finding tabs by URL pattern)
- Query all tabs with full information for test assertions
In production, the extension functions perfectly without the tabs permission because:
- Tab event listeners (onRemoved, onActivated, onUpdated) work without it
- chrome.tabs.create() and chrome.tabs.remove() work without it
- chrome.tabs.query() for active tab works without it
@@ -44,11 +46,13 @@ All extension code (JavaScript, HTML, CSS) is fully bundled within the extension
The extension establishes a WebSocket connection to `ws://localhost:19988` - a local server running on the user's own machine. This connection is used exclusively for **message passing** (sending and receiving JSON data), NOT code execution.
**What the WebSocket is used for:**
- Receiving CDP (Chrome DevTools Protocol) command messages in JSON format from local Playwright scripts
- Forwarding these command messages to attached browser tabs via the `chrome.debugger` API
- Sending CDP event messages back to the local Playwright scripts
**What it is NOT used for:**
- Downloading or executing JavaScript, WebAssembly, or any other executable code
- Connecting to external/remote servers (strictly localhost only)
- Loading remote configurations that modify extension behavior
@@ -66,6 +70,7 @@ This is functionally similar to Native Messaging but uses WebSockets for cross-p
## Screenshots Required
Need to provide at least one screenshot showing:
- Extension icon in toolbar (gray when disconnected, green when connected)
- Extension attached to a tab with Chrome's "debugging this browser" banner visible
- Welcome page or usage demonstration
+8 -4
View File
@@ -14,19 +14,23 @@ const files: [string, string][] = [
function download(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
https
.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`Failed to download ${url}: ${res.statusCode}`))
return
}
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
res.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
res.on('end', () => {
fs.writeFileSync(dest, Buffer.concat(chunks))
resolve()
})
res.on('error', reject)
}).on('error', reject)
})
.on('error', reject)
})
}
@@ -34,7 +38,7 @@ async function main() {
await Promise.all(
files.map(([src, dest]) => {
return download(BASE + src, path.join(DEST, dest))
})
}),
)
console.log(`Downloaded ${files.length} Prism.js files to ${DEST}`)
}
+71 -24
View File
@@ -32,7 +32,6 @@ type ExtensionIdentity = {
id: string
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -126,10 +125,12 @@ function flushRecordingChunkBuffer(ws: WebSocket): void {
const { tabId, data, final } = chunk
// Send metadata message first
ws.send(JSON.stringify({
ws.send(
JSON.stringify({
method: 'recordingData',
params: { tabId, final },
}))
}),
)
// Then send binary data if not final
if (data && !final) {
@@ -168,7 +169,7 @@ class ConnectionManager {
setTimeout(() => {
reject(new Error('Connection timeout (global)'))
}, GLOBAL_TIMEOUT_MS)
})
}),
])
try {
@@ -417,7 +418,9 @@ class ConnectionManager {
const mem = performance.memory
if (mem) {
const formatMB = (b: number) => (b / 1024 / 1024).toFixed(2) + 'MB'
logger.warn(`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`)
logger.warn(
`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`,
)
}
} catch {}
logger.warn(`DISCONNECT: WS closed code=${code} reason=${reason || 'none'} stack=${getCallStack()}`)
@@ -484,12 +487,21 @@ class ConnectionManager {
// Slot is free when: no extension connected, OR connected but no active tabs.
if (store.getState().connectionState === 'extension-replaced') {
try {
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
const data = await response.json() as { connected: boolean; activeTargets: number }
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
})
const data = (await response.json()) as { connected: boolean; activeTargets: number }
const slotAvailable = !data.connected || data.activeTargets === 0
if (slotAvailable) {
store.setState({ connectionState: 'idle', errorText: undefined })
logger.debug('Extension slot is free (connected:', data.connected, 'activeTargets:', data.activeTargets, '), cleared error state')
logger.debug(
'Extension slot is free (connected:',
data.connected,
'activeTargets:',
data.activeTargets,
'), cleared error state',
)
} else {
logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...')
}
@@ -777,8 +789,7 @@ function getTabByTargetId(targetId: string): { tabId: number; tab: TabInfo } | u
}
function emitChildDetachesForTab(tabId: number): void {
const childEntries = Array.from(childSessions.entries())
.filter(([_, parentTab]) => parentTab.tabId === tabId)
const childEntries = Array.from(childSessions.entries()).filter(([_, parentTab]) => parentTab.tabId === tabId)
childEntries.forEach(([childSessionId, parentTab]) => {
const childDetachParams: Protocol.Target.DetachedFromTargetEvent = parentTab.targetId
@@ -843,13 +854,15 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise<any> {
.filter(([_, info]) => info.state === 'connected')
.map(([tabId]) => tabId)
await Promise.all(connectedTabIds.map(async (tabId) => {
await Promise.all(
connectedTabIds.map(async (tabId) => {
try {
await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params)
} catch (error) {
logger.debug('Failed to set auto-attach for tab:', tabId, error)
}
}))
}),
)
return {}
}
@@ -1007,7 +1020,10 @@ type AttachTabResult = {
sessionId: string
}
async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {}): Promise<AttachTabResult> {
async function attachTab(
tabId: number,
{ skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {},
): Promise<AttachTabResult> {
const debuggee = { tabId }
let debuggerAttached = false
@@ -1045,7 +1061,12 @@ async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAtt
// Log error if URL is empty - this causes Playwright to create broken pages
if (!targetInfo.url || targetInfo.url === '' || targetInfo.url === ':') {
logger.error('WARNING: Target.attachedToTarget will be sent with empty URL! tabId:', tabId, 'targetInfo:', JSON.stringify(targetInfo))
logger.error(
'WARNING: Target.attachedToTarget will be sent with empty URL! tabId:',
tabId,
'targetInfo:',
JSON.stringify(targetInfo),
)
}
const attachOrder = nextSessionId
@@ -1076,7 +1097,18 @@ async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAtt
})
}
logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId, 'url:', targetInfo.url, 'skipAttachedEvent:', skipAttachedEvent)
logger.debug(
'Tab attached successfully:',
tabId,
'sessionId:',
sessionId,
'targetId:',
targetInfo.targetId,
'url:',
targetInfo.url,
'skipAttachedEvent:',
skipAttachedEvent,
)
return { targetInfo, sessionId }
} catch (error) {
// Clean up debugger if we attached but failed later
@@ -1127,8 +1159,6 @@ function detachTab(tabId: number, shouldDetachDebugger: boolean): void {
}
}
async function connectTab(tabId: number): Promise<void> {
try {
logger.debug(`Starting connection to tab ${tabId}`)
@@ -1264,7 +1294,13 @@ function isRestrictedUrl(url: string | undefined): boolean {
return !OUR_EXTENSION_IDS.includes(extensionId)
}
const restrictedPrefixes = ['chrome://', 'devtools://', 'edge://', 'https://chrome.google.com/', 'https://chromewebstore.google.com/']
const restrictedPrefixes = [
'chrome://',
'devtools://',
'edge://',
'https://chrome.google.com/',
'https://chromewebstore.google.com/',
]
return restrictedPrefixes.some((prefix) => url.startsWith(prefix))
}
@@ -1434,7 +1470,10 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
resetDebugger()
connectionManager.maintainLoop()
chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => {
chrome.contextMenus
.remove('playwriter-pin-element')
.catch(() => {})
.finally(() => {
chrome.contextMenus.create({
id: 'playwriter-pin-element',
title: 'Copy Playwriter Element Reference',
@@ -1501,11 +1540,17 @@ function checkMemory(): void {
// Log if memory is high or growing rapidly
if (used > MEMORY_CRITICAL_THRESHOLD) {
logger.error(`MEMORY CRITICAL: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`)
logger.error(
`MEMORY CRITICAL: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`,
)
} else if (used > MEMORY_WARNING_THRESHOLD) {
logger.warn(`MEMORY WARNING: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`)
logger.warn(
`MEMORY WARNING: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`,
)
} else if (memoryDelta > MEMORY_GROWTH_THRESHOLD && timeDelta < 60000) {
logger.warn(`MEMORY SPIKE: grew ${formatMB(memoryDelta)} in ${(timeDelta / 1000).toFixed(1)}s (used=${formatMB(used)})`)
logger.warn(
`MEMORY SPIKE: grew ${formatMB(memoryDelta)} in ${(timeDelta / 1000).toFixed(1)}s (used=${formatMB(used)})`,
)
}
lastMemoryUsage = used
@@ -1528,7 +1573,8 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
void updateIcons()
if (changeInfo.groupId !== undefined) {
// Queue tab group operations to serialize with syncTabGroup and disconnectEverything
tabGroupQueue = tabGroupQueue.then(async () => {
tabGroupQueue = tabGroupQueue
.then(async () => {
// Query for playwriter group by title - no stale cached ID
const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' })
const groupId = existingGroups[0]?.id
@@ -1550,7 +1596,8 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
logger.debug('Tab manually removed from playwriter group:', tabId)
await disconnectTab(tabId)
}
}).catch((e) => {
})
.catch((e) => {
logger.debug('onTabUpdated handler error:', e)
})
}
+71 -190
View File
@@ -93,10 +93,7 @@ declare namespace chrome {
* identity: chrome.ghostPublicAPI.NEW_TEMPORARY_IDENTITY
* }, (tabId) => console.log('Opened tab:', tabId))
*/
export function openTab(
params: OpenTabParams,
callback?: (tabId: number) => void
): Promise<number>
export function openTab(params: OpenTabParams, callback?: (tabId: number) => void): Promise<number>
}
// ============================================================================
@@ -151,107 +148,73 @@ declare namespace chrome {
}
// Proxy CRUD operations
export function add(
proxy: AddProxyParams,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function add(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function import_(
proxy: AddProxyParams,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function import_(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function remove(
proxy_id: string,
callback?: (success: boolean) => void
): Promise<boolean>
export function remove(proxy_id: string, callback?: (success: boolean) => void): Promise<boolean>
export function removeAll(callback?: (success: boolean) => void): Promise<boolean>
export function getList(callback?: (proxies: GhostProxy[]) => void): Promise<GhostProxy[]>
export function get(
proxy_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function get(proxy_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function move(
proxy_id: string,
new_index: number,
callback?: (success: boolean) => void
): Promise<boolean>
export function move(proxy_id: string, new_index: number, callback?: (success: boolean) => void): Promise<boolean>
export function modify(
proxy_id: string,
params: ModifyProxyParams,
callback?: (proxy: GhostProxy) => void
callback?: (proxy: GhostProxy) => void,
): Promise<GhostProxy>
// Set proxy at different levels
export function setProjectProxy(
proxy_id: string,
keep_overrides: boolean,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function setSessionProxy(
session_id: string,
proxy_id: string,
keep_overrides: boolean,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function setIdentityProxy(
identity_id: string,
proxy_id: string,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function setTabProxy(
tab_id: number,
proxy_id: string,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
// Get proxy at different levels
export function getProjectProxy(callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function getSessionProxy(
session_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getSessionProxy(session_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function getIdentityProxy(
identity_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getIdentityProxy(identity_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function getTabProxy(
tab_id: number,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getTabProxy(tab_id: number, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
// Clear proxy at different levels
export function clearProjectProxy(
keep_overrides: boolean,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearProjectProxy(keep_overrides: boolean, callback?: (success: boolean) => void): Promise<boolean>
export function clearSessionProxy(
session_id: string,
keep_overrides: boolean,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function clearIdentityProxy(
identity_id: string,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearIdentityProxy(identity_id: string, callback?: (success: boolean) => void): Promise<boolean>
export function clearTabProxy(
tab_id: number,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearTabProxy(tab_id: number, callback?: (success: boolean) => void): Promise<boolean>
// Events
export const onAdded: chrome.events.Event<(proxy: GhostProxy) => void>
@@ -259,15 +222,9 @@ declare namespace chrome {
export const onChanged: chrome.events.Event<(proxy: GhostProxy) => void>
export const onMoved: chrome.events.Event<(proxy: GhostProxy, old_index: number) => void>
export const onProjectProxyChanged: chrome.events.Event<(proxy: GhostProxy) => void>
export const onSessionProxyChanged: chrome.events.Event<
(session_id: string, proxy: GhostProxy) => void
>
export const onTabProxyChanged: chrome.events.Event<
(tab_id: number, proxy: GhostProxy) => void
>
export const onIdentityProxyChanged: chrome.events.Event<
(identity_id: string, proxy: GhostProxy) => void
>
export const onSessionProxyChanged: chrome.events.Event<(session_id: string, proxy: GhostProxy) => void>
export const onTabProxyChanged: chrome.events.Event<(tab_id: number, proxy: GhostProxy) => void>
export const onIdentityProxyChanged: chrome.events.Event<(identity_id: string, proxy: GhostProxy) => void>
}
// ============================================================================
@@ -405,42 +362,24 @@ declare namespace chrome {
}
// Project functions
export function getProjectsList(
callback?: (projects: GhostProject[]) => void
): Promise<GhostProject[]>
export function getProjectsList(callback?: (projects: GhostProject[]) => void): Promise<GhostProject[]>
export function getProject(
project_id: string,
callback?: (project: GhostProject) => void
): Promise<GhostProject>
export function getProject(project_id: string, callback?: (project: GhostProject) => void): Promise<GhostProject>
export function getActiveProject(
callback?: (project: GhostProject) => void
): Promise<GhostProject>
export function getActiveProject(callback?: (project: GhostProject) => void): Promise<GhostProject>
export function addProject(
project: AddProjectDetails,
callback?: () => void
): Promise<void>
export function addProject(project: AddProjectDetails, callback?: () => void): Promise<void>
export function removeProject(project_id: string, callback?: () => void): Promise<void>
export function moveProject(
project_id: string,
new_index: number,
callback?: () => void
): Promise<void>
export function moveProject(project_id: string, new_index: number, callback?: () => void): Promise<void>
export function renameProject(
project_id: string,
project_name: string,
callback?: () => void
): Promise<void>
export function renameProject(project_id: string, project_name: string, callback?: () => void): Promise<void>
export function setProjectDescription(
project_id: string,
project_description: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function lockProject(project_id: string, callback?: () => void): Promise<void>
@@ -448,141 +387,115 @@ declare namespace chrome {
export function openProject(project_id: string, callback?: () => void): Promise<void>
// Archived projects
export function getArchivedProjects(
callback?: (projects: ArchivedProject[]) => void
): Promise<ArchivedProject[]>
export function getArchivedProjects(callback?: (projects: ArchivedProject[]) => void): Promise<ArchivedProject[]>
export function archiveProject(project_id: string, callback?: () => void): Promise<void>
export function restoreArchivedProject(
project_id: string,
callback?: () => void
): Promise<void>
export function restoreArchivedProject(project_id: string, callback?: () => void): Promise<void>
export function deleteArchivedProject(
project_id: string,
callback?: () => void
): Promise<void>
export function deleteArchivedProject(project_id: string, callback?: () => void): Promise<void>
// Session functions
export function getSessionsList(
project_id: string,
callback?: (sessions: GhostSession[]) => void
callback?: (sessions: GhostSession[]) => void,
): Promise<GhostSession[]>
export function getSession(
project_id: string,
session_id: string,
callback?: (session: GhostSession) => void
callback?: (session: GhostSession) => void,
): Promise<GhostSession>
export function renameSession(
project_id: string,
session_id: string,
session_name: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function changeSessionColor(
project_id: string,
session_id: string,
session_color: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function clearSessionData(
project_id: string,
session_id: string,
type: ClearSessionDataType,
callback?: () => void
callback?: () => void,
): Promise<void>
// Identity functions
export function getIdentitiesList(
callback?: (identities: GhostIdentity[]) => void
): Promise<GhostIdentity[]>
export function getIdentitiesList(callback?: (identities: GhostIdentity[]) => void): Promise<GhostIdentity[]>
export function sortIdentitiesList(
condition: IdentitySortCondition,
desc: boolean,
callback?: (identities: GhostIdentity[]) => void
callback?: (identities: GhostIdentity[]) => void,
): Promise<GhostIdentity[]>
export function getIdentity(
identity_id: string,
callback?: (identity: GhostIdentity) => void
callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity>
export function addIdentity(
identity: AddIdentityDetails,
callback?: (identity: GhostIdentity) => void
callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity>
export function removeIdentity(identity_id: string, callback?: () => void): Promise<void>
export function moveIdentity(
identity_id: string,
new_index: number,
callback?: () => void
): Promise<void>
export function moveIdentity(identity_id: string, new_index: number, callback?: () => void): Promise<void>
export function renameIdentity(
identity_id: string,
identity_name: string,
callback?: () => void
): Promise<void>
export function renameIdentity(identity_id: string, identity_name: string, callback?: () => void): Promise<void>
export function changeIdentityColor(
identity_id: string,
identity_color: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityTag(
identity_id: string,
identity_tag: string,
callback?: () => void
): Promise<void>
export function setIdentityTag(identity_id: string, identity_tag: string, callback?: () => void): Promise<void>
export function setIdentityDescription(
identity_id: string,
identity_description: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityDedication(
identity_id: string,
identity_dedication: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityDedicationIsStrict(
identity_id: string,
identity_dedication_is_strict: boolean,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityUserAgent(
identity_id: string,
user_agent: string,
callback?: () => void
): Promise<void>
export function setIdentityUserAgent(identity_id: string, user_agent: string, callback?: () => void): Promise<void>
export function resetIdentity(
identity_id: string,
callback?: (identity: GhostIdentity) => void
callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity>
export function clearIdentityData(
identity_id: string,
type: ClearIdentityDataType,
callback?: () => void
callback?: () => void,
): Promise<void>
export function getIdentityTabsList(
project_id: string,
identity_id: string,
callback?: (tabs: GhostTab[]) => void
callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]>
/** Opens a new tab in a new identity */
@@ -594,110 +507,80 @@ declare namespace chrome {
// Window functions
export function getWindowsList(
project_id: string,
callback?: (windows: GhostWindow[]) => void
callback?: (windows: GhostWindow[]) => void,
): Promise<GhostWindow[]>
export function getWindowTabsList(
project_id: string,
window_id: number,
callback?: (tabs: GhostTab[]) => void
callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]>
export function addWindow(window: AddWindowDetails, callback?: () => void): Promise<void>
export function removeWindow(
project_id: string,
window_id: number,
callback?: () => void
): Promise<void>
export function removeWindow(project_id: string, window_id: number, callback?: () => void): Promise<void>
// Tab functions
export function getSessionTabsList(
project_id: string,
session_id: string,
callback?: (tabs: GhostTab[]) => void
callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]>
export function getTab(
project_id: string,
tab_id: number,
callback?: (tab: GhostTab) => void
): Promise<GhostTab>
export function getTab(project_id: string, tab_id: number, callback?: (tab: GhostTab) => void): Promise<GhostTab>
export function addTab(tab: AddTabDetails, callback?: () => void): Promise<void>
export function removeTab(
project_id: string,
tab_id: number,
callback?: () => void
): Promise<void>
export function removeTab(project_id: string, tab_id: number, callback?: () => void): Promise<void>
export function updateTab(
project_id: string,
tab_id: number,
tab_info: TabInfo,
callback?: () => void
callback?: () => void,
): Promise<void>
// Multi-extension options
export function isMultiExtensionEnabled(
callback?: (enabled: boolean) => void
): Promise<boolean>
export function isMultiExtensionEnabled(callback?: (enabled: boolean) => void): Promise<boolean>
export function getMultiExtensionOptions(
identity_id: string,
callback?: (options: GhostMultiExtensionOption[]) => void
callback?: (options: GhostMultiExtensionOption[]) => void,
): Promise<GhostMultiExtensionOption[]>
export function setMultiExtensionOption(
identity_id: string,
id: string,
value: number,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function clearMultiExtensionOptions(
identity_id: string,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
// Events
export const onProjectWillOpen: chrome.events.Event<
(project_id: string, first_time: boolean) => void
>
export const onProjectOpened: chrome.events.Event<
(project_id: string, first_time: boolean) => void
>
export const onProjectWillOpen: chrome.events.Event<(project_id: string, first_time: boolean) => void>
export const onProjectOpened: chrome.events.Event<(project_id: string, first_time: boolean) => void>
export const onProjectClosed: chrome.events.Event<(project_id: string) => void>
export const onProjectAdded: chrome.events.Event<(project: GhostProject) => void>
export const onProjectRemoved: chrome.events.Event<(project_id: string) => void>
export const onProjectNameChanged: chrome.events.Event<
(project_id: string, new_name: string) => void
>
export const onProjectDescriptionChanged: chrome.events.Event<
(project_id: string, description: string) => void
>
export const onProjectLockStateChanged: chrome.events.Event<
(project_id: string, locked: boolean) => void
>
export const onProjectNameChanged: chrome.events.Event<(project_id: string, new_name: string) => void>
export const onProjectDescriptionChanged: chrome.events.Event<(project_id: string, description: string) => void>
export const onProjectLockStateChanged: chrome.events.Event<(project_id: string, locked: boolean) => void>
export const onIdentityAdded: chrome.events.Event<(identity: GhostIdentity) => void>
export const onIdentityRemoved: chrome.events.Event<(identity_id: string) => void>
export const onIdentityNameChanged: chrome.events.Event<
(identity_id: string, identity_name: string) => void
>
export const onIdentityColorChanged: chrome.events.Event<
(identity_id: string, identity_color: string) => void
>
export const onIdentityNameChanged: chrome.events.Event<(identity_id: string, identity_name: string) => void>
export const onIdentityColorChanged: chrome.events.Event<(identity_id: string, identity_color: string) => void>
export const onIdentityUserAgentChanged: chrome.events.Event<
(identity_id: string, identity_user_agent: string) => void
>
export const onIdentitiesChanged: chrome.events.Event<() => void>
export const onSessionAdded: chrome.events.Event<(session: GhostSession) => void>
export const onSessionRemoved: chrome.events.Event<
(project_id: string, session_id: string) => void
>
export const onSessionRemoved: chrome.events.Event<(project_id: string, session_id: string) => void>
export const onSessionNameChanged: chrome.events.Event<
(project_id: string, session_id: string, new_name: string) => void
>
@@ -712,9 +595,7 @@ declare namespace chrome {
export const onTabUpdated: chrome.events.Event<(project_id: string, tab_id: number) => void>
export const onWindowAdded: chrome.events.Event<(window: GhostWindow) => void>
export const onWindowRemoved: chrome.events.Event<
(project_id: string, window_id: number) => void
>
export const onWindowRemoved: chrome.events.Event<(project_id: string, window_id: number) => void>
}
// ============================================================================
+13 -9
View File
@@ -55,21 +55,25 @@ export type OffscreenMessage =
| OffscreenCancelRecordingMessage
// Offscreen document response types
export type OffscreenStartRecordingResult = {
export type OffscreenStartRecordingResult =
| {
success: true
tabId: number
startedAt: number
mimeType: string
} | {
}
| {
success: false
error: string
}
export type OffscreenStopRecordingResult = {
export type OffscreenStopRecordingResult =
| {
success: true
tabId: number
duration: number
} | {
}
| {
success: false
error: string
}
@@ -80,10 +84,12 @@ export interface OffscreenIsRecordingResult {
startedAt?: number
}
export type OffscreenCancelRecordingResult = {
export type OffscreenCancelRecordingResult =
| {
success: true
tabId: number
} | {
}
| {
success: false
error: string
}
@@ -101,6 +107,4 @@ export interface OffscreenRecordingCancelledMessage {
tabId: number
}
export type OffscreenOutgoingMessage =
| OffscreenRecordingChunkMessage
| OffscreenRecordingCancelledMessage
export type OffscreenOutgoingMessage = OffscreenRecordingChunkMessage | OffscreenRecordingCancelledMessage
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<title>Playwriter Offscreen</title>
+16 -6
View File
@@ -61,7 +61,11 @@ interface OffscreenRecordingState {
// Map of tabId -> recording state for concurrent recording support
const recordings = new Map<number, OffscreenRecordingState>()
type OffscreenResult = OffscreenStartRecordingResult | OffscreenStopRecordingResult | OffscreenIsRecordingResult | OffscreenCancelRecordingResult
type OffscreenResult =
| OffscreenStartRecordingResult
| OffscreenStopRecordingResult
| OffscreenIsRecordingResult
| OffscreenCancelRecordingResult
chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => {
handleMessage(message).then(sendResponse)
@@ -93,12 +97,14 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
try {
// Build Chrome-specific tabCapture constraints
// These use Chrome's proprietary API that TypeScript doesn't have built-in types for
const audioConstraints: ChromeTabCaptureAudioConstraints | false = params.audio ? {
const audioConstraints: ChromeTabCaptureAudioConstraints | false = params.audio
? {
mandatory: {
chromeMediaSource: 'tab',
chromeMediaSourceId: params.streamId,
},
}
} : false
: false
const videoConstraints: ChromeTabCaptureVideoConstraints = {
mandatory: {
@@ -106,7 +112,7 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
chromeMediaSourceId: params.streamId,
minFrameRate: params.frameRate || 30,
maxFrameRate: params.frameRate || 30,
}
},
}
// Get media stream from the streamId provided by tabCapture.getMediaStreamId
@@ -206,7 +212,9 @@ async function handleStopRecording(params: OffscreenStopRecordingMessage): Promi
})
// Stop all tracks
stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() })
stream.getTracks().forEach((track: MediaStreamTrack) => {
track.stop()
})
const duration = Date.now() - startedAt
@@ -260,7 +268,9 @@ function handleCancelRecordingForTab(tabId: number): OffscreenCancelRecordingRes
if (recorder.state !== 'inactive') {
recorder.stop()
}
stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() })
stream.getTracks().forEach((track: MediaStreamTrack) => {
track.stop()
})
chrome.runtime.sendMessage({
action: 'recordingCancelled',
+11 -8
View File
@@ -92,7 +92,10 @@ function updateTabRecordingState(tabId: number, isRecording: boolean): void {
export async function handleStartRecording(params: StartRecordingParams): Promise<StartRecordingResult> {
const tabId = resolveTabIdFromSessionId(params.sessionId)
if (!tabId) {
return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' }
return {
success: false,
error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.',
}
}
if (activeRecordings.has(tabId)) {
@@ -133,7 +136,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis
logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...')
// Send message to offscreen document to start recording
const result = await chrome.runtime.sendMessage({
const result = (await chrome.runtime.sendMessage({
action: 'startRecording',
tabId,
streamId,
@@ -141,7 +144,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis
videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000,
audioBitsPerSecond: params.audioBitsPerSecond ?? 128000,
audio: params.audio ?? false,
}) as OffscreenStartRecordingResult
})) as OffscreenStartRecordingResult
if (!result.success) {
return { success: false, error: result.error || 'Failed to start recording in offscreen document' }
@@ -179,16 +182,16 @@ export async function handleStopRecording(params: StopRecordingParams): Promise<
try {
// Send message to offscreen document to stop recording - include tabId for concurrent support
const result = await chrome.runtime.sendMessage({
const result = (await chrome.runtime.sendMessage({
action: 'stopRecording',
tabId,
}) as OffscreenStopRecordingResult
})) as OffscreenStopRecordingResult
if (!result.success) {
return { success: false, error: result.error || 'Failed to stop recording in offscreen document' }
}
const duration = result.duration || (Date.now() - recording.startedAt)
const duration = result.duration || Date.now() - recording.startedAt
// Clean up
activeRecordings.delete(tabId)
@@ -216,10 +219,10 @@ export async function handleIsRecording(params: IsRecordingParams): Promise<IsRe
// Check with offscreen document for actual recording state - include tabId for concurrent support
try {
const result = await chrome.runtime.sendMessage({
const result = (await chrome.runtime.sendMessage({
action: 'isRecording',
tabId,
}) as OffscreenIsRecordingResult
})) as OffscreenIsRecordingResult
return {
isRecording: result.isRecording,
+23 -24
View File
@@ -1,29 +1,27 @@
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { defineConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { defineConfig } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Bundle the playwriter package version into the extension so it can report
// which playwriter version it was built against. CLI/MCP use this to warn
// when the extension is outdated.
const playwriterPkg = JSON.parse(
readFileSync(resolve(__dirname, '../playwriter/package.json'), 'utf-8')
);
const playwriterPkg = JSON.parse(readFileSync(resolve(__dirname, '../playwriter/package.json'), 'utf-8'))
const defineEnv: Record<string, string> = {
'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'),
'__PLAYWRITER_VERSION__': JSON.stringify(playwriterPkg.version),
};
__PLAYWRITER_VERSION__: JSON.stringify(playwriterPkg.version),
}
if (process.env.TESTING) {
defineEnv['import.meta.env.TESTING'] = 'true';
defineEnv['import.meta.env.TESTING'] = 'true'
}
// Allow tests to build per-port extension outputs to avoid parallel run conflicts.
const outDir = process.env.PLAYWRITER_EXTENSION_DIST || 'dist';
const outDir = process.env.PLAYWRITER_EXTENSION_DIST || 'dist'
export default defineConfig({
plugins: [
@@ -31,33 +29,34 @@ export default defineConfig({
targets: [
{
src: resolve(__dirname, 'icons/*'),
dest: 'icons'
dest: 'icons',
},
{
src: resolve(__dirname, 'manifest.json'),
dest: '.',
transform: (content) => {
const manifest = JSON.parse(content);
const manifest = JSON.parse(content)
// Only include tabs permission during testing
if (process.env.TESTING) {
if (!manifest.permissions.includes('tabs')) {
manifest.permissions.push('tabs');
manifest.permissions.push('tabs')
}
}
// Inject key for stable extension ID in dev/test builds (not production)
// This ensures all developers get the same extension ID: pebbngnfojnignonigcnkdilknapkgid
if (!process.env.PRODUCTION) {
manifest.key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwCJoq5UYhOo5x8s50pVBUHjQ8idyUHnZFDj1JspWJPe6kvM7RFIaE/y5WTAH05kuK0R7v/ipcGA4ywA5wKdPKHZzkl5xstlNPj0Ivu4CqLobU7eY5G3k3Gq7wql2pbwb/A8Nat4VLbfBjQLA6TGWd3LQOHS6M0B3AvrtEw7DLDUdGKh4SCLewCbdlDIzpXQwKOzrRPyLFBwj9eEeITy5aNwJ9r9JMNBvACVZiRCHsGI6DufU+OiIO232l/8OoNNt6kdTMyNgiqOogFApXPJwREUwZHGqjXD3s6bXiBIQtwkNyZfemHKkxj6g/fhCV2EMgTY6+ikQEY1gEJMrRVmcYQIDAQAB';
manifest.key =
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwCJoq5UYhOo5x8s50pVBUHjQ8idyUHnZFDj1JspWJPe6kvM7RFIaE/y5WTAH05kuK0R7v/ipcGA4ywA5wKdPKHZzkl5xstlNPj0Ivu4CqLobU7eY5G3k3Gq7wql2pbwb/A8Nat4VLbfBjQLA6TGWd3LQOHS6M0B3AvrtEw7DLDUdGKh4SCLewCbdlDIzpXQwKOzrRPyLFBwj9eEeITy5aNwJ9r9JMNBvACVZiRCHsGI6DufU+OiIO232l/8OoNNt6kdTMyNgiqOogFApXPJwREUwZHGqjXD3s6bXiBIQtwkNyZfemHKkxj6g/fhCV2EMgTY6+ikQEY1gEJMrRVmcYQIDAQAB'
}
return JSON.stringify(manifest, null, 2);
}
return JSON.stringify(manifest, null, 2)
},
]
})
},
],
}),
],
build: {
@@ -76,5 +75,5 @@ export default defineConfig({
},
},
},
define: defineEnv
});
define: defineEnv,
})
+1
View File
@@ -4,6 +4,7 @@
"scripts": {
"test": "pnpm --filter playwriter test",
"watch": "pnpm -r watch",
"format": "prettier --write .",
"cli": "pnpm --filter playwriter cli",
"build": "pnpm --filter playwriter --filter mcp-extension build",
"reload": "pnpm --filter playwriter build && pnpm --filter mcp-extension reload",
+4 -4
View File
@@ -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
```
+13 -12
View File
@@ -51,9 +51,7 @@ function writeToDestinations(filename: string, content: string) {
}
function cleanTypes(typesContent: string): string {
return typesContent
.replace(/\/\/# sourceMappingURL=.*$/gm, '')
.trim()
return typesContent.replace(/\/\/# sourceMappingURL=.*$/gm, '').trim()
}
function buildDebuggerApi() {
@@ -163,7 +161,14 @@ function stripCliSectionsFromSkill(skillContent: string): string {
}
// 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() {
@@ -245,16 +250,12 @@ function buildWellKnownSkills() {
{
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')
}
+4 -12
View File
@@ -2,9 +2,7 @@ import playwright from 'playwright-core'
async function main() {
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {
})
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {})
const contexts = browser.contexts()
console.log(`Found ${contexts.length} browser context(s)`)
@@ -16,7 +14,7 @@ async function main() {
console.log(`Context has ${pages.length} page(s):`)
for (const page of pages) {
await page.emulateMedia({colorScheme: null, })
await page.emulateMedia({ colorScheme: null })
const url = page.url()
console.log(`\nPage URL: ${url}`)
@@ -40,14 +38,8 @@ async function main() {
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),
)
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.')
}
@@ -2,9 +2,7 @@ import playwright from 'playwright-core'
async function main() {
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}`
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {
})
const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {})
const contexts = browser.contexts()
console.log(`Found ${contexts.length} browser context(s)`)
-1
View File
@@ -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
+2 -1
View File
@@ -234,7 +234,8 @@ export function hideA11yLabels(): void {
// Expose on globalThis for injection
// ============================================================================
;(globalThis as { __a11y?: { renderA11yLabels: (labels: A11yLabel[]) => number; hideA11yLabels: () => void } }).__a11y = {
;(globalThis as { __a11y?: { renderA11yLabels: (labels: A11yLabel[]) => number; hideA11yLabels: () => void } }).__a11y =
{
renderA11yLabels,
hideA11yLabels,
}
+5 -2
View File
@@ -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()
+152 -61
View File
@@ -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,7 +317,8 @@ 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'
const line =
node.role === 'text'
? buildTextLine(node.name, nodeIndent)
: buildSnapshotLine({
role: node.role,
@@ -339,13 +349,15 @@ export function buildRawSnapshotTree(options: {
const role = getAxRole(node)
const name = getAxValueString(node.name).trim()
const children = (node.childIds ?? []).map((childId) => {
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,7 +633,8 @@ export function finalizeSnapshotOutput(
}, [])
let lineLocatorIndex = 0
const snapshot = lines.map((line) => {
const snapshot = lines
.map((line) => {
let text = line.text
if (line.baseLocator) {
const locator = locatorSequence[lineLocatorIndex]
@@ -624,7 +645,8 @@ export function finalizeSnapshotOutput(
text += ':'
}
return text
}).join('\n')
})
.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,7 +831,7 @@ 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()
@@ -807,16 +844,16 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
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) => {
await scopeLocator.evaluate(
(element, data) => {
element.setAttribute(data.attr, data.value)
}, { attr: scopeAttr, value: scopeValue })
},
{ 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,15 +983,17 @@ 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) => {
const rawRoots =
rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
? rootNode.childIds
.map((childId) => {
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
}).filter(isTruthy)
})
.filter(isTruthy)
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
const filtered = rawRoots.flatMap((rawNode) => {
@@ -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) => {
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 {
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 }
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => {
labelCount: number
}
}).__a11y
}
}
).__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
return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true })
.labelCount
}
throw new Error('a11y client not loaded')
}, { entries: shortLabels, root: rootHandle, interactiveOnly })
},
{ 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
+108 -50
View File
@@ -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, {
[
rootId,
{
nodeId: rootId,
ignored: false,
role: roleValue('rootwebarea'),
childIds: [mainId, navId],
}],
[mainId, {
},
],
[
mainId,
{
nodeId: mainId,
ignored: false,
role: roleValue('main'),
childIds: [headingId, buttonId],
backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId,
}],
[navId, {
},
],
[
navId,
{
nodeId: navId,
ignored: false,
role: roleValue('navigation'),
childIds: [listId],
backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId,
}],
[listId, {
},
],
[
listId,
{
nodeId: listId,
ignored: false,
role: roleValue('list'),
childIds: [listItemId],
backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId,
}],
[listItemId, {
},
],
[
listItemId,
{
nodeId: listItemId,
ignored: false,
role: roleValue('listitem'),
childIds: [linkId],
backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId,
}],
[linkId, {
},
],
[
linkId,
{
nodeId: linkId,
ignored: false,
role: roleValue('link'),
name: nameValue('Docs'),
backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId,
}],
[headingId, {
},
],
[
headingId,
{
nodeId: headingId,
ignored: false,
role: roleValue('heading'),
name: nameValue('Title'),
backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId,
}],
[buttonId, {
},
],
[
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, {
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, {
}
>([
[
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, {
},
],
[
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, {
},
],
[
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, {
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, {
}
>([
[
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, {
},
],
[
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, {
},
],
[
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, {
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, {
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, {
}
>([
[
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, {
},
],
[
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
+4 -1
View File
@@ -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)) {
+183 -105
View File
@@ -93,7 +93,6 @@ type ExtensionConnection = {
pingInterval: ReturnType<typeof setInterval> | null
}
export type RelayServer = {
close(): void
on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
@@ -129,7 +128,7 @@ export async function startPlayWriterCDPRelayServer({
const getExtensionConnection = (
extensionId?: string | null,
options: { allowFallback?: boolean } = {}
options: { allowFallback?: boolean } = {},
): ExtensionConnection | null => {
if (extensionId) {
const direct = extensionConnections.get(extensionId)
@@ -177,7 +176,7 @@ export async function startPlayWriterCDPRelayServer({
const getPageTargetForFrameId = ({
connection,
frameId
frameId,
}: {
connection: ExtensionConnection
frameId: string
@@ -216,7 +215,7 @@ export async function startPlayWriterCDPRelayServer({
sessionId,
params,
id,
source
source,
}: {
direction: 'to-playwright' | 'from-playwright' | 'from-extension'
clientId?: string
@@ -232,7 +231,7 @@ export async function startPlayWriterCDPRelayServer({
'Network.responseReceivedExtraInfo',
'Network.dataReceived',
'Network.requestWillBeSent',
'Network.loadingFinished'
'Network.loadingFinished',
]
if (noisyEvents.includes(method)) {
@@ -287,9 +286,7 @@ export async function startPlayWriterCDPRelayServer({
source?: 'extension' | 'server'
extensionId?: string | null
}) {
const messageToSend = source === 'server' && 'method' in message
? { ...message, __serverGenerated: true }
: message
const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message
logCdpJson({
timestamp: new Date().toISOString(),
@@ -306,7 +303,7 @@ export async function startPlayWriterCDPRelayServer({
method: message.method,
sessionId: 'sessionId' in message ? message.sessionId : undefined,
params: 'params' in message ? message.params : undefined,
source
source,
})
}
@@ -409,7 +406,7 @@ export async function startPlayWriterCDPRelayServer({
reject: (error) => {
clearTimeout(timeoutId)
reject(error)
}
},
})
})
}
@@ -429,7 +426,7 @@ export async function startPlayWriterCDPRelayServer({
(params) => sendToExtension({ extensionId: connection.id, ...params }),
() => extensionConnections.has(connection.id),
logger,
)
),
)
}
return recordingRelays.get(connection.id) || null
@@ -451,7 +448,7 @@ export async function startPlayWriterCDPRelayServer({
try {
logger?.log(pc.blue('Auto-creating initial tab for Playwright client'))
const result = await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 }) as {
const result = (await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 })) as {
success: boolean
tabId: number
sessionId: string
@@ -462,9 +459,13 @@ export async function startPlayWriterCDPRelayServer({
sessionId: result.sessionId,
targetId: result.targetInfo.targetId,
targetInfo: result.targetInfo,
frameIds: new Set()
frameIds: new Set(),
})
logger?.log(pc.blue(`Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`))
logger?.log(
pc.blue(
`Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`,
),
)
}
} catch (e) {
logger?.error('Failed to auto-create initial tab:', e)
@@ -493,7 +494,7 @@ export async function startPlayWriterCDPRelayServer({
product: 'Chrome/Extension-Bridge',
revision: '1.0.0',
userAgent: 'CDP-Bridge-Server/1.0.0',
jsVersion: 'V8'
jsVersion: 'V8',
} satisfies Protocol.Browser.GetVersionResponse
}
@@ -516,7 +517,7 @@ export async function startPlayWriterCDPRelayServer({
await sendToExtension({
extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand',
params: { method, params, source }
params: { method, params, source },
})
return {}
}
@@ -568,8 +569,8 @@ export async function startPlayWriterCDPRelayServer({
.filter((t) => !isRestrictedTarget(t.targetInfo))
.map((t) => ({
...t.targetInfo,
attached: true
}))
attached: true,
})),
}
}
@@ -577,7 +578,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({
extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand',
params: { method, params, source }
params: { method, params, source },
})
}
@@ -585,7 +586,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({
extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand',
params: { method, params, source }
params: { method, params, source },
})
}
@@ -594,7 +595,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({
extensionId: extension?.id || extensionId,
method: 'ghost-browser',
params
params,
})
}
@@ -616,7 +617,11 @@ export async function startPlayWriterCDPRelayServer({
}
const timeout = setTimeout(() => {
emitter.off('cdp:event', handler)
logger?.log(pc.yellow(`IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`))
logger?.log(
pc.yellow(
`IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`,
),
)
resolve()
}, 3000)
emitter.on('cdp:event', handler)
@@ -625,7 +630,7 @@ export async function startPlayWriterCDPRelayServer({
const result = await sendToExtension({
extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand',
params: { sessionId, method, params, source }
params: { sessionId, method, params, source },
})
await contextCreatedPromise
@@ -637,7 +642,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({
extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand',
params: { sessionId, method, params, source }
params: { sessionId, method, params, source },
})
}
@@ -645,7 +650,9 @@ export async function startPlayWriterCDPRelayServer({
// CORS middleware for HTTP endpoints - only allows our specific extension IDs.
// This prevents other extensions from reading responses via fetch/XHR.
// WebSocket connections have their own separate origin validation.
app.use('*', cors({
app.use(
'*',
cors({
origin: (origin) => {
if (!origin.startsWith('chrome-extension://')) {
return null
@@ -657,7 +664,8 @@ export async function startPlayWriterCDPRelayServer({
return origin
},
allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'],
}))
}),
)
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
const getCdpWsUrl = (c: { req: { header: (name: string) => string | undefined } }) => {
@@ -709,76 +717,76 @@ export async function startPlayWriterCDPRelayServer({
app
.on(['GET', 'PUT'], '/json/version', (c) => {
return c.json({
'Browser': `Playwriter/${VERSION}`,
Browser: `Playwriter/${VERSION}`,
'Protocol-Version': '1.3',
'webSocketDebuggerUrl': getCdpWsUrl(c)
webSocketDebuggerUrl: getCdpWsUrl(c),
})
})
.on(['GET', 'PUT'], '/json/version/', (c) => {
return c.json({
'Browser': `Playwriter/${VERSION}`,
Browser: `Playwriter/${VERSION}`,
'Protocol-Version': '1.3',
'webSocketDebuggerUrl': getCdpWsUrl(c)
webSocketDebuggerUrl: getCdpWsUrl(c),
})
})
.on(['GET', 'PUT'], '/json/list', (c) => {
const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json(
Array.from(defaultTargets.values()).map(t => ({
Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
})),
)
})
.on(['GET', 'PUT'], '/json/list/', (c) => {
const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json(
Array.from(defaultTargets.values()).map(t => ({
Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
})),
)
})
.on(['GET', 'PUT'], '/json', (c) => {
const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json(
Array.from(defaultTargets.values()).map(t => ({
Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
})),
)
})
.on(['GET', 'PUT'], '/json/', (c) => {
const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json(
Array.from(defaultTargets.values()).map(t => ({
Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
})),
)
})
@@ -798,7 +806,9 @@ export async function startPlayWriterCDPRelayServer({
// Browsers always send Origin header for WebSocket connections, but Node.js clients don't.
// We only allow our specific extension IDs to prevent malicious websites or extensions
// from connecting to the local WebSocket server.
app.get('/cdp/:clientId?', (c, next) => {
app.get(
'/cdp/:clientId?',
(c, next) => {
const origin = c.req.header('origin')
// Validate Origin header if present (Node.js clients don't send it)
@@ -823,7 +833,8 @@ export async function startPlayWriterCDPRelayServer({
}
}
return next()
}, upgradeWebSocket((c) => {
},
upgradeWebSocket((c) => {
const clientId = c.req.param('clientId') || 'default'
const url = new URL(c.req.url, 'http://localhost')
const requestedExtensionId = url.searchParams.get('extensionId')
@@ -853,7 +864,11 @@ export async function startPlayWriterCDPRelayServer({
playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId })
const extensionConnection = getExtensionConnection(clientExtensionId)
const targetCount = extensionConnection?.connectedTargets.size || 0
logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`))
logger?.log(
pc.green(
`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`,
),
)
},
async onMessage(event, ws) {
@@ -879,7 +894,7 @@ export async function startPlayWriterCDPRelayServer({
clientId,
method,
sessionId,
id
id,
})
emitter.emit('cdp:command', { clientId, command: message })
@@ -890,15 +905,21 @@ export async function startPlayWriterCDPRelayServer({
message: {
id,
sessionId,
error: { message: 'Extension not connected' }
error: { message: 'Extension not connected' },
},
clientId
clientId,
})
return
}
try {
const result: any = await routeCdpCommand({ extensionId: extensionConnection.id, method, params, sessionId, source })
const result: any = await routeCdpCommand({
extensionId: extensionConnection.id,
method,
params,
sessionId,
source,
})
if (method === 'Target.setAutoAttach' && !sessionId) {
for (const target of extensionConnection.connectedTargets.values()) {
@@ -912,19 +933,25 @@ export async function startPlayWriterCDPRelayServer({
sessionId: target.sessionId,
targetInfo: {
...target.targetInfo,
attached: true
attached: true,
},
waitingForDebugger: false,
},
waitingForDebugger: false
}
} satisfies CDPEventFor<'Target.attachedToTarget'>
if (!target.targetInfo.url) {
logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload))
logger?.error(
pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'),
JSON.stringify(attachedPayload),
)
}
logger?.log(pc.magenta('[Server] Target.attachedToTarget full payload:'), JSON.stringify(attachedPayload))
logger?.log(
pc.magenta('[Server] Target.attachedToTarget full payload:'),
JSON.stringify(attachedPayload),
)
sendToPlaywright({
message: attachedPayload,
clientId,
source: 'server'
source: 'server',
})
}
}
@@ -940,25 +967,33 @@ export async function startPlayWriterCDPRelayServer({
params: {
targetInfo: {
...target.targetInfo,
attached: true
}
}
attached: true,
},
},
} satisfies CDPEventFor<'Target.targetCreated'>
if (!target.targetInfo.url) {
logger?.error(pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload))
logger?.error(
pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'),
JSON.stringify(targetCreatedPayload),
)
}
logger?.log(pc.magenta('[Server] Target.targetCreated full payload:'), JSON.stringify(targetCreatedPayload))
logger?.log(
pc.magenta('[Server] Target.targetCreated full payload:'),
JSON.stringify(targetCreatedPayload),
)
sendToPlaywright({
message: targetCreatedPayload,
clientId,
source: 'server'
source: 'server',
})
}
}
if (method === 'Target.attachToTarget' && result?.sessionId) {
const targetId = params?.targetId
const target = Array.from(extensionConnection.connectedTargets.values()).find(t => t.targetId === targetId)
const target = Array.from(extensionConnection.connectedTargets.values()).find(
(t) => t.targetId === targetId,
)
if (target) {
const attachedPayload = {
method: 'Target.attachedToTarget',
@@ -966,19 +1001,25 @@ export async function startPlayWriterCDPRelayServer({
sessionId: result.sessionId,
targetInfo: {
...target.targetInfo,
attached: true
attached: true,
},
waitingForDebugger: false,
},
waitingForDebugger: false
}
} satisfies CDPEventFor<'Target.attachedToTarget'>
if (!target.targetInfo.url) {
logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload))
logger?.error(
pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'),
JSON.stringify(attachedPayload),
)
}
logger?.log(pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload))
logger?.log(
pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'),
JSON.stringify(attachedPayload),
)
sendToPlaywright({
message: attachedPayload,
clientId,
source: 'server'
source: 'server',
})
}
}
@@ -991,7 +1032,7 @@ export async function startPlayWriterCDPRelayServer({
const errorResponse: CDPResponseBase = {
id,
sessionId,
error: { message: (e as Error).message }
error: { message: (e as Error).message },
}
sendToPlaywright({ message: errorResponse, clientId })
emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
@@ -1005,9 +1046,10 @@ export async function startPlayWriterCDPRelayServer({
onError(event) {
logger?.error(`Playwright WebSocket error [${clientId}]:`, event)
},
}
}
}))
}),
)
const getExtensionInfoFromRequest = (c: { req: { query: (name: string) => string | undefined } }): ExtensionInfo => {
const browser = c.req.query('browser')
@@ -1022,7 +1064,9 @@ export async function startPlayWriterCDPRelayServer({
}
}
app.get('/extension', (c, next) => {
app.get(
'/extension',
(c, next) => {
// 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
// This prevents attackers on the network from hijacking the browser session
// even if the server is exposed via 0.0.0.0.
@@ -1040,7 +1084,9 @@ export async function startPlayWriterCDPRelayServer({
// is coming from our specific Chrome Extension, not a malicious website.
const origin = c.req.header('origin')
if (!origin || !origin.startsWith('chrome-extension://')) {
logger?.log(pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`))
logger?.log(
pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`),
)
return c.text('Forbidden', 403)
}
@@ -1051,7 +1097,8 @@ export async function startPlayWriterCDPRelayServer({
}
return next()
}, upgradeWebSocket((c) => {
},
upgradeWebSocket((c) => {
const incomingExtensionInfo = getExtensionInfoFromRequest(c)
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
return {
@@ -1158,7 +1205,7 @@ export async function startPlayWriterCDPRelayServer({
direction: 'from-extension',
method,
sessionId,
params
params,
})
const cdpEvent: CDPEventBase = { method, sessionId, params }
@@ -1168,7 +1215,8 @@ export async function startPlayWriterCDPRelayServer({
const targetParams = params as Protocol.Target.AttachedToTargetEvent
const incomingSessionId = sessionId
const iframeParentFrameId = targetParams.targetInfo.parentFrameId
const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId
const iframeOwnerSessionId =
targetParams.targetInfo.type === 'iframe' && iframeParentFrameId
? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId
: undefined
@@ -1189,14 +1237,24 @@ export async function startPlayWriterCDPRelayServer({
logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), message)
})
}
logger?.log(pc.gray(`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`))
logger?.log(
pc.gray(
`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`,
),
)
return
}
if (!targetParams.targetInfo.url) {
logger?.error(pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'), JSON.stringify({ method, params: targetParams, sessionId }))
logger?.error(
pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'),
JSON.stringify({ method, params: targetParams, sessionId }),
)
}
logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }))
logger?.log(
pc.yellow('[Extension] Target.attachedToTarget full payload:'),
JSON.stringify({ method, params: targetParams, sessionId }),
)
// Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId)
@@ -1207,7 +1265,7 @@ export async function startPlayWriterCDPRelayServer({
sessionId: targetParams.sessionId,
targetId: targetParams.targetInfo.targetId,
targetInfo: targetParams.targetInfo,
frameIds: existingTarget?.frameIds ?? new Set()
frameIds: existingTarget?.frameIds ?? new Set(),
})
// Only forward to Playwright if this is a new target to avoid duplicates
@@ -1222,7 +1280,7 @@ export async function startPlayWriterCDPRelayServer({
// session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
sessionId: iframeOwnerSessionId ?? incomingSessionId,
method: 'Target.attachedToTarget',
params: targetParams
params: targetParams,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1235,7 +1293,7 @@ export async function startPlayWriterCDPRelayServer({
sendToPlaywright({
message: {
method: 'Target.detachedFromTarget',
params: detachParams
params: detachParams,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1253,7 +1311,7 @@ export async function startPlayWriterCDPRelayServer({
sendToPlaywright({
message: {
method: 'Target.targetCrashed',
params: crashParams
params: crashParams,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1270,7 +1328,7 @@ export async function startPlayWriterCDPRelayServer({
sendToPlaywright({
message: {
method: 'Target.targetInfoChanged',
params: infoParams
params: infoParams,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1288,7 +1346,7 @@ export async function startPlayWriterCDPRelayServer({
message: {
sessionId,
method,
params
params,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1304,7 +1362,7 @@ export async function startPlayWriterCDPRelayServer({
message: {
sessionId,
method,
params
params,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1325,7 +1383,10 @@ export async function startPlayWriterCDPRelayServer({
url: frameParams.frame.url,
title: frameParams.frame.name || target.targetInfo.title,
}
logger?.log(pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url)
logger?.log(
pc.magenta('[Server] Updated target URL from Page.frameNavigated:'),
frameParams.frame.url,
)
}
}
@@ -1333,7 +1394,7 @@ export async function startPlayWriterCDPRelayServer({
message: {
sessionId,
method,
params
params,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1347,7 +1408,10 @@ export async function startPlayWriterCDPRelayServer({
...target.targetInfo,
url: navParams.url,
}
logger?.log(pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url)
logger?.log(
pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'),
navParams.url,
)
}
}
@@ -1355,7 +1419,7 @@ export async function startPlayWriterCDPRelayServer({
message: {
sessionId,
method,
params
params,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1365,7 +1429,7 @@ export async function startPlayWriterCDPRelayServer({
message: {
sessionId,
method,
params
params,
} as CDPEventBase,
source: 'extension',
extensionId: connectionId,
@@ -1415,9 +1479,10 @@ export async function startPlayWriterCDPRelayServer({
onError(event) {
logger?.error('Extension WebSocket error:', event)
},
}
}
}))
}),
)
// ============================================================================
// CLI Execute Endpoints - For stateful code execution via CLI
@@ -1457,7 +1522,10 @@ export async function startPlayWriterCDPRelayServer({
// preflight as a fallback, which our CORS policy already blocks.
// 3. When token mode is enabled (remote access), require the token.
// ============================================================================
const privilegedRouteMiddleware = async (c: Parameters<Parameters<typeof app.use>[1]>[0], next: () => Promise<void>) => {
const privilegedRouteMiddleware = async (
c: Parameters<Parameters<typeof app.use>[1]>[0],
next: () => Promise<void>,
) => {
// Block cross-origin browser requests via Sec-Fetch-Site header.
// Browsers always set this forbidden header; it cannot be spoofed.
// Non-browser clients (Node.js, curl, MCP) don't send it.
@@ -1497,7 +1565,7 @@ export async function startPlayWriterCDPRelayServer({
app.post('/cli/execute', async (c) => {
try {
const body = await c.req.json() as { sessionId: string | number; code: string; timeout?: number }
const body = (await c.req.json()) as { sessionId: string | number; code: string; timeout?: number }
const sessionId = normalizeSessionId(body.sessionId)
const { code, timeout = 10000 } = body
@@ -1508,7 +1576,10 @@ export async function startPlayWriterCDPRelayServer({
const manager = await getExecutorManager()
const existingExecutor = manager.getSession(sessionId)
if (!existingExecutor) {
return c.json({ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], isError: true }, 404)
return c.json(
{ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], isError: true },
404,
)
}
const result = await existingExecutor.execute(code, timeout)
@@ -1521,7 +1592,7 @@ export async function startPlayWriterCDPRelayServer({
app.post('/cli/reset', async (c) => {
try {
const body = await c.req.json() as { sessionId: string | number }
const body = (await c.req.json()) as { sessionId: string | number }
const sessionId = normalizeSessionId(body.sessionId)
if (!sessionId) {
@@ -1556,7 +1627,7 @@ export async function startPlayWriterCDPRelayServer({
})
app.post('/cli/session/new', async (c) => {
const body = await c.req.json().catch(() => ({})) as { extensionId?: string | null; cwd?: string }
const body = (await c.req.json().catch(() => ({}))) as { extensionId?: string | null; cwd?: string }
const sessionId = String(nextSessionNumber++)
const extensionId = body.extensionId || null
const cwd = body.cwd
@@ -1605,7 +1676,7 @@ export async function startPlayWriterCDPRelayServer({
app.post('/cli/session/delete', async (c) => {
try {
const body = await c.req.json() as { sessionId: string | number }
const body = (await c.req.json()) as { sessionId: string | number }
const sessionId = normalizeSessionId(body.sessionId)
if (!sessionId) {
@@ -1630,7 +1701,14 @@ export async function startPlayWriterCDPRelayServer({
// ============================================================================
app.post('/recording/start', async (c) => {
const body = await c.req.json() as { outputPath?: string; sessionId?: string | number; frameRate?: number; audio?: boolean; videoBitsPerSecond?: number; audioBitsPerSecond?: number }
const body = (await c.req.json()) as {
outputPath?: string
sessionId?: string | number
frameRate?: number
audio?: boolean
videoBitsPerSecond?: number
audioBitsPerSecond?: number
}
const sessionId = normalizeSessionId(body.sessionId)
const { sessionId: _sessionId, ...recordingOptions } = body
const manager = await getExecutorManager()
@@ -1645,12 +1723,12 @@ export async function startPlayWriterCDPRelayServer({
}
const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions) as StartRecordingBody
const result = await relay.startRecording(recordingParams)
const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500)
const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500
return c.json(result, status)
})
app.post('/recording/stop', async (c) => {
const body = await c.req.json() as { sessionId?: string | number }
const body = (await c.req.json()) as { sessionId?: string | number }
const sessionId = normalizeSessionId(body.sessionId)
const manager = await getExecutorManager()
const executor = sessionId ? manager.getSession(sessionId) : null
@@ -1664,7 +1742,7 @@ export async function startPlayWriterCDPRelayServer({
}
const stopParams: StopRecordingParams = sessionId ? { sessionId } : {}
const result = await relay.stopRecording(stopParams)
const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500)
const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500
return c.json(result, status)
})
@@ -1684,7 +1762,7 @@ export async function startPlayWriterCDPRelayServer({
})
app.post('/recording/cancel', async (c) => {
const body = await c.req.json() as { sessionId?: string | number }
const body = (await c.req.json()) as { sessionId?: string | number }
const sessionId = normalizeSessionId(body.sessionId)
const manager = await getExecutorManager()
const executor = sessionId ? manager.getSession(sessionId) : null
@@ -1732,6 +1810,6 @@ export async function startPlayWriterCDPRelayServer({
},
off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
emitter.off(event, listener as (...args: unknown[]) => void)
}
},
}
}
+12 -3
View File
@@ -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
View File
@@ -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
}
+35 -23
View File
@@ -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 [{
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'
const extensionId =
selectedExtension.extensionId === 'default'
? null
: (selectedExtension.stableKey || selectedExtension.extensionId)
: 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,16 +528,12 @@ cli
})
})
cli
.command('logfile', 'Print the path to the relay server log file')
.action(() => {
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(() => {
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)
+5 -3
View File
@@ -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
}
+4 -1
View File
@@ -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 }
+1 -5
View File
@@ -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)
}
+1 -4
View File
@@ -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)
+11 -1
View File
@@ -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,
}
+10 -2
View File
@@ -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
View File
@@ -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`
}
+52 -41
View File
@@ -53,7 +53,7 @@ describe('Extension Connection Tests', () => {
let contexts = directBrowser.contexts()
let pages = contexts[0].pages()
let foundPage = pages.find(p => p.url() === testUrl)
let foundPage = pages.find((p) => p.url() === testUrl)
expect(foundPage).toBeDefined()
expect(foundPage?.url()).toBe(testUrl)
@@ -73,7 +73,7 @@ describe('Extension Connection Tests', () => {
contexts = directBrowser.contexts()
pages = contexts[0].pages()
foundPage = pages.find(p => p.url() === testUrl)
foundPage = pages.find((p) => p.url() === testUrl)
expect(foundPage).toBeUndefined()
await directBrowser.close()
@@ -86,15 +86,15 @@ describe('Extension Connection Tests', () => {
// 7. Verify page is back
directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
contexts = directBrowser.contexts()
if (contexts[0].pages().length === 0) {
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
pages = contexts[0].pages()
foundPage = pages.find(p => p.url() === testUrl)
foundPage = pages.find((p) => p.url() === testUrl)
expect(foundPage).toBeDefined()
expect(foundPage?.url()).toBe(testUrl)
@@ -110,7 +110,7 @@ describe('Extension Connection Tests', () => {
const serviceWorker = await getExtensionServiceWorker(browserContext)
const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
// 1. Create a new page
const page = await browserContext.newPage()
@@ -127,9 +127,9 @@ describe('Extension Connection Tests', () => {
let foundPage
for (let i = 0; i < 50; i++) {
const pages = directBrowser.contexts()[0].pages()
foundPage = pages.find(p => p.url() === testUrl)
foundPage = pages.find((p) => p.url() === testUrl)
if (foundPage) break
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
expect(foundPage).toBeDefined()
expect(foundPage?.url()).toBe(testUrl)
@@ -145,9 +145,9 @@ describe('Extension Connection Tests', () => {
// 5. Verify page disappears (polling)
for (let i = 0; i < 50; i++) {
const pages = directBrowser.contexts()[0].pages()
foundPage = pages.find(p => p.url() === testUrl)
foundPage = pages.find((p) => p.url() === testUrl)
if (!foundPage) break
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
expect(foundPage).toBeUndefined()
@@ -159,9 +159,9 @@ describe('Extension Connection Tests', () => {
// 7. Verify page reappears (polling)
for (let i = 0; i < 50; i++) {
const pages = directBrowser.contexts()[0].pages()
foundPage = pages.find(p => p.url() === testUrl)
foundPage = pages.find((p) => p.url() === testUrl)
if (foundPage) break
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
expect(foundPage).toBeDefined()
expect(foundPage?.url()).toBe(testUrl)
@@ -191,7 +191,10 @@ describe('Extension Connection Tests', () => {
// 3. Connect via CDP
const cdpUrl = getCdpUrl({ port: TEST_PORT })
const directBrowser = await chromium.connectOverCDP(cdpUrl)
const connectedPage = directBrowser.contexts()[0].pages().find(p => p.url() === initialUrl)
const connectedPage = directBrowser
.contexts()[0]
.pages()
.find((p) => p.url() === initialUrl)
expect(connectedPage).toBeDefined()
expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2)
@@ -220,13 +223,13 @@ describe('Extension Connection Tests', () => {
it('should support multiple concurrent tabs', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
// Tab A
const pageA = await browserContext.newPage()
await pageA.goto('https://example.com/tab-a')
await pageA.bringToFront()
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
@@ -235,7 +238,7 @@ describe('Extension Connection Tests', () => {
const pageB = await browserContext.newPage()
await pageB.goto('https://example.com/tab-b')
await pageB.bringToFront()
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
@@ -249,19 +252,22 @@ describe('Extension Connection Tests', () => {
const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
return {
idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
idB: state.tabs.get(tabB?.id ?? -1)?.targetId
idB: state.tabs.get(tabB?.id ?? -1)?.targetId,
}
})
expect(targetIds).toMatchInlineSnapshot({
expect(targetIds).toMatchInlineSnapshot(
{
idA: expect.any(String),
idB: expect.any(String)
}, `
idB: expect.any(String),
},
`
{
"idA": Any<String>,
"idB": Any<String>,
}
`)
`,
)
expect(targetIds.idA).not.toBe(targetIds.idB)
// Verify independent connections
@@ -269,10 +275,12 @@ describe('Extension Connection Tests', () => {
const pages = browser.contexts()[0].pages()
const results = await Promise.all(pages.map(async (p) => ({
const results = await Promise.all(
pages.map(async (p) => ({
url: p.url(),
title: await p.title()
})))
title: await p.title(),
})),
)
expect(results).toMatchInlineSnapshot(`
[
@@ -292,8 +300,8 @@ describe('Extension Connection Tests', () => {
`)
// Verify execution on both pages
const pageA_CDP = pages.find(p => p.url().includes('tab-a'))
const pageB_CDP = pages.find(p => p.url().includes('tab-b'))
const pageA_CDP = pages.find((p) => p.url().includes('tab-a'))
const pageB_CDP = pages.find((p) => p.url().includes('tab-b'))
expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40)
@@ -432,9 +440,12 @@ describe('Extension Connection Tests', () => {
})
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const cdpPage = browser.contexts()[0].pages().find(p => p.url() === targetUrl)
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url() === targetUrl)
expect(cdpPage).toBeDefined()
expect(cdpPage?.url()).toBe(targetUrl)
@@ -462,7 +473,7 @@ describe('Extension Connection Tests', () => {
console.log('Initial enable result:', initialEnable)
expect(initialEnable.isConnected).toBe(true)
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
// Verify MCP can see the page
const beforeDisconnect = await client.callTool({
@@ -488,7 +499,7 @@ describe('Extension Connection Tests', () => {
await globalThis.disconnectEverything()
})
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
// 3. Verify MCP cannot execute code anymore (no pages available)
const afterDisconnect = await client.callTool({
@@ -521,7 +532,7 @@ describe('Extension Connection Tests', () => {
expect(reconnectResult.isConnected).toBe(true)
console.log('Waiting for reconnection to stabilize...')
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
// 5. Reset the MCP client's playwright connection
console.log('Resetting MCP playwright connection...')
@@ -582,7 +593,7 @@ describe('Extension Connection Tests', () => {
return await globalThis.toggleExtensionForActiveTab()
})
expect(initialEnable.isConnected).toBe(true)
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
// 2. Verify MCP can execute commands
const beforeResult = await client.callTool({
@@ -610,7 +621,7 @@ describe('Extension Connection Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything()
})
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
// Re-enable extension
await page.bringToFront()
@@ -618,7 +629,7 @@ describe('Extension Connection Tests', () => {
return await globalThis.toggleExtensionForActiveTab()
})
expect(reconnectResult.isConnected).toBe(true)
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
// 4. Execute command WITHOUT calling resetPlaywright()
const afterResult = await client.callTool({
@@ -662,11 +673,11 @@ describe('Extension Connection Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPages = browser.contexts()[0].pages()
const testPage = cdpPages.find(p => p.url().includes('sw-test'))
const testPage = cdpPages.find((p) => p.url().includes('sw-test'))
expect(testPage).toBeDefined()
expect(testPage?.url()).toContain('sw-test')
@@ -692,13 +703,13 @@ describe('Extension Connection Tests', () => {
for (let i = 0; i < 5; i++) {
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPages = browser.contexts()[0].pages()
const testPage = cdpPages.find(p => p.url().includes('repeated-test'))
const testPage = cdpPages.find((p) => p.url().includes('repeated-test'))
expect(testPage).toBeDefined()
expect(testPage?.url()).toBe(targetUrl)
await browser.close()
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
await page.close()
@@ -717,7 +728,7 @@ describe('Extension Connection Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
const [mcpResult, cdpBrowser] = await Promise.all([
client.callTool({
@@ -730,14 +741,14 @@ describe('Extension Connection Tests', () => {
`,
},
}),
chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
])
const mcpOutput = (mcpResult as any).content[0].text
expect(mcpOutput).toContain(targetUrl)
const cdpPages = cdpBrowser.contexts()[0].pages()
const cdpPage = cdpPages.find(p => p.url().includes('concurrent-test'))
const cdpPage = cdpPages.find((p) => p.url().includes('concurrent-test'))
expect(cdpPage?.url()).toBe(targetUrl)
await cdpBrowser.close()
+4 -6
View File
@@ -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
+6 -2
View File
@@ -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')
+22 -60
View File
@@ -14,16 +14,7 @@ export async function formatHtmlForPrompt({
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
@@ -108,15 +99,11 @@ export async function formatHtmlForPrompt({
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)
const shouldKeep = attr.startsWith('data-') || attributesToKeep.includes(attr)
if (shouldKeep) {
// Truncate attribute values
newAttrs[attr] = typeof value === 'string'
? truncate(value, maxAttrLen)
: value
newAttrs[attr] = typeof value === 'string' ? truncate(value, maxAttrLen) : value
}
}
node.attrs = newAttrs
@@ -124,9 +111,7 @@ export async function formatHtmlForPrompt({
// Process content recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter(item => {
node.content = node.content.map(processNode).filter((item) => {
if (item === null) return false
if (typeof item === 'string') {
const trimmed = item.trim()
@@ -140,7 +125,7 @@ export async function formatHtmlForPrompt({
}
// Process all root nodes
return tree.map(processNode).filter(item => item !== null)
return tree.map(processNode).filter((item) => item !== null)
}
}
@@ -159,9 +144,7 @@ export async function formatHtmlForPrompt({
// Process children recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter((item) => item !== null)
node.content = node.content.map(processNode).filter((item) => item !== null)
}
return node
@@ -189,9 +172,7 @@ export async function formatHtmlForPrompt({
// Process children recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter((item) => item !== null)
node.content = node.content.map(processNode).filter((item) => item !== null)
}
return node
@@ -207,15 +188,7 @@ export async function formatHtmlForPrompt({
// - 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',
]
const meaningfulAttrs = ['aria-label', 'title', 'alt', 'value', 'placeholder', 'href', 'name']
// Form elements are always actionable, keep unconditionally
const formTags = ['input', 'select', 'textarea']
@@ -271,24 +244,12 @@ export async function formatHtmlForPrompt({
// First process children
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter((item) => item !== null)
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',
]
const semanticTags = ['html', 'body', 'main', 'header', 'footer', 'nav', 'section', 'article', 'aside']
if (semanticTags.includes(node.tag.toLowerCase())) {
return node
}
@@ -330,7 +291,7 @@ export async function formatHtmlForPrompt({
// - 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))
const nonWhitespaceChildren = node.content.filter((c) => !isWhitespaceOnly(c))
if (nonWhitespaceChildren.length === 1) {
const onlyChild = nonWhitespaceChildren[0]
@@ -367,9 +328,8 @@ export async function formatHtmlForPrompt({
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
)
const hasContent =
node.content && node.content.some((c) => (typeof c === 'string' ? c.trim().length > 0 : true))
return !hasAttrs && !hasContent
}
@@ -377,14 +337,14 @@ export async function formatHtmlForPrompt({
if (!content || !Array.isArray(content)) return content
return content
.map(node => {
.map((node) => {
if (typeof node === 'string') return node
if (node.content) {
node.content = removeEmpty(node.content)
}
return node
})
.filter(node => !isEmptyElement(node))
.filter((node) => !isEmptyElement(node))
}
// Apply multiple passes until stable
@@ -409,17 +369,19 @@ export async function formatHtmlForPrompt({
.use(removeDecorativeSubtreesPlugin())
.use(removeEmptyElementsPlugin())
.use(unwrapNestedWrappersPlugin())
.use(beautify({
.use(
beautify({
rules: {
indent: 1, // 1-space indent
blankLines: false, // no extra blank lines
maxlen: 100000 // effectively never wrap by content length
maxlen: 100000, // effectively never wrap by content length
},
jsBeautifyOptions: {
wrap_line_length: 0, // disable js-beautify wrapping
preserve_newlines: false // reduce stray newlines
}
}))
preserve_newlines: false, // reduce stray newlines
},
}),
)
// Process with await
const result = await processor.process(html)
+1 -3
View File
@@ -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 {
+1 -1
View File
@@ -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',
+4 -1
View File
@@ -262,7 +262,10 @@ server.tool(
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) {
+2 -2
View File
@@ -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,7 +131,7 @@ 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[] = []
+25 -13
View File
@@ -2,8 +2,7 @@ import { CDPEventFor, ProtocolMapping } from './cdp-types.js'
export const VERSION = 1
type ForwardCDPCommand =
{
type ForwardCDPCommand = {
[K in keyof ProtocolMapping.Commands]: {
id: number
method: 'forwardCDPCommand'
@@ -29,8 +28,7 @@ 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 =
{
export type ExtensionEventMessage = {
[K in keyof ProtocolMapping.Events]: {
id?: undefined
method: 'forwardCDPEvent'
@@ -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,33 +141,39 @@ export type RecordingCommandMessage =
| CancelRecordingMessage
// Recording result types
export type StartRecordingResult = {
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 = {
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 = {
export type StopRecordingResult =
| {
success: true
tabId: number
duration: number
path: string
size: number
} | {
}
| {
success: false
error: string
}
@@ -193,10 +203,12 @@ export type GhostBrowserCommandMessage = {
}
}
export type GhostBrowserCommandResult = {
export type GhostBrowserCommandResult =
| {
success: true
result: unknown
} | {
}
| {
success: false
error: string
}
+19 -11
View File
@@ -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)
+16 -6
View File
@@ -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: {
export async function waitForExtension(
options: {
port?: number
timeoutMs?: number
logger?: { log: (...args: any[]) => void }
} = {}): Promise<boolean> {
} = {},
): 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 })
}
+28 -19
View File
@@ -2,7 +2,15 @@ import { createMCPClient } from './mcp-client.js'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { getCDPSessionForPage } from './cdp-session.js'
import { getCdpUrl } from './utils.js'
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, js, tryJsonParse } from './test-utils.js'
import {
setupTestContext,
cleanupTestContext,
getExtensionServiceWorker,
type TestContext,
withTimeout,
js,
tryJsonParse,
} from './test-utils.js'
import './test-declarations.js'
const TEST_PORT = 19987
@@ -52,7 +60,9 @@ describe('Relay Core Tests', () => {
timeoutMs: 10000,
errorMessage: 'Timed out toggling extension for active tab',
})
await new Promise((r) => { setTimeout(r, 100) })
await new Promise((r) => {
setTimeout(r, 100)
})
const cdpSession = await withTimeout({
promise: getCDPSessionForPage({ page }),
@@ -150,7 +160,7 @@ describe('Relay Core Tests', () => {
connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
tabId: testTab?.id,
tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null,
connectionState: state.connectionState
connectionState: state.connectionState,
}
})
@@ -215,9 +225,7 @@ describe('Relay Core Tests', () => {
typeof interactiveResult === 'object' && interactiveResult.content?.[0]?.text
? tryJsonParse(interactiveResult.content[0].text)
: interactiveResult
await expect(interactiveData).toMatchFileSnapshot(
`snapshots/${testCase.name}-accessibility-interactive.md`,
)
await expect(interactiveData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-interactive.md`)
expect(interactiveResult.content).toBeDefined()
for (const expected of testCase.expectedContent) {
expect(interactiveData).toContain(expected)
@@ -238,9 +246,7 @@ describe('Relay Core Tests', () => {
typeof fullResult === 'object' && fullResult.content?.[0]?.text
? tryJsonParse(fullResult.content[0].text)
: fullResult
await expect(fullData).toMatchFileSnapshot(
`snapshots/${testCase.name}-accessibility-full.md`,
)
await expect(fullData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-full.md`)
expect(fullResult.content).toBeDefined()
for (const expected of testCase.expectedContent) {
expect(fullData).toContain(expected)
@@ -265,7 +271,6 @@ describe('Relay Core Tests', () => {
`,
},
})
})
it('should capture browser console logs with getLatestLogs', async () => {
@@ -611,7 +616,9 @@ describe('Relay Core Tests', () => {
}, 30000)
// right now our extension always forces light mode because of a playwright cdp bug
it.todo('should preserve system color scheme instead of forcing light mode', async () => {
it.todo(
'should preserve system color scheme instead of forcing light mode',
async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
@@ -627,7 +634,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const result = await client.callTool({
name: 'execute',
@@ -658,7 +665,9 @@ describe('Relay Core Tests', () => {
`)
await page.close()
}, 60000)
},
60000,
)
it('should get clean HTML with getCleanHTML', async () => {
const browserContext = getBrowserContext()
@@ -686,7 +695,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
// Test basic getCleanHTML
const result = await client.callTool({
@@ -787,7 +796,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
// Test basic getPageMarkdown
const result = await client.callTool({
@@ -859,7 +868,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
// 2. Create first page and enable extension
const page1 = await browserContext.newPage()
@@ -869,7 +878,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
// 3. Reset MCP to ensure page1 becomes the default page (only page available)
const resetResult = await client.callTool({
@@ -899,11 +908,11 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
// 6. Close the first page (which is the default `page` in MCP scope)
await page1.close()
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
// 7. Execute code via MCP - should NOT fail with "page closed" error
// Instead, it should automatically switch to the second page
+47 -37
View File
@@ -1,7 +1,14 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { chromium, type Page } from '@xmorse/playwright-core'
import { getCdpUrl } from './utils.js'
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, createSimpleServer } from './test-utils.js'
import {
setupTestContext,
cleanupTestContext,
getExtensionServiceWorker,
type TestContext,
withTimeout,
createSimpleServer,
} from './test-utils.js'
import './test-declarations.js'
const TEST_PORT = 19992
@@ -23,13 +30,7 @@ describe('Relay Navigation Tests', () => {
return testCtx.browserContext
}
const waitForStableDocumentReadyState = async ({
page,
timeoutMs,
}: {
page: Page
timeoutMs: number
}) => {
const waitForStableDocumentReadyState = async ({ page, timeoutMs }: { page: Page; timeoutMs: number }) => {
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
@@ -132,7 +133,9 @@ describe('Relay Navigation Tests', () => {
timeoutMs: 5000,
errorMessage: 'Timed out toggling extension for iframe test',
})
await new Promise((r) => { setTimeout(r, 400) })
await new Promise((r) => {
setTimeout(r, 400)
})
const browser = await withTimeout({
promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
@@ -164,10 +167,7 @@ describe('Relay Navigation Tests', () => {
timeoutMs: 5000,
errorMessage: 'Timed out closing page for iframe test',
})
await Promise.all([
parentServer.close(),
childServer.close(),
])
await Promise.all([parentServer.close(), childServer.close()])
}
}, 60000)
@@ -282,10 +282,7 @@ describe('Relay Navigation Tests', () => {
timeoutMs: 5000,
errorMessage: 'Timed out closing page for empty-src iframe test',
})
await Promise.all([
parentServer.close(),
childServer.close(),
])
await Promise.all([parentServer.close(), childServer.close()])
}
}, 60000)
@@ -305,7 +302,10 @@ describe('Relay Navigation Tests', () => {
const context = browser.contexts()[0]
const pages = context.pages()
console.log('All page URLs:', pages.map(p => p.url()))
console.log(
'All page URLs:',
pages.map((p) => p.url()),
)
expect(pages.length).toBeGreaterThan(0)
for (const p of pages) {
@@ -314,7 +314,7 @@ describe('Relay Navigation Tests', () => {
expect(p.url()).not.toBeUndefined()
}
const discordPage = pages.find(p => p.url().includes('discord.com'))
const discordPage = pages.find((p) => p.url().includes('discord.com'))
expect(discordPage).toBeDefined()
const result = await discordPage!.evaluate(() => window.location.href)
@@ -337,10 +337,13 @@ describe('Relay Navigation Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url() === initialUrl)
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url() === initialUrl)
expect(cdpPage).toBeDefined()
const response = await cdpPage!.goto('https://www.notion.so', { waitUntil: 'domcontentloaded', timeout: 20000 })
@@ -367,10 +370,13 @@ describe('Relay Navigation Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('about:'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('about:'))
expect(cdpPage).toBeDefined()
const response = await cdpPage!.goto('https://www.youtube.com', { waitUntil: 'domcontentloaded', timeout: 20000 })
@@ -407,7 +413,7 @@ describe('Relay Navigation Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
for (let i = 0; i < 3; i++) {
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
@@ -425,7 +431,7 @@ describe('Relay Navigation Tests', () => {
expect(iframePage?.url()).toContain('about:')
await browser.close()
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
await page.close()
@@ -438,20 +444,20 @@ describe('Relay Navigation Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const targetUrl = 'https://example.com/'
const enableResult = await serviceWorker.evaluate(async (url) => {
const tab = await chrome.tabs.create({ url, active: true })
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
return await globalThis.toggleExtensionForActiveTab()
}, targetUrl)
console.log('Extension enabled:', enableResult)
expect(enableResult.isConnected).toBe(true)
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const { Stagehand } = await import('@browserbasehq/stagehand')
@@ -472,9 +478,13 @@ describe('Relay Navigation Tests', () => {
expect(context).toBeDefined()
const pages = context.pages()
console.log('Stagehand pages:', pages.length, pages.map(p => p.url()))
console.log(
'Stagehand pages:',
pages.length,
pages.map((p) => p.url()),
)
const stagehandPage = pages.find(p => p.url().includes('example.com'))
const stagehandPage = pages.find((p) => p.url().includes('example.com'))
expect(stagehandPage).toBeDefined()
const url = stagehandPage!.url()
@@ -495,16 +505,16 @@ describe('Relay Navigation Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 200))
await new Promise((r) => setTimeout(r, 200))
// Test /json/version
const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`)
expect(versionRes.status).toBe(200)
const versionJson = await versionRes.json() as { webSocketDebuggerUrl: string }
const versionJson = (await versionRes.json()) as { webSocketDebuggerUrl: string }
expect(versionJson).toMatchObject({
'Browser': expect.stringContaining('Playwriter/'),
Browser: expect.stringContaining('Playwriter/'),
'Protocol-Version': '1.3',
'webSocketDebuggerUrl': expect.stringContaining('ws://'),
webSocketDebuggerUrl: expect.stringContaining('ws://'),
})
expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`)
@@ -515,7 +525,7 @@ describe('Relay Navigation Tests', () => {
// Test /json/list
const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`)
expect(listRes.status).toBe(200)
const listJson = await listRes.json() as Array<{ url?: string }>
const listJson = (await listRes.json()) as Array<{ url?: string }>
expect(Array.isArray(listJson)).toBe(true)
expect(listJson.length).toBeGreaterThan(0)
@@ -555,7 +565,7 @@ describe('Relay Navigation Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 200))
await new Promise((r) => setTimeout(r, 200))
const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.mp4')
if (!fs.existsSync(path.dirname(outputPath))) {
@@ -576,7 +586,7 @@ describe('Relay Navigation Tests', () => {
await recordingPage.locator('.titleline a').first().click()
await recordingPage.waitForLoadState('domcontentloaded')
await new Promise(r => setTimeout(r, 500))
await new Promise((r) => setTimeout(r, 500))
await recordingPage.goBack()
await recordingPage.waitForLoadState('domcontentloaded')
+118 -71
View File
@@ -6,7 +6,16 @@ import { getCDPSessionForPage } from './cdp-session.js'
import { Debugger } from './debugger.js'
import { Editor } from './editor.js'
import { PlaywrightExecutor } from './executor.js'
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, createSseServer, safeCloseCDPBrowser, type TestContext, withTimeout, js } from './test-utils.js'
import {
setupTestContext,
cleanupTestContext,
getExtensionServiceWorker,
createSseServer,
safeCloseCDPBrowser,
type TestContext,
withTimeout,
js,
} from './test-utils.js'
import './test-declarations.js'
const TEST_PORT = 19993
@@ -23,7 +32,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}, 600000)
afterAll(async () => {
@@ -36,8 +45,6 @@ describe('CDP Session Tests', () => {
return testCtx.browserContext
}
it('should use Debugger class to set breakpoints and inspect variables', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
@@ -50,7 +57,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page })
@@ -72,12 +79,12 @@ describe('CDP Session Tests', () => {
const numberVar = 42;
debugger;
return localVar + numberVar;
})()`
})()`,
})
await Promise.race([
pausedPromise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)),
])
expect(dbg.isPaused()).toBe(true)
@@ -98,7 +105,7 @@ describe('CDP Session Tests', () => {
expect(evalResult.value).toBe('hello world')
await dbg.resume()
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
expect(dbg.isPaused()).toBe(false)
await evalPromise
@@ -118,7 +125,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const executor = new PlaywrightExecutor({
cdpConfig: { port: TEST_PORT },
@@ -154,10 +161,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('news.ycombinator'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('news.ycombinator'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -194,7 +204,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page })
@@ -231,7 +241,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page })
@@ -253,7 +263,7 @@ describe('CDP Session Tests', () => {
}
const result = inner();
return result;
})()`
})()`,
})
await pausedPromise
@@ -301,7 +311,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page })
@@ -320,15 +330,15 @@ describe('CDP Session Tests', () => {
for (let i = 0; i < 1000; i++) {
document.querySelectorAll('*')
}
})()`
})()`,
})
const stopResult = await cdpSession.send('Profiler.stop')
const profile = stopResult.profile
const functionNames = profile.nodes
.map(n => n.callFrame.functionName)
.filter(name => name && name.length > 0)
.map((n) => n.callFrame.functionName)
.filter((name) => name && name.length > 0)
.slice(0, 10)
expect(profile.nodes.length).toBeGreaterThan(0)
@@ -355,32 +365,35 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
const initialTargets = await cdpSession.send('Target.getTargets')
const initialPageTarget = initialTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('example.com'))
const initialPageTarget = initialTargets.targetInfos.find((t) => t.type === 'page' && t.url.includes('example.com'))
expect(initialPageTarget?.url).toBe('https://example.com/')
await cdpPage!.goto('https://example.org/', { waitUntil: 'domcontentloaded' })
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const afterNavTargets = await cdpSession.send('Target.getTargets')
const allPageTargets = afterNavTargets.targetInfos.filter(t => t.type === 'page')
const allPageTargets = afterNavTargets.targetInfos.filter((t) => t.type === 'page')
const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank')
const aboutBlankTargets = allPageTargets.filter((t) => t.url === 'about:blank')
expect(aboutBlankTargets).toHaveLength(0)
const exampleComTargets = allPageTargets.filter(t => t.url.includes('example.com'))
const exampleComTargets = allPageTargets.filter((t) => t.url.includes('example.com'))
expect(exampleComTargets).toHaveLength(0)
const exampleOrgTargets = allPageTargets.filter(t => t.url.includes('example.org'))
const exampleOrgTargets = allPageTargets.filter((t) => t.url.includes('example.org'))
expect(exampleOrgTargets).toHaveLength(1)
await cdpSession.detach()
@@ -410,23 +423,26 @@ describe('CDP Session Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
const { targetInfos } = await cdpSession.send('Target.getTargets')
const allPageTargets = targetInfos.filter(t => t.type === 'page')
const allPageTargets = targetInfos.filter((t) => t.type === 'page')
const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank')
const aboutBlankTargets = allPageTargets.filter((t) => t.url === 'about:blank')
expect(aboutBlankTargets).toHaveLength(0)
const pageTargets = allPageTargets
.map(t => ({ type: t.type, url: t.url }))
.map((t) => ({ type: t.type, url: t.url }))
.sort((a, b) => a.url.localeCompare(b.url))
expect(pageTargets).toMatchInlineSnapshot(`
@@ -459,13 +475,16 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
await page.goto('https://example.org/', { waitUntil: 'domcontentloaded' })
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.org'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.org'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -494,10 +513,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -546,10 +568,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -570,12 +595,12 @@ describe('CDP Session Tests', () => {
} catch (e) {
// caught but should still pause with state 'all'
}
})()`
})()`,
})
await Promise.race([
pausedPromise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)),
])
expect(dbg.isPaused()).toBe(true)
@@ -623,7 +648,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
let cdpPage
@@ -692,10 +717,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const h1Bounds = await cdpPage!.locator('h1').boundingBox()
@@ -703,10 +731,10 @@ describe('CDP Session Tests', () => {
console.log('H1 bounding box:', h1Bounds)
await cdpPage!.evaluate(() => {
(window as any).clickedAt = null;
;(window as any).clickedAt = null
document.addEventListener('click', (e) => {
(window as any).clickedAt = { x: e.clientX, y: e.clientY };
});
;(window as any).clickedAt = { x: e.clientX, y: e.clientY }
})
})
await cdpPage!.locator('h1').click()
@@ -733,10 +761,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -753,7 +784,7 @@ describe('CDP Session Tests', () => {
}
`,
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const scripts = await editor.list()
expect(scripts.length).toBeGreaterThan(0)
@@ -772,14 +803,14 @@ describe('CDP Session Tests', () => {
})
const consoleLogs: string[] = []
cdpPage!.on('console', msg => {
cdpPage!.on('console', (msg) => {
consoleLogs.push(msg.text())
})
await cdpPage!.evaluate(() => {
(window as any).greetUser('World')
;(window as any).greetUser('World')
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
expect(consoleLogs).toContain('Hello, World')
expect(consoleLogs).toContain('EDITOR_TEST_MARKER')
@@ -800,10 +831,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -820,7 +854,7 @@ describe('CDP Session Tests', () => {
}
`,
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const stylesheets = await editor.list({ pattern: /inline-css:/ })
expect(stylesheets.length).toBeGreaterThan(0)
@@ -893,11 +927,11 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
await new Promise((r) => setTimeout(r, 500))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const pages = browser.contexts()[0].pages()
const cdpPage = pages.find(p => p.url().startsWith('about:'))
const cdpPage = pages.find((p) => p.url().startsWith('about:'))
expect(cdpPage).toBeDefined()
const btn = cdpPage!.locator('#react-btn')
@@ -975,7 +1009,7 @@ describe('Service Worker Target Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
await new Promise((r) => setTimeout(r, 500))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const context = browser.contexts()[0]
@@ -989,7 +1023,7 @@ describe('Service Worker Target Tests', () => {
expect(url).not.toMatch(/service.?worker/i)
}
const targetPage = pages.find(p => p.url().includes('web.dev'))
const targetPage = pages.find((p) => p.url().includes('web.dev'))
expect(targetPage).toBeDefined()
const title = await targetPage!.title()
@@ -1010,10 +1044,13 @@ describe('Service Worker Target Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -1022,12 +1059,12 @@ describe('Service Worker Target Tests', () => {
await cdpSession.send('Network.disable')
await cdpSession.send('Network.enable', {
maxTotalBufferSize: 10000000,
maxResourceBufferSize: 5000000
maxResourceBufferSize: 5000000,
})
const [response] = await Promise.all([
cdpPage!.waitForResponse(resp => resp.url() === 'https://example.com/'),
cdpPage!.goto('https://example.com/')
cdpPage!.waitForResponse((resp) => resp.url() === 'https://example.com/'),
cdpPage!.goto('https://example.com/'),
])
const body = await response.text()
@@ -1085,7 +1122,10 @@ describe('Service Worker Target Tests', () => {
timeoutMs: 5000,
errorMessage: 'connectOverCDP timed out',
})
const cdpPage = browser.contexts()[0].pages().find((p) => {
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => {
return p.url().startsWith(sseServer.baseUrl)
})
expect(cdpPage).toBeDefined()
@@ -1094,9 +1134,12 @@ describe('Service Worker Target Tests', () => {
return window.startSse()
})
await withTimeout({
promise: cdpPage!.waitForFunction(() => {
promise: cdpPage!.waitForFunction(
() => {
return window.__sseMessages.length > 0
}, { timeout: 5000 }),
},
{ timeout: 5000 },
),
timeoutMs: 7000,
errorMessage: 'SSE message not received in time',
})
@@ -1170,7 +1213,7 @@ describe('Auto-enable Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}, 600000)
afterAll(async () => {
@@ -1192,7 +1235,7 @@ describe('Auto-enable Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const tabCountBefore = await serviceWorker.evaluate(() => {
const state = globalThis.getExtensionState()
@@ -1229,7 +1272,9 @@ describe('Auto-enable Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything()
})
await new Promise((r) => { setTimeout(r, 100) })
await new Promise((r) => {
setTimeout(r, 100)
})
const tabCountBefore = await serviceWorker.evaluate(() => {
const state = globalThis.getExtensionState()
@@ -1267,7 +1312,9 @@ describe('Auto-enable Tests', () => {
},
})
await new Promise((r) => { setTimeout(r, 100) })
await new Promise((r) => {
setTimeout(r, 100)
})
const afterCloseResult = await client.callTool({
name: 'execute',
+10 -38
View File
@@ -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)
```
@@ -230,10 +226,7 @@ await page.evaluate(() => {
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
@@ -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
@@ -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')
```
+9 -3
View File
@@ -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
}
+27 -16
View File
@@ -9,12 +9,7 @@
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'
/**
@@ -27,7 +22,8 @@ import { EXTENSION_IDS } from './utils.js'
*/
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') ||
return (
error.includes('Extension has not been invoked') ||
error.includes('activeTab') ||
error.includes('enable recording')
)
}
export interface StartRecordingOptions {
@@ -104,10 +102,17 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
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'
@@ -120,7 +125,7 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
`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.`
`Or click the Playwriter extension icon on the tab once to grant permission.`,
)
}
@@ -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
? `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}`)
+224 -156
View File
@@ -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,26 +307,29 @@ 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 $'...'
playwriter -s 1 -e $'await state.page.locator("[id=\"_r_a_\"]").click()'
@@ -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.
@@ -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
@@ -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
```
@@ -827,26 +872,27 @@ await startRecording({
outputPath: './recording.mp4',
frameRate: 30, // default: 30
audio: false, // default: false (tab audio)
videoBitsPerSecond: 2500000 // 2.5 Mbps
});
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);
}))
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,7 +1052,10 @@ 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
@@ -1023,7 +1089,9 @@ 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
@@ -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:
+53 -35
View File
@@ -52,7 +52,7 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const capturedCommands: CDPCommand[] = []
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
@@ -63,7 +63,10 @@ describe('Snapshot & Screenshot Tests', () => {
testCtx!.relayServer.on('cdp:command', commandHandler)
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
@@ -94,10 +97,12 @@ describe('Snapshot & Screenshot Tests', () => {
testCtx!.relayServer.off('cdp:command', commandHandler)
expect(capturedCommands.length).toBe(2)
expect(capturedCommands.map(c => ({
expect(
capturedCommands.map((c) => ({
method: c.method,
params: c.params
}))).toMatchInlineSnapshot(`
params: c.params,
})),
).toMatchInlineSnapshot(`
[
{
"method": "Page.captureScreenshot",
@@ -152,10 +157,13 @@ describe('Snapshot & Screenshot Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
// Get actual browser viewport via JS
@@ -220,7 +228,7 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
const capturedCommands: CDPCommand[] = []
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
@@ -288,7 +296,7 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
const result = await client.callTool({
name: 'execute',
@@ -352,7 +360,7 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
const stylesResult = await client.callTool({
name: 'execute',
@@ -516,10 +524,13 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
@@ -531,7 +542,8 @@ describe('Snapshot & Screenshot Tests', () => {
cssVisualViewport: layoutMetrics.cssVisualViewport,
layoutViewport: layoutMetrics.layoutViewport,
visualViewport: layoutMetrics.visualViewport,
devicePixelRatio: layoutMetrics.cssVisualViewport.clientWidth > 0
devicePixelRatio:
layoutMetrics.cssVisualViewport.clientWidth > 0
? layoutMetrics.visualViewport.clientWidth / layoutMetrics.cssVisualViewport.clientWidth
: 1,
}
@@ -595,10 +607,13 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
// Use the new getCDPSessionForPage which reuses Playwright's internal WS
@@ -640,7 +655,7 @@ describe('Snapshot & Screenshot Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
let cdpPage
@@ -769,12 +784,16 @@ describe('Snapshot & Screenshot Tests', () => {
}
await page.close()
throw new Error(`Failed to load ${name} after ${attempts} attempts`, { cause: lastError instanceof Error ? lastError : undefined })
throw new Error(`Failed to load ${name} after ${attempts} attempts`, {
cause: lastError instanceof Error ? lastError : undefined,
})
}
const pages = await Promise.all(testPages.map((testPage) => {
const pages = await Promise.all(
testPages.map((testPage) => {
return loadPageWithRetries(testPage)
}))
}),
)
for (const { page } of pages) {
await page.bringToFront()
@@ -785,7 +804,7 @@ describe('Snapshot & Screenshot Tests', () => {
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const withTimeout = async <T,>(label: string, task: () => Promise<T>, timeoutMs: number): Promise<T> => {
const withTimeout = async <T>(label: string, task: () => Promise<T>, timeoutMs: number): Promise<T> => {
let timeoutId: NodeJS.Timeout | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
@@ -806,7 +825,10 @@ describe('Snapshot & Screenshot Tests', () => {
for (const { name, url, page } of pages) {
console.log(`[labels] start ${name}`)
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes(new URL(url).hostname))
const cdpPage = browser
.contexts()[0]
.pages()
.find((p) => p.url().includes(new URL(url).hostname))
if (!cdpPage) {
throw new Error(`Could not find CDP page for ${name}`)
@@ -818,7 +840,7 @@ describe('Snapshot & Screenshot Tests', () => {
async () => {
return await showAriaRefLabels({ page: cdpPage })
},
60000
60000,
)
console.log(`${name}: ${labelCount} labels shown`)
expect(labelCount).toBeGreaterThan(0)
@@ -829,7 +851,7 @@ describe('Snapshot & Screenshot Tests', () => {
async () => {
return await cdpPage.screenshot({ type: 'png', fullPage: false })
},
30000
30000,
)
const screenshotPath = path.join(assetsDir, `aria-labels-${name}.png`)
fs.writeFileSync(screenshotPath, screenshot)
@@ -839,11 +861,9 @@ describe('Snapshot & Screenshot Tests', () => {
const labelElements = await withTimeout(
`countLabels(${name})`,
async () => {
return await cdpPage.evaluate(() =>
document.querySelectorAll('.__pw_label__').length
)
return await cdpPage.evaluate(() => document.querySelectorAll('.__pw_label__').length)
},
10000
10000,
)
expect(labelElements).toBe(labelCount)
@@ -853,17 +873,15 @@ describe('Snapshot & Screenshot Tests', () => {
async () => {
await hideAriaRefLabels({ page: cdpPage })
},
10000
10000,
)
const labelsAfterHide = await withTimeout(
`verifyHide(${name})`,
async () => {
return await cdpPage.evaluate(() =>
document.getElementById('__playwriter_labels__')
)
return await cdpPage.evaluate(() => document.getElementById('__playwriter_labels__'))
},
10000
10000,
)
expect(labelsAfterHide).toBeNull()
@@ -942,7 +960,7 @@ describe('Snapshot & Screenshot Tests', () => {
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 400))
await new Promise((r) => setTimeout(r, 400))
const result = await client.callTool({
name: 'execute',
@@ -965,7 +983,7 @@ describe('Snapshot & Screenshot Tests', () => {
const content = result.content as any[]
expect(content.length).toBe(2)
const textContent = content.find(c => c.type === 'text')
const textContent = content.find((c) => c.type === 'text')
expect(textContent).toBeDefined()
expect(textContent.text).toContain('Screenshot saved to:')
expect(textContent.text).toContain('.jpg')
@@ -973,7 +991,7 @@ describe('Snapshot & Screenshot Tests', () => {
expect(textContent.text).toContain('Accessibility snapshot:')
expect(textContent.text).toContain('Submit Form')
const imageContent = content.find(c => c.type === 'image')
const imageContent = content.find((c) => c.type === 'image')
expect(imageContent).toBeDefined()
expect(imageContent.mimeType).toBe('image/jpeg')
expect(imageContent.data).toBeDefined()
+13 -10
View File
@@ -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.')
+8 -1
View File
@@ -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,
}
+4 -5
View File
@@ -149,9 +149,10 @@ export async function getStylesForLocator({
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',
@@ -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,
+5 -5
View File
@@ -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;
var window: any
var document: any
}
export {}
+25 -12
View File
@@ -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,11 +273,19 @@ export async function createSseServer(): Promise<SseServer> {
resolve()
})
})
}
},
}
}
export async function withTimeout<T>({ promise, timeoutMs, errorMessage }: { promise: Promise<T>; timeoutMs: number; errorMessage: string }): Promise<T> {
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))
@@ -289,10 +305,7 @@ export async function withTimeout<T>({ promise, timeoutMs, errorMessage }: { pro
/** 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) {
@@ -318,11 +331,11 @@ export function tryJsonParse(str: string) {
*/
export async function safeCloseCDPBrowser(
browser: Awaited<ReturnType<typeof import('@xmorse/playwright-core').chromium.connectOverCDP>>,
drainDelayMs = 50
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 new Promise((r) => setTimeout(r, drainDelayMs))
await browser.close()
}
+2 -1
View File
@@ -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
+6 -1
View File
@@ -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[]
+11 -10
View File
@@ -1,4 +1,3 @@
import { describe, it, expect, afterEach } from 'vitest'
import { startPlayWriterCDPRelayServer } from '../src/cdp-relay.js'
import { WebSocket } from 'ws'
@@ -33,7 +32,7 @@ describe('Security Tests', () => {
server = await startPlayWriterCDPRelayServer({
port: TEST_PORT,
token,
logger
logger,
})
// Helper to try connecting
@@ -72,7 +71,7 @@ describe('Security Tests', () => {
const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({
port: TEST_PORT,
logger
logger,
})
const tryConnectExtension = (origin?: string) => {
@@ -117,7 +116,11 @@ describe('Security Tests', () => {
// Content-Type: text/plain bypasses CORS entirely).
// =========================================================================
const httpRequest = ({ path, method = 'POST', headers = {} }: {
const httpRequest = ({
path,
method = 'POST',
headers = {},
}: {
path: string
method?: string
headers?: Record<string, string>
@@ -229,7 +232,7 @@ describe('Security Tests', () => {
const wrongToken = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: { 'Authorization': 'Bearer wrong-token' },
headers: { Authorization: 'Bearer wrong-token' },
})
expect(wrongToken.status).toBe(401)
@@ -237,14 +240,12 @@ describe('Security Tests', () => {
const bearerOk = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: { 'Authorization': `Bearer ${secretToken}` },
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}`,
)
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/*
@@ -258,7 +259,7 @@ describe('Security Tests', () => {
const recordingWithToken = await httpRequest({
path: '/recording/status',
method: 'GET',
headers: { 'Authorization': `Bearer ${secretToken}` },
headers: { Authorization: `Bearer ${secretToken}` },
})
expect(recordingWithToken.status).toBe(200)
})
+1
View File
@@ -12,6 +12,7 @@ playwriter skill
```
This outputs the complete documentation including:
- Session management and timeout configuration
- Selector strategies (and which ones to AVOID)
- Rules to prevent timeouts and failures
+46 -8
View File
@@ -26,9 +26,11 @@ The aria snapshot feature in playwriter extracts an accessibility tree from the
### Key Functions
#### `getAriaSnapshot()`
**Location:** Line 749-1033 in `aria-snapshot.ts`
Main entry point that returns `AriaSnapshotResult` containing:
- `snapshot`: String representation of the accessibility tree
- `tree`: Structured tree with nodes
- `refs`: Array of references to interactive elements
@@ -36,6 +38,7 @@ Main entry point that returns `AriaSnapshotResult` containing:
- `getRefsForLocators()`: Get refs for Playwright locators
**Signature:**
```typescript
export async function getAriaSnapshot({
page,
@@ -43,7 +46,7 @@ export async function getAriaSnapshot({
refFilter,
wsUrl,
interactiveOnly = false,
cdp
cdp,
}: {
page: Page
locator?: Locator
@@ -60,14 +63,16 @@ export async function getAriaSnapshot({
**Command:** `DOM.getFlattenedDocument`
**Location:** Line 772
```typescript
const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', {
const { nodes: domNodes } = (await session.send('DOM.getFlattenedDocument', {
depth: -1,
pierce: true
}) as Protocol.DOM.GetFlattenedDocumentResponse
pierce: true,
})) as Protocol.DOM.GetFlattenedDocumentResponse
```
**Parameters:**
- `depth: -1` - Get entire subtree
- `pierce: true` - **Traverses iframes and shadow roots**
@@ -77,12 +82,14 @@ const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', {
**Command:** `Accessibility.getFullAXTree`
**Location:** Line 791
```typescript
const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
as Protocol.Accessibility.GetFullAXTreeResponse
```
**Parameters:** None specified (uses defaults)
- Default: Returns AX tree for root frame
- **Has optional `frameId` parameter** (not currently used)
@@ -93,10 +100,12 @@ const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
### Current Implementation
**DOM Level:** ✅ **FULL SUPPORT**
- `DOM.getFlattenedDocument` with `pierce: true` traverses all iframes and shadow roots
- All DOM nodes from all frames are included in the flattened document
**Accessibility Level:** ⚠️ **LIMITED**
- `Accessibility.getFullAXTree` is called **without `frameId` parameter**
- According to CDP spec, when `frameId` is omitted, **only the root frame is used**
- Cross-origin iframes may have additional restrictions
@@ -104,6 +113,7 @@ const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
### CDP Spec Details
From `Accessibility.pdl`:
```
experimental command getFullAXTree
parameters
@@ -126,11 +136,13 @@ experimental command getFullAXTree
### Evidence from Code
**Scope handling (Line 760-789):**
- Uses `data-pw-scope` attribute to scope snapshots to a locator
- Builds `allowedBackendIds` set from DOM tree traversal
- Filters AX nodes based on `backendDOMNodeId` membership in this set
**Node mapping:**
- Each AX node has `backendDOMNodeId` property linking to DOM node
- DOM nodes fetched with `pierce: true` include iframe contents
- But AX tree without `frameId` may not cover all frames
@@ -138,6 +150,7 @@ experimental command getFullAXTree
## Key Data Structures
### AriaSnapshotNode
```typescript
type AriaSnapshotNode = {
role: string
@@ -151,6 +164,7 @@ type AriaSnapshotNode = {
```
### AriaRef
```typescript
interface AriaRef {
role: string
@@ -184,12 +198,28 @@ Generates Playwright-compatible locators:
**Location:** Lines 123-143
Only these roles get refs in interactive mode:
```typescript
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'combobox', 'searchbox',
'checkbox', 'radio', 'slider', 'spinbutton', 'switch',
'menuitem', 'menuitemcheckbox', 'menuitemradio',
'option', 'tab', 'treeitem', 'img', 'video', 'audio',
'button',
'link',
'textbox',
'combobox',
'searchbox',
'checkbox',
'radio',
'slider',
'spinbutton',
'switch',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'tab',
'treeitem',
'img',
'video',
'audio',
])
```
@@ -240,10 +270,12 @@ Takes a screenshot with Vimium-style labels overlaid on interactive elements:
**File:** `playwriter/src/aria-snapshot.test.ts`
Tests against real websites:
- Hacker News
- GitHub
**Coverage:**
- Snapshot generation
- Interactive-only mode
- Locator format validation
@@ -277,12 +309,14 @@ Tests against real websites:
To support iframes properly:
1. **Enumerate frames:**
```typescript
const { frameTree } = await session.send('Page.getFrameTree')
// Recursively collect all frame IDs
```
2. **Get AX tree per frame:**
```typescript
for (const frameId of frameIds) {
const { nodes } = await session.send('Accessibility.getFullAXTree', { frameId })
@@ -303,12 +337,14 @@ To support iframes properly:
## Summary
**File Paths:**
- Main implementation: `playwriter/src/aria-snapshot.ts`
- Executor integration: `playwriter/src/executor.ts` (lines 533-619)
- CDP session: `playwriter/src/cdp-session.ts`
- Tests: `playwriter/src/aria-snapshot.test.ts`
**CDP Commands:**
- `DOM.enable` - Enable DOM domain
- `DOM.getFlattenedDocument({ depth: -1, pierce: true })` - Get all DOM nodes including iframes
- `Accessibility.enable` - Enable accessibility domain
@@ -316,6 +352,7 @@ To support iframes properly:
- `DOM.getBoxModel({ backendNodeId })` - Get element positions for labels
**Frame Handling:**
- ✅ DOM tree includes iframe content (`pierce: true`)
- ⚠️ Accessibility tree likely **only root frame** (no `frameId` parameter)
- ❌ No frame enumeration or per-frame AX tree fetching
@@ -323,6 +360,7 @@ To support iframes properly:
**Key Insight:**
The current implementation may miss interactive elements inside iframes because:
1. `Accessibility.getFullAXTree()` without `frameId` only returns root frame
2. No iteration over child frames to collect their AX trees
3. Cross-origin iframes would be blocked anyway for security
+71 -61
View File
@@ -6,6 +6,7 @@ description: Analysis of how Playwright implements accessibility snapshots and h
## Overview
This document contains findings from exploring the Playwright source code to understand:
1. How Playwright implements accessibility snapshots (ariaSnapshot)
2. Whether Playwright supports getting accessibility tree for iframes/child frames
3. How Playwright handles frame locators for accessibility
@@ -69,7 +70,7 @@ From `packages/injected/src/ariaSnapshot.ts:217-232`:
```typescript
function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | null {
const active = element.ownerDocument.activeElement === element;
const active = element.ownerDocument.activeElement === element
if (element.nodeName === 'IFRAME') {
const ariaNode: aria.AriaNode = {
role: 'iframe',
@@ -78,17 +79,18 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode |
props: {},
box: computeBox(element),
receivesPointerEvents: true,
active
};
setAriaNodeElement(ariaNode, element);
computeAriaRef(ariaNode, options);
return ariaNode;
active,
}
setAriaNodeElement(ariaNode, element)
computeAriaRef(ariaNode, options)
return ariaNode
}
// ...
}
```
**Key observations:**
- IFrame elements are detected and added to the tree with `role: 'iframe'`
- The `children` array is ALWAYS empty for iframes
- IFrame refs are tracked separately in `snapshot.iframeRefs` array
@@ -99,11 +101,11 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode |
```typescript
export type AriaSnapshot = {
root: aria.AriaNode;
elements: Map<string, Element>; // ref -> Element mapping
refs: Map<Element, string>; // Element -> ref mapping
iframeRefs: string[]; // List of iframe ref IDs
};
root: aria.AriaNode
elements: Map<string, Element> // ref -> Element mapping
refs: Map<Element, string> // Element -> ref mapping
iframeRefs: string[] // List of iframe ref IDs
}
```
The `iframeRefs` array contains references to iframe elements but NOT their content.
@@ -115,21 +117,22 @@ The CDP protocol DOES support frame-specific accessibility queries:
```typescript
// From protocol.d.ts
export type getFullAXTreeParameters = {
depth?: number;
frameId?: Page.FrameId; // ⚠️ Supports frame-specific queries!
depth?: number
frameId?: Page.FrameId // ⚠️ Supports frame-specific queries!
}
export type getPartialAXTreeParameters = {
nodeId?: DOM.NodeId;
backendNodeId?: DOM.BackendNodeId;
objectId?: Runtime.RemoteObjectId;
fetchRelatives?: boolean;
nodeId?: DOM.NodeId
backendNodeId?: DOM.BackendNodeId
objectId?: Runtime.RemoteObjectId
fetchRelatives?: boolean
}
```
**However, Playwright does NOT use these CDP commands anywhere in the codebase.**
Search results show:
- CDP types defined in `protocol.d.ts`
- No actual usage in Chromium implementation files
- Firefox uses a custom `Accessibility.getFullAXTree` via Juggler protocol
@@ -137,12 +140,14 @@ Search results show:
### 5. Why Playwright Uses DOM Traversal Instead of CDP
**Advantages of DOM traversal approach:**
1. **Cross-browser compatibility** - Works in Firefox, WebKit, Chromium
2. **Full control** - Can customize what gets included/excluded
3. **Performance** - No serialization overhead for large trees
4. **Flexibility** - Can implement custom filtering (visibility, aria roles, etc.)
**Disadvantages:**
1. **Cannot access iframe content** - Browser security prevents cross-origin access
2. **Must execute in each frame separately** - No single command for entire page tree
3. **Slower for deep trees** - Must traverse DOM node-by-node
@@ -158,19 +163,21 @@ Based on code analysis:
3. **By design**: The `generateAriaTree` function explicitly skips iframe children
**To get iframe content accessibility tree, you would need to:**
1. Switch to the iframe's frame context
2. Call `ariaSnapshot()` again on that frame
3. Manually combine the results
Example:
```typescript
// Get main frame snapshot
const mainSnapshot = await page.locator('body').ariaSnapshot();
const mainSnapshot = await page.locator('body').ariaSnapshot()
// Get iframe content
const frameElement = await page.frameLocator('iframe');
const frame = await frameElement.owner();
const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot();
const frameElement = await page.frameLocator('iframe')
const frame = await frameElement.owner()
const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot()
// Results are separate - no automatic merging
```
@@ -184,14 +191,16 @@ const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot();
3. Merge the results into a single tree
**CDP Command:**
```typescript
await session.send('Accessibility.getFullAXTree', {
frameId: 'frame-id-here',
depth: -1 // unlimited depth
});
depth: -1, // unlimited depth
})
```
This would return the full accessibility tree including iframe content, but:
- Only works in Chromium (not Firefox/WebKit)
- Returns CDP's AXNode format, not Playwright's AriaNode format
- Would need conversion logic
@@ -205,34 +214,34 @@ Get accessibility tree for each frame separately:
```typescript
// Pseudo-code for MCP implementation
async function getAccessibilitySnapshot({ sessionId, includeFrames = false }) {
const page = getPage(sessionId);
const page = getPage(sessionId)
// Get main frame snapshot
const mainSnapshot = await page.evaluate(() => {
return injected.ariaSnapshot(document.body, { mode: 'ai' });
});
return injected.ariaSnapshot(document.body, { mode: 'ai' })
})
if (!includeFrames) {
return mainSnapshot;
return mainSnapshot
}
// Get all iframe snapshots
const frames = page.frames();
const frames = page.frames()
const frameSnapshots = await Promise.all(
frames.slice(1).map(async (frame) => {
return {
frameId: frame.name() || frame.url(),
snapshot: await frame.evaluate(() => {
return injected.ariaSnapshot(document.body, { mode: 'ai' });
})
};
})
);
return injected.ariaSnapshot(document.body, { mode: 'ai' })
}),
}
}),
)
return {
main: mainSnapshot,
frames: frameSnapshots
};
frames: frameSnapshots,
}
}
```
@@ -242,24 +251,24 @@ Use CDP `Accessibility.getFullAXTree` for each frame:
```typescript
async function getFullAccessibilityTree({ sessionId }) {
const page = getPage(sessionId);
const frames = page.frames();
const page = getPage(sessionId)
const frames = page.frames()
const trees = await Promise.all(
frames.map(async (frame) => {
const session = await frame._client; // Get CDP session
const session = await frame._client // Get CDP session
const { nodes } = await session.send('Accessibility.getFullAXTree', {
frameId: frame._id
});
frameId: frame._id,
})
return {
frameId: frame.url(),
nodes
};
})
);
nodes,
}
}),
)
// Convert CDP AXNode[] to Playwright AriaNode format
return convertCDPtoAria(trees);
return convertCDPtoAria(trees)
}
```
@@ -274,48 +283,48 @@ async function getFullAccessibilityTree({ sessionId }) {
```typescript
async function getRecursiveSnapshot({ sessionId, maxDepth = 3 }) {
const page = getPage(sessionId);
const page = getPage(sessionId)
async function getFrameSnapshot(frame, depth = 0) {
if (depth >= maxDepth) return null;
if (depth >= maxDepth) return null
// Get snapshot for this frame
const result = await frame.evaluate(() => {
return injected.incrementalAriaSnapshot(document.body, {
mode: 'ai',
refPrefix: `f${depth}_`
});
});
refPrefix: `f${depth}_`,
})
})
// Find iframe elements
const iframeElements = await frame.$$('iframe');
const iframeElements = await frame.$$('iframe')
// Get snapshots for child frames
const childSnapshots = await Promise.all(
iframeElements.map(async (iframeEl) => {
const childFrame = await iframeEl.contentFrame();
if (!childFrame) return null;
const childFrame = await iframeEl.contentFrame()
if (!childFrame) return null
return {
iframeSrc: await iframeEl.getAttribute('src'),
content: await getFrameSnapshot(childFrame, depth + 1)
};
})
);
content: await getFrameSnapshot(childFrame, depth + 1),
}
}),
)
return {
snapshot: result.full,
iframes: childSnapshots.filter(Boolean)
};
iframes: childSnapshots.filter(Boolean),
}
}
return await getFrameSnapshot(page.mainFrame());
return await getFrameSnapshot(page.mainFrame())
}
```
## Summary
| Feature | Playwright Support | Notes |
|---------|-------------------|-------|
| ----------------------------------------- | ------------------ | ----------------------------------------- |
| Accessibility snapshot for current frame | ✅ Yes | Via injected script DOM traversal |
| Accessibility snapshot for iframe content | ❌ No | Iframes detected but content not included |
| CDP Accessibility commands | ❌ Not used | Available but Playwright doesn't use them |
@@ -324,6 +333,7 @@ async function getRecursiveSnapshot({ sessionId, maxDepth = 3 }) {
| Multi-frame snapshot | ⚠️ Manual | Must query each frame separately |
**Bottom line:** Playwright's `ariaSnapshot()` works on a single frame at a time. To get iframe content, you must:
1. Get the iframe element
2. Access its `contentFrame()`
3. Call `ariaSnapshot()` on that frame
+7 -5
View File
@@ -15,7 +15,9 @@ Dark mode uses `prefers-color-scheme` media query, configured in `globals.css`:
```css
/* WRONG - silently produces no output */
@variant dark {
.my-class { color: white; }
.my-class {
color: white;
}
}
/* CORRECT - nest inside the selector */
@@ -37,9 +39,9 @@ Dark mode uses `prefers-color-scheme` media query, configured in `globals.css`:
```css
/* globals.css — this is the Tailwind entry point */
@import "tailwindcss";
@import "./editorial.css"; /* editorial page styles (class names, layout) */
@import "./editorial-prism.css"; /* prism syntax highlighting */
@import 'tailwindcss';
@import './editorial.css'; /* editorial page styles (class names, layout) */
@import './editorial-prism.css'; /* prism syntax highlighting */
@custom-variant dark (@media (prefers-color-scheme: dark));
```
@@ -54,7 +56,7 @@ For files with many dark mode selectors (like prism syntax colors), define CSS v
Every `<img>` must have explicit `width` and `height` attributes matching the intrinsic pixel dimensions of the source file. Add `style={{ height: "auto" }}` to keep it responsive. This lets the browser reserve the correct aspect ratio space before the image loads, preventing layout shift.
```tsx
<img src="/photo.png" width={1280} height={800} style={{ maxWidth: "100%", height: "auto" }} />
<img src='/photo.png' width={1280} height={800} style={{ maxWidth: '100%', height: 'auto' }} />
```
Use `sips -g pixelWidth -g pixelHeight <file>` to get dimensions on macOS.
+1 -3
View File
@@ -3,9 +3,7 @@
{
"name": "playwriter",
"description": "Control the user own Chrome browser via Playwriter extension with Playwright code snippets in a stateful local js sandbox via playwriter cli. Use this over other Playwright MCPs to automate the browser — it connects to the user's existing Chrome instead of launching a new one. Use this for JS-heavy websites (Instagram, Twitter, cookie/login walls, lazy-loaded UIs) instead of webfetch/curl. Run `playwriter skill` command to read the complete up to date skill",
"files": [
"SKILL.md"
]
"files": ["SKILL.md"]
}
]
}
@@ -12,6 +12,7 @@ playwriter skill
```
This outputs the complete documentation including:
- Session management and timeout configuration
- Selector strategies (and which ones to AVOID)
- Rules to prevent timeouts and failures
+199 -140
View File
@@ -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
@@ -213,30 +214,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.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage }).then(console.log)
state.myPage = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' })
console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage }).then(console.log)
```
```js
// 2. Act: open command palette → observe result
await state.myPage.keyboard.press('Meta+k');
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log)
await state.myPage.keyboard.press('Meta+k')
console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage, 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.myPage.keyboard.type('MCP');
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
await state.myPage.keyboard.type('MCP')
console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
```
```js
// 4. Act: press Enter → observe plugin loaded
await state.myPage.keyboard.press('Enter');
await state.myPage.waitForTimeout(1000);
console.log('URL:', state.myPage.url());
const frame = state.myPage.frames().find(f => f.url().includes('plugins.framercdn.com'));
await state.myPage.keyboard.press('Enter')
await state.myPage.waitForTimeout(1000)
console.log('URL:', state.myPage.url())
const frame = state.myPage.frames().find((f) => f.url().includes('plugins.framercdn.com'))
await snapshot({ page: state.myPage, frame: frame || undefined }).then(console.log)
// If frame not found, wait and observe again — plugin may still be loading
```
@@ -251,7 +255,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
page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
page.on('response', async (res) => {
if (res.url().includes('/api/')) {
console.log(res.status(), res.url())
}
})
```
- **URL changes** — confirm navigation happened:
```js
@@ -263,28 +271,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 page.keyboard.type('my text');
await page.keyboard.type('my text')
await snapshot({ 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 = page.locator('input[type="file"]').first();
await fileInput.setInputFiles('/path/to/image.png');
const fileInput = 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 page.keyboard.press('Meta+v'); // always verify with screenshot!
await 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 page.locator('[id="old-id"]').click(); // element may have changed
await page.locator('[id="old-id"]').click() // element may have changed
// GOOD: get fresh snapshot, then immediately use locators from it
await snapshot({ page, showDiffSinceLastCall: true })
@@ -293,26 +304,29 @@ await snapshot({ 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 });
await screenshotWithAccessibilityLabels({ 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 page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
await page.keyboard.type('Line 1\nLine 2') // becomes "Line 1Line 2"
// GOOD: use Enter key for line breaks
await page.keyboard.type('Line 1');
await page.keyboard.press('Enter');
await page.keyboard.type('Line 2');
await page.keyboard.type('Line 1')
await page.keyboard.press('Enter')
await 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 $'...'
playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()'
@@ -329,47 +343,50 @@ 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 page.screenshot({ path: 'check.png', scale: 'css' });
await page.screenshot({ path: 'check.png', scale: 'css' })
// GOOD: snapshot is text — fast, cheap, searchable
await snapshot({ page, search: /expected text/i })
// GOOD: evaluate DOM directly for content checks
const text = await page.evaluate(() => document.querySelector('.message')?.textContent);
const text = await page.evaluate(() => document.querySelector('.message')?.textContent)
```
**8. Assuming page content loaded**
Even after `goto()`, dynamic content may not be ready:
```js
await page.goto('https://example.com');
await page.goto('https://example.com')
// Content may still be loading via JavaScript!
await page.waitForSelector('article', { timeout: 10000 });
await page.waitForSelector('article', { timeout: 10000 })
// Or use waitForPageLoad utility
await waitForPageLoad({ page, timeout: 5000 });
await waitForPageLoad({ page, timeout: 5000 })
```
**9. 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 page.click('button:has-text("Login with Google")');
await page.click('button:has-text("Login with Google")')
// GOOD: cmd+click opens in new tab that playwriter can control
await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
await page.waitForTimeout(2000);
await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
await 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() === 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
```
@@ -379,10 +396,12 @@ After any action (click, submit, navigate), verify what happened. Always print U
```js
// Always print URL first, then snapshot
console.log('URL:', page.url()); await snapshot({ page }).then(console.log)
console.log('URL:', page.url())
await snapshot({ page }).then(console.log)
// Filter for specific content when snapshot is large
console.log('URL:', page.url()); await snapshot({ page, search: /dialog|button|error/i }).then(console.log)
console.log('URL:', page.url())
await snapshot({ page, search: /dialog|button|error/i }).then(console.log)
```
If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element.
@@ -437,11 +456,12 @@ const snap = await snapshot({ 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, 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, 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.
@@ -451,12 +471,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,8 +526,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.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
await state.myPage.goto('https://example.com');
state.myPage = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.myPage.goto('https://example.com')
// Use state.myPage for ALL subsequent operations
```
@@ -515,9 +537,9 @@ The user may close your page by accident (e.g., closing a tab in Chrome). Always
```js
if (!state.myPage || state.myPage.isClosed()) {
state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
state.myPage = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
}
await state.myPage.goto('https://example.com');
await state.myPage.goto('https://example.com')
```
**Use an existing page only when the user asks:**
@@ -525,16 +547,16 @@ await state.myPage.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
@@ -542,8 +564,8 @@ context.pages().map(p => p.url())
**Use `domcontentloaded`** for `page.goto()`:
```js
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await waitForPageLoad({ page, timeout: 5000 });
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page, timeout: 5000 })
```
## common patterns
@@ -556,9 +578,9 @@ await waitForPageLoad({ page, timeout: 5000 });
// GOOD: fetch inside page.evaluate uses browser's full session
const data = await 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:
@@ -566,18 +588,19 @@ const data = await page.evaluate(async (url) => {
```js
// Fetch protected data and trigger download to user's Downloads folder
await 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
@@ -588,37 +611,40 @@ 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 page.locator('a[target=_blank]').click({ modifiers: ['Meta'] });
await page.waitForTimeout(1000);
const pages = context.pages();
const newTab = pages[pages.length - 1];
console.log('New tab URL:', newTab.url());
await page.locator('a[target=_blank]').click({ modifiers: ['Meta'] })
await 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([page.waitForEvent('download'), page.click('button.download')]);
await download.saveAs(`./${download.suggestedFilename()}`);
const [download] = await Promise.all([page.waitForEvent('download'), page.click('button.download')])
await download.saveAs(`./${download.suggestedFilename()}`)
```
**iFrames** - two approaches depending on what you need:
```js
// frameLocator: for chaining locator operations (click, fill, etc.)
const frame = page.frameLocator('#my-iframe');
await frame.locator('button').click();
const frame = page.frameLocator('#my-iframe')
await frame.locator('button').click()
// contentFrame: returns a Frame object, needed for snapshot({ frame })
const frame2 = await page.locator('iframe').contentFrame();
const frame2 = await page.locator('iframe').contentFrame()
await snapshot({ frame: frame2 })
```
**Dialogs** - handle alerts/confirms/prompts:
```js
page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); });
await page.click('button.trigger-alert');
page.on('dialog', async (dialog) => {
console.log(dialog.message())
await dialog.accept()
})
await page.click('button.trigger-alert')
```
## utility functions
@@ -645,6 +671,7 @@ const fullHtml = await getCleanHTML({ locator: page, showDiffSinceLastCall: fals
```
**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.
@@ -652,12 +679,14 @@ const fullHtml = await getCleanHTML({ locator: page, showDiffSinceLastCall: fals
**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`)
@@ -672,6 +701,7 @@ const matches = await getPageMarkdown({ page, search: /API/i }) // search withi
```
**Output format:**
```
# Article Title
@@ -683,11 +713,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
@@ -702,46 +734,50 @@ await waitForPageLoad({ page, timeout?, pollInterval?, minWait? })
**getCDPSession** - send raw CDP commands:
```js
const cdp = await getCDPSession({ page });
const metrics = await cdp.send('Page.getLayoutMetrics');
const cdp = await getCDPSession({ page })
const metrics = await cdp.send('Page.getLayoutMetrics')
```
**getLocatorStringForElement** - get stable Playwright selector from an element:
```js
const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]'));
const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]'))
// => "getByRole('button', { name: 'Save' })"
```
**getReactSource** - get React component source location (dev mode only):
```js
const source = await getReactSource({ locator: page.locator('[data-testid="submit-btn"]') });
const source = await getReactSource({ locator: 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: page.locator('.btn'), cdp: await getCDPSession({ page }) });
console.log(formatStylesAsText(styles));
const styles = await getStylesForLocator({ locator: page.locator('.btn'), cdp: await getCDPSession({ 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 }); 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 })
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 }); 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 })
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.
@@ -749,15 +785,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 });
await screenshotWithAccessibilityLabels({ page })
// Image and accessibility snapshot are automatically included in response
// Use refs from snapshot to interact with elements
await page.locator('[id="submit-btn"]').click();
await page.locator('[id="submit-btn"]').click()
// Can take multiple screenshots in one execution
await screenshotWithAccessibilityLabels({ page });
await page.click('button');
await screenshotWithAccessibilityLabels({ page });
await screenshotWithAccessibilityLabels({ page })
await page.click('button')
await screenshotWithAccessibilityLabels({ page })
// Both images are included in the response
```
@@ -774,26 +810,27 @@ await startRecording({
outputPath: './recording.mp4',
frameRate: 30, // default: 30
audio: false, // default: false (tab audio)
videoBitsPerSecond: 2500000 // 2.5 Mbps
});
videoBitsPerSecond: 2500000, // 2.5 Mbps
})
// Navigate around - recording continues!
await page.click('a');
await page.waitForLoadState('domcontentloaded');
await page.goBack();
await page.click('a')
await page.waitForLoadState('domcontentloaded')
await page.goBack()
// Stop and get result
const { path, duration, size } = await stopRecording({ page });
console.log(`Saved ${size} bytes, duration: ${duration}ms`);
const { path, duration, size } = await stopRecording({ page })
console.log(`Saved ${size} bytes, duration: ${duration}ms`)
```
Additional recording utilities:
```js
// Check if recording is active
const { isRecording, startedAt } = await isRecording({ page });
const { isRecording, startedAt } = await isRecording({ page })
// Cancel recording without saving
await cancelRecording({ page });
await cancelRecording({ 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.
@@ -803,8 +840,8 @@ await cancelRecording({ 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 page.evaluateHandle(() => globalThis.playwriterPinnedElem1);
await el.click();
const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
await el.click()
```
## taking screenshots
@@ -812,7 +849,7 @@ await el.click();
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
```js
await page.screenshot({ path: 'shot.png', scale: 'css' });
await 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.
@@ -822,14 +859,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 page.evaluate(() => document.title);
console.log('Title:', title);
const title = await page.evaluate(() => document.title)
console.log('Title:', title)
const info = await page.evaluate(() => ({
url: location.href,
buttons: document.querySelectorAll('button').length,
}));
console.log(info);
}))
console.log(info)
```
## loading files
@@ -837,7 +874,9 @@ console.log(info);
Fill inputs with file content:
```js
const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'utf-8'); await page.locator('textarea').fill(content);
const fs = require('node:fs')
const content = fs.readFileSync('./data.txt', 'utf-8')
await page.locator('textarea').fill(content)
```
## network interception
@@ -845,31 +884,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 = [];
page.on('request', req => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }); });
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 = []
page.on('request', (req) => {
if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() })
})
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 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 page.evaluate(
async ({ url, headers }) => {
const res = await fetch(url, { headers })
return res.json()
},
{ url, headers },
)
console.log(data)
```
Clean up listeners when done: `page.removeAllListeners('request'); page.removeAllListeners('response');`
@@ -881,38 +935,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, search: /error|fail/i, count: 20 });
const appLogs = await getLatestLogs({ page, search: /myComponent|state/i });
const errors = await getLatestLogs({ page, search: /error|fail/i, count: 20 })
const appLogs = await getLatestLogs({ page, search: /myComponent|state/i })
```
**2. DOM inspection via evaluate** — check content directly without screenshots:
```js
const info = await 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 page.keyboard.press('Enter');
await page.waitForTimeout(2000);
await page.keyboard.press('Enter')
await page.waitForTimeout(2000)
const snap = await snapshot({ page, search: /dialog|error|message/ });
const logs = await getLatestLogs({ page, search: /error/i, count: 10 });
console.log('UI:', snap);
console.log('Logs:', logs);
const snap = await snapshot({ page, search: /dialog|error|message/ })
const logs = await getLatestLogs({ 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
@@ -922,7 +977,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.
@@ -936,7 +990,10 @@ This section covers low-level mouse/keyboard APIs not documented elsewhere. For
await page.locator('button[name="Submit"]').click()
await page.locator('text=Login').click({ button: 'right' })
await page.locator('text=Login').dblclick()
await page.locator('a').first().click({ modifiers: ['Meta'] }) // cmd+click opens new tab
await 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 page.mouse.click(450, 320) // left click
@@ -970,7 +1027,9 @@ await page.mouse.move(450, 320)
await page.mouse.wheel(0, 500)
// Scroll inside a container
await page.locator('.scrollable-list').evaluate(el => { el.scrollTop += 500 })
await page.locator('.scrollable-list').evaluate((el) => {
el.scrollTop += 500
})
```
### drag
@@ -1018,12 +1077,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:
+53 -78
View File
@@ -2,31 +2,31 @@
## Types
```ts
import type { ICDPSession } from './cdp-session.js';
````ts
import type { ICDPSession } from './cdp-session.js'
export interface BreakpointInfo {
id: string;
file: string;
line: number;
id: string
file: string
line: number
}
export interface LocationInfo {
url: string;
lineNumber: number;
columnNumber: number;
url: string
lineNumber: number
columnNumber: number
callstack: Array<{
functionName: string;
url: string;
lineNumber: number;
columnNumber: number;
}>;
sourceContext: string;
functionName: string
url: string
lineNumber: number
columnNumber: number
}>
sourceContext: string
}
export interface EvaluateResult {
value: unknown;
value: unknown
}
export interface ScriptInfo {
scriptId: string;
url: string;
scriptId: string
url: string
}
/**
* A class for debugging JavaScript code via Chrome DevTools Protocol.
@@ -45,14 +45,14 @@ export interface ScriptInfo {
* ```
*/
export declare class Debugger {
private cdp;
private debuggerEnabled;
private paused;
private currentCallFrames;
private breakpoints;
private scripts;
private xhrBreakpoints;
private blackboxPatterns;
private cdp
private debuggerEnabled
private paused
private currentCallFrames
private breakpoints
private scripts
private xhrBreakpoints
private blackboxPatterns
/**
* Creates a new Debugger instance.
*
@@ -66,10 +66,8 @@ export declare class Debugger {
* const dbg = new Debugger({ cdp })
* ```
*/
constructor({ cdp }: {
cdp: ICDPSession;
});
private setupEventListeners;
constructor({ cdp }: { cdp: ICDPSession })
private setupEventListeners
/**
* Enables the debugger and runtime domains. Called automatically by other methods.
* Also resumes execution if the target was started with --inspect-brk.
@@ -79,7 +77,7 @@ export declare class Debugger {
* await dbg.enable()
* ```
*/
enable(): Promise<void>;
enable(): Promise<void>
/**
* Sets a breakpoint at a specified URL and line number.
* Use the URL from listScripts() to find available scripts.
@@ -104,11 +102,7 @@ export declare class Debugger {
* })
* ```
*/
setBreakpoint({ file, line, condition }: {
file: string;
line: number;
condition?: string;
}): Promise<string>;
setBreakpoint({ file, line, condition }: { file: string; line: number; condition?: string }): Promise<string>
/**
* Removes a breakpoint by its ID.
*
@@ -120,9 +114,7 @@ export declare class Debugger {
* await dbg.deleteBreakpoint({ breakpointId: 'bp-123' })
* ```
*/
deleteBreakpoint({ breakpointId }: {
breakpointId: string;
}): Promise<void>;
deleteBreakpoint({ breakpointId }: { breakpointId: string }): Promise<void>
/**
* Returns a list of all active breakpoints set by this debugger instance.
*
@@ -134,7 +126,7 @@ export declare class Debugger {
* // [{ id: 'bp-123', file: 'https://example.com/index.js', line: 42 }]
* ```
*/
listBreakpoints(): BreakpointInfo[];
listBreakpoints(): BreakpointInfo[]
/**
* Inspects local variables in the current call frame.
* Must be paused at a breakpoint. String values over 1000 chars are truncated.
@@ -149,7 +141,7 @@ export declare class Debugger {
* // { myVar: 'hello', count: 42 }
* ```
*/
inspectLocalVariables(): Promise<Record<string, unknown>>;
inspectLocalVariables(): Promise<Record<string, unknown>>
/**
* Returns global lexical scope variable names.
*
@@ -161,7 +153,7 @@ export declare class Debugger {
* // ['myGlobal', 'CONFIG']
* ```
*/
inspectGlobalVariables(): Promise<string[]>;
inspectGlobalVariables(): Promise<string[]>
/**
* Evaluates a JavaScript expression and returns the result.
* When paused at a breakpoint, evaluates in the current stack frame scope,
@@ -181,9 +173,7 @@ export declare class Debugger {
* const full = await dbg.evaluate({ expression: 'largeStringVar' })
* ```
*/
evaluate({ expression }: {
expression: string;
}): Promise<EvaluateResult>;
evaluate({ expression }: { expression: string }): Promise<EvaluateResult>
/**
* Gets the current execution location when paused at a breakpoint.
* Includes the call stack and surrounding source code for context.
@@ -204,7 +194,7 @@ export declare class Debugger {
* // 43: }'
* ```
*/
getLocation(): Promise<LocationInfo>;
getLocation(): Promise<LocationInfo>
/**
* Steps over to the next line of code, not entering function calls.
*
@@ -216,7 +206,7 @@ export declare class Debugger {
* const newLocation = await dbg.getLocation()
* ```
*/
stepOver(): Promise<void>;
stepOver(): Promise<void>
/**
* Steps into a function call on the current line.
*
@@ -229,7 +219,7 @@ export declare class Debugger {
* // now inside the called function
* ```
*/
stepInto(): Promise<void>;
stepInto(): Promise<void>
/**
* Steps out of the current function, returning to the caller.
*
@@ -242,7 +232,7 @@ export declare class Debugger {
* // back in the calling function
* ```
*/
stepOut(): Promise<void>;
stepOut(): Promise<void>
/**
* Resumes code execution until the next breakpoint or completion.
*
@@ -254,7 +244,7 @@ export declare class Debugger {
* // execution continues
* ```
*/
resume(): Promise<void>;
resume(): Promise<void>
/**
* Returns whether the debugger is currently paused at a breakpoint.
*
@@ -267,7 +257,7 @@ export declare class Debugger {
* }
* ```
*/
isPaused(): boolean;
isPaused(): boolean
/**
* Configures the debugger to pause on exceptions.
*
@@ -286,9 +276,7 @@ export declare class Debugger {
* await dbg.setPauseOnExceptions({ state: 'none' })
* ```
*/
setPauseOnExceptions({ state }: {
state: 'none' | 'uncaught' | 'all';
}): Promise<void>;
setPauseOnExceptions({ state }: { state: 'none' | 'uncaught' | 'all' }): Promise<void>
/**
* Lists available scripts where breakpoints can be set.
* Automatically enables the debugger if not already enabled.
@@ -308,16 +296,10 @@ export declare class Debugger {
* // [{ scriptId: '5', url: 'https://example.com/handlers.js' }]
* ```
*/
listScripts({ search }?: {
search?: string;
}): Promise<ScriptInfo[]>;
setXHRBreakpoint({ url }: {
url: string;
}): Promise<void>;
removeXHRBreakpoint({ url }: {
url: string;
}): Promise<void>;
listXHRBreakpoints(): string[];
listScripts({ search }?: { search?: string }): Promise<ScriptInfo[]>
setXHRBreakpoint({ url }: { url: string }): Promise<void>
removeXHRBreakpoint({ url }: { url: string }): Promise<void>
listXHRBreakpoints(): string[]
/**
* Sets regex patterns for scripts to blackbox (skip when stepping).
* Blackboxed scripts are hidden from the call stack and stepped over automatically.
@@ -348,9 +330,7 @@ export declare class Debugger {
* await dbg.setBlackboxPatterns({ patterns: [] })
* ```
*/
setBlackboxPatterns({ patterns }: {
patterns: string[];
}): Promise<void>;
setBlackboxPatterns({ patterns }: { patterns: string[] }): Promise<void>
/**
* Adds a single regex pattern to the blackbox list.
*
@@ -363,27 +343,23 @@ export declare class Debugger {
* await dbg.addBlackboxPattern({ pattern: 'node_modules/axios' })
* ```
*/
addBlackboxPattern({ pattern }: {
pattern: string;
}): Promise<void>;
addBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
/**
* Removes a pattern from the blackbox list.
*
* @param options - Options
* @param options.pattern - The exact pattern string to remove
*/
removeBlackboxPattern({ pattern }: {
pattern: string;
}): Promise<void>;
removeBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
/**
* Returns the current list of blackbox patterns.
*/
listBlackboxPatterns(): string[];
private truncateValue;
private formatPropertyValue;
private processRemoteObject;
listBlackboxPatterns(): string[]
private truncateValue
private formatPropertyValue
private processRemoteObject
}
```
````
## Examples
@@ -454,5 +430,4 @@ async function cleanupBreakpoints() {
}
export { listScriptsAndSetBreakpoint, inspectWhenPaused, stepThroughCode, cleanupBreakpoints }
```
+49 -50
View File
@@ -4,22 +4,22 @@ The Editor class provides a Claude Code-like interface for viewing and editing w
## Types
```ts
import type { ICDPSession } from './cdp-session.js';
````ts
import type { ICDPSession } from './cdp-session.js'
export interface ReadResult {
content: string;
totalLines: number;
startLine: number;
endLine: number;
content: string
totalLines: number
startLine: number
endLine: number
}
export interface SearchMatch {
url: string;
lineNumber: number;
lineContent: string;
url: string
lineNumber: number
lineContent: string
}
export interface EditResult {
success: boolean;
stackChanged?: boolean;
success: boolean
stackChanged?: boolean
}
/**
* A class for viewing and editing web page scripts via Chrome DevTools Protocol.
@@ -49,22 +49,20 @@ export interface EditResult {
* ```
*/
export declare class Editor {
private cdp;
private enabled;
private scripts;
private stylesheets;
private sourceCache;
constructor({ cdp }: {
cdp: ICDPSession;
});
private setupEventListeners;
private cdp
private enabled
private scripts
private stylesheets
private sourceCache
constructor({ cdp }: { cdp: ICDPSession })
private setupEventListeners
/**
* Enables the editor. Must be called before other methods.
* Scripts are collected from Debugger.scriptParsed events.
* Reload the page after enabling to capture all scripts.
*/
enable(): Promise<void>;
private getIdByUrl;
enable(): Promise<void>
private getIdByUrl
/**
* Lists available script and stylesheet URLs. Use pattern to filter by regex.
* Automatically enables the editor if not already enabled.
@@ -88,9 +86,7 @@ export declare class Editor {
* const appScripts = await editor.list({ pattern: /app/ })
* ```
*/
list({ pattern }?: {
pattern?: RegExp;
}): Promise<string[]>;
list({ pattern }?: { pattern?: RegExp }): Promise<string[]>
/**
* Reads a script or stylesheet's source code by URL.
* Returns line-numbered content like Claude Code's Read tool.
@@ -120,12 +116,8 @@ export declare class Editor {
* })
* ```
*/
read({ url, offset, limit }: {
url: string;
offset?: number;
limit?: number;
}): Promise<ReadResult>;
private getSource;
read({ url, offset, limit }: { url: string; offset?: number; limit?: number }): Promise<ReadResult>
private getSource
/**
* Edits a script or stylesheet by replacing oldString with newString.
* Like Claude Code's Edit tool - performs exact string replacement.
@@ -154,13 +146,18 @@ export declare class Editor {
* })
* ```
*/
edit({ url, oldString, newString, dryRun, }: {
url: string;
oldString: string;
newString: string;
dryRun?: boolean;
}): Promise<EditResult>;
private setSource;
edit({
url,
oldString,
newString,
dryRun,
}: {
url: string
oldString: string
newString: string
dryRun?: boolean
}): Promise<EditResult>
private setSource
/**
* Searches for a regex across all scripts and stylesheets.
* Like Claude Code's Grep tool - returns matching lines with context.
@@ -188,10 +185,7 @@ export declare class Editor {
* })
* ```
*/
grep({ regex, pattern }: {
regex: RegExp;
pattern?: RegExp;
}): Promise<SearchMatch[]>;
grep({ regex, pattern }: { regex: RegExp; pattern?: RegExp }): Promise<SearchMatch[]>
/**
* Writes entire content to a script or stylesheet, replacing all existing code.
* Use with caution - prefer edit() for targeted changes.
@@ -201,13 +195,9 @@ export declare class Editor {
* @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/
write({ url, content, dryRun }: {
url: string;
content: string;
dryRun?: boolean;
}): Promise<EditResult>;
write({ url, content, dryRun }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult>
}
```
````
## Examples
@@ -359,6 +349,15 @@ 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,
}
```
+32 -22
View File
@@ -5,32 +5,36 @@ The getStylesForLocator function inspects CSS styles applied to an element, simi
## Types
```ts
import type { ICDPSession } from './cdp-session.js';
import type { Locator } from '@xmorse/playwright-core';
import type { ICDPSession } from './cdp-session.js'
import type { Locator } from '@xmorse/playwright-core'
export interface StyleSource {
url: string;
line: number;
column: number;
url: string
line: number
column: number
}
export type StyleDeclarations = Record<string, string>;
export type StyleDeclarations = Record<string, string>
export interface StyleRule {
selector: string;
source: StyleSource | null;
origin: 'regular' | 'user-agent' | 'injected' | 'inspector';
declarations: StyleDeclarations;
inheritedFrom: string | null;
selector: string
source: StyleSource | null
origin: 'regular' | 'user-agent' | 'injected' | 'inspector'
declarations: StyleDeclarations
inheritedFrom: string | null
}
export interface StylesResult {
element: string;
inlineStyle: StyleDeclarations | null;
rules: StyleRule[];
element: string
inlineStyle: StyleDeclarations | null
rules: StyleRule[]
}
export declare function getStylesForLocator({ locator, cdp: cdpSession, includeUserAgentStyles, }: {
locator: Locator;
cdp: ICDPSession;
includeUserAgentStyles?: boolean;
}): Promise<StylesResult>;
export declare function formatStylesAsText(styles: StylesResult): string;
export declare function getStylesForLocator({
locator,
cdp: cdpSession,
includeUserAgentStyles,
}: {
locator: Locator
cdp: ICDPSession
includeUserAgentStyles?: boolean
}): Promise<StylesResult>
export declare function formatStylesAsText(styles: StylesResult): string
```
## Examples
@@ -112,6 +116,12 @@ async function compareStyles() {
console.log(formatStylesAsText(secondary))
}
export { getElementStyles, inspectButtonStyles, getStylesWithUserAgent, findPropertySource, checkInheritedStyles, compareStyles }
export {
getElementStyles,
inspectButtonStyles,
getStylesWithUserAgent,
findPropertySource,
checkInheritedStyles,
compareStyles,
}
```
+5 -6
View File
@@ -1,10 +1,9 @@
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from '@vercel/react-router/vite';
import type { Config } from '@react-router/dev/config'
import { vercelPreset } from '@vercel/react-router/vite'
export default {
appDirectory: "src",
appDirectory: 'src',
ssr: true,
presets: [vercelPreset()],
prerender: ["/"],
} satisfies Config;
prerender: ['/'],
} satisfies Config
+27 -32
View File
@@ -17,59 +17,54 @@
* Usage: tsx website/scripts/generate-placeholders.ts
*/
import sharp from "sharp";
import path from "node:path";
import fs from "node:fs";
import sharp from 'sharp'
import path from 'node:path'
import fs from 'node:fs'
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
const OUTPUT_DIR = path.resolve(import.meta.dirname, "../src/assets/placeholders");
const PLACEHOLDER_PREFIX = "placeholder-";
const PLACEHOLDER_WIDTH = 64;
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const PUBLIC_DIR = path.resolve(import.meta.dirname, '../public')
const OUTPUT_DIR = path.resolve(import.meta.dirname, '../src/assets/placeholders')
const PLACEHOLDER_PREFIX = 'placeholder-'
const PLACEHOLDER_WIDTH = 64
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
const entries = fs.readdirSync(PUBLIC_DIR);
const entries = fs.readdirSync(PUBLIC_DIR)
const images = entries.filter((name) => {
if (name.startsWith(PLACEHOLDER_PREFIX)) {
return false;
return false
}
const ext = path.extname(name).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
});
const ext = path.extname(name).toLowerCase()
return IMAGE_EXTENSIONS.has(ext)
})
if (images.length === 0) {
console.error("No images found in public/");
return;
console.error('No images found in public/')
return
}
for (const name of images) {
const inputPath = path.join(PUBLIC_DIR, name);
const ext = path.extname(name);
const base = path.basename(name, ext);
const outputPath = path.join(OUTPUT_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`);
const inputPath = path.join(PUBLIC_DIR, name)
const ext = path.extname(name)
const base = path.basename(name, ext)
const outputPath = path.join(OUTPUT_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`)
// Skip if placeholder already exists and is newer than source
if (fs.existsSync(outputPath)) {
const srcMtime = fs.statSync(inputPath).mtimeMs;
const outMtime = fs.statSync(outputPath).mtimeMs;
const srcMtime = fs.statSync(inputPath).mtimeMs
const outMtime = fs.statSync(outputPath).mtimeMs
if (outMtime > srcMtime) {
continue;
continue
}
}
await sharp(inputPath)
.resize(PLACEHOLDER_WIDTH)
.png({ compressionLevel: 9 })
.toFile(outputPath);
await sharp(inputPath).resize(PLACEHOLDER_WIDTH).png({ compressionLevel: 9 }).toFile(outputPath)
const stats = fs.statSync(outputPath);
console.error(
`Generated ${PLACEHOLDER_PREFIX}${base}${ext} (${PLACEHOLDER_WIDTH}px wide, ${stats.size} bytes)`,
);
const stats = fs.statSync(outputPath)
console.error(`Generated ${PLACEHOLDER_PREFIX}${base}${ext} (${PLACEHOLDER_WIDTH}px wide, ${stats.size} bytes)`)
}
}
main();
main()
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,7 +1,7 @@
import { startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from 'react-router/dom'
startTransition(() => {
hydrateRoot(document, <HydratedRouter />);
});
hydrateRoot(document, <HydratedRouter />)
})
+56 -74
View File
@@ -1,17 +1,12 @@
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import type {
ActionFunctionArgs,
AppLoadContext,
EntryContext,
LoaderFunctionArgs,
} from "react-router";
import { isRouteErrorResponse, ServerRouter } from "react-router";
import { notifyError } from "./lib/errors";
import { PassThrough } from 'node:stream'
import { createReadableStreamFromReadable } from '@react-router/node'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import type { ActionFunctionArgs, AppLoadContext, EntryContext, LoaderFunctionArgs } from 'react-router'
import { isRouteErrorResponse, ServerRouter } from 'react-router'
import { notifyError } from './lib/errors'
export const streamTimeout = 60 * 20 * 1_000;
export const streamTimeout = 60 * 20 * 1_000
export default function handleRequest(
request: Request,
@@ -21,32 +16,22 @@ export default function handleRequest(
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
loadContext: AppLoadContext,
) {
return isbot(request.headers.get("user-agent") || "")
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
reactRouterContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
reactRouterContext
);
return isbot(request.headers.get('user-agent') || '')
? handleBotRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
: handleBrowserRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext
reactRouterContext: EntryContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let timeoutId: NodeJS.Timeout;
let shellRendered = false
let timeoutId: NodeJS.Timeout
const { pipe, abort } = renderToPipeableStream(
<ServerRouter
context={reactRouterContext}
@@ -55,51 +40,51 @@ function handleBotRequest(
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set('Content-Type', 'text/html')
clearTimeout(timeoutId);
clearTimeout(timeoutId)
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
}),
)
pipe(body);
pipe(body)
},
onShellError(error: unknown) {
clearTimeout(timeoutId);
reject(error);
clearTimeout(timeoutId)
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500;
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
console.error(error)
}
},
}
);
},
)
timeoutId = setTimeout(abort, streamTimeout);
});
timeoutId = setTimeout(abort, streamTimeout)
})
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext
reactRouterContext: EntryContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let timeoutId: NodeJS.Timeout;
let shellRendered = false
let timeoutId: NodeJS.Timeout
const { pipe, abort } = renderToPipeableStream(
<ServerRouter
context={reactRouterContext}
@@ -108,56 +93,53 @@ function handleBrowserRequest(
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set('Content-Type', 'text/html')
clearTimeout(timeoutId);
clearTimeout(timeoutId)
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
}),
)
pipe(body);
pipe(body)
},
onShellError(error: unknown) {
clearTimeout(timeoutId);
reject(error);
clearTimeout(timeoutId)
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500;
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
console.error(error)
}
},
}
);
},
)
timeoutId = setTimeout(abort, streamTimeout);
});
timeoutId = setTimeout(abort, streamTimeout)
})
}
export function handleError(
error: any,
{ request, params, context }: LoaderFunctionArgs | ActionFunctionArgs
) {
export function handleError(error: any, { request, params, context }: LoaderFunctionArgs | ActionFunctionArgs) {
// https://github.com/remix-run/remix/discussions/8933
if (request.signal.aborted || isRouteErrorResponse(error)) {
return;
return
}
if (error?.["status"] === 404) {
return;
if (error?.['status'] === 404) {
return
}
if (error instanceof Error) {
notifyError(error, "unhandled remix error");
notifyError(error, 'unhandled remix error')
} else {
console.error("error", error);
console.error('error', error)
}
}
+14 -14
View File
@@ -1,34 +1,34 @@
import { captureException, flush, init } from "sentries";
import { captureException, flush, init } from 'sentries'
init({
dsn: "https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168",
dsn: 'https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168',
integrations: [],
tracesSampleRate: 0.01,
profilesSampleRate: 0.01,
beforeSend(event) {
if (process.env.NODE_ENV === "development") {
return null;
if (process.env.NODE_ENV === 'development') {
return null
}
if (process.env.BYTECODE_RUN) {
return null;
return null
}
if (event?.["name"] === "AbortError") {
return null;
if (event?.['name'] === 'AbortError') {
return null
}
return event;
return event
},
});
})
export async function notifyError(error: any, msg?: string) {
console.error(msg, error);
captureException(error, { extra: { msg } });
await flush(1000);
console.error(msg, error)
captureException(error, { extra: { msg } })
await flush(1000)
}
export class AppError extends Error {
constructor(message: string) {
super(message);
this.name = "AppError";
super(message)
this.name = 'AppError'
}
}
+5 -5
View File
@@ -1,10 +1,10 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}
+30 -45
View File
@@ -1,40 +1,26 @@
import "website/src/styles/globals.css";
import type { LinksFunction } from "react-router";
import { Route } from "./+types/root";
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import 'website/src/styles/globals.css'
import type { LinksFunction } from 'react-router'
import { Route } from './+types/root'
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
export const links: LinksFunction = () => [
{ rel: "icon", type: "image/png", href: "/favicon-32.png", sizes: "32x32" },
{ rel: "icon", type: "image/png", href: "/favicon-16.png", sizes: "16x16" },
];
{ rel: 'icon', type: 'image/png', href: '/favicon-32.png', sizes: '32x32' },
{ rel: 'icon', type: 'image/png', href: '/favicon-16.png', sizes: '16x16' },
]
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang='en'>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<meta charSet='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='preconnect' href='https://fonts.googleapis.com' />
<link rel='preconnect' href='https://fonts.gstatic.com' crossOrigin='' />
{/* Inter from rsms (same source as next/font) for weight fidelity */}
<link href='https://rsms.me/inter/inter.css' rel='stylesheet' />
<link
href="https://rsms.me/inter/inter.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap"
rel="stylesheet"
href='https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap'
rel='stylesheet'
/>
<Meta />
<Links />
@@ -45,40 +31,39 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Scripts />
</body>
</html>
);
)
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
<h1 className="text-4xl font-bold mb-4">
<div className='flex flex-col items-center justify-center min-h-screen p-8 text-center'>
<h1 className='text-4xl font-bold mb-4'>
{error.status} {error.statusText}
</h1>
<p className="text-lg text-gray-600">{error.data}</p>
<p className='text-lg text-gray-600'>{error.data}</p>
</div>
);
)
} else if (error instanceof Error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-4">Error</h1>
<p className="text-lg text-gray-600 mb-6">{error.message}</p>
<p className="text-sm text-gray-500 mb-2">The stack trace is:</p>
<pre className="bg-gray-100 p-4 rounded-lg text-xs text-left overflow-auto max-w-full border">
<div className='flex flex-col items-center justify-center min-h-screen p-8 text-center max-w-4xl mx-auto'>
<h1 className='text-4xl font-bold mb-4'>Error</h1>
<p className='text-lg text-gray-600 mb-6'>{error.message}</p>
<p className='text-sm text-gray-500 mb-2'>The stack trace is:</p>
<pre className='bg-gray-100 p-4 rounded-lg text-xs text-left overflow-auto max-w-full border'>
{error.stack}
</pre>
</div>
);
)
} else {
return (
<div className="flex items-center justify-center min-h-screen">
<h1 className="text-4xl font-bold text-center">Unknown Error</h1>
<div className='flex items-center justify-center min-h-screen'>
<h1 className='text-4xl font-bold text-center'>Unknown Error</h1>
</div>
);
)
}
}
export default function App() {
return <Outlet />;
return <Outlet />
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
import type { RouteConfig } from '@react-router/dev/routes'
import { flatRoutes } from '@react-router/fs-routes'
export default flatRoutes() satisfies RouteConfig;
export default flatRoutes() satisfies RouteConfig
+176 -249
View File
@@ -4,8 +4,8 @@
* Styles from globals.css (editorial tokens) and editorial-prism.css.
*/
import type { MetaFunction } from "react-router";
import dedent from "string-dedent";
import type { MetaFunction } from 'react-router'
import dedent from 'string-dedent'
import {
EditorialPage,
P,
@@ -19,150 +19,134 @@ import {
OL,
Li,
PixelatedImage,
} from "website/src/components/markdown";
import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png";
} from 'website/src/components/markdown'
import placeholderScreenshot from '../assets/placeholders/placeholder-screenshot@2x.png'
export const meta: MetaFunction = () => {
const title = "Playwriter - Control your Chrome with Playwright API";
const title = 'Playwriter - Control your Chrome with Playwright API'
const description =
"Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat.";
const image = "https://playwriter.dev/og-image.png";
'Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat.'
const image = 'https://playwriter.dev/og-image.png'
return [
{ title },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: image },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:type", content: "website" },
{ property: "og:url", content: "https://playwriter.dev" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
{ name: "twitter:image", content: image },
];
};
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:image', content: image },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: 'https://playwriter.dev' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: title },
{ name: 'twitter:description', content: description },
{ name: 'twitter:image', content: image },
]
}
const tocItems = [
{ label: "Getting started", href: "#getting-started" },
{ label: "How it works", href: "#how-it-works" },
{ label: "Collaboration", href: "#collaboration" },
{ label: "Snapshots", href: "#snapshots" },
{ label: "Visual labels", href: "#visual-labels" },
{ label: "Sessions", href: "#sessions" },
{ label: "Debugger & editor", href: "#debugger-and-editor" },
{ label: "Network interception", href: "#network-interception" },
{ label: "Screen recording", href: "#screen-recording" },
{ label: "Comparison", href: "#comparison" },
{ label: "Remote access", href: "#remote-access" },
{ label: "Security", href: "#security" },
];
{ label: 'Getting started', href: '#getting-started' },
{ label: 'How it works', href: '#how-it-works' },
{ label: 'Collaboration', href: '#collaboration' },
{ label: 'Snapshots', href: '#snapshots' },
{ label: 'Visual labels', href: '#visual-labels' },
{ label: 'Sessions', href: '#sessions' },
{ label: 'Debugger & editor', href: '#debugger-and-editor' },
{ label: 'Network interception', href: '#network-interception' },
{ label: 'Screen recording', href: '#screen-recording' },
{ label: 'Comparison', href: '#comparison' },
{ label: 'Remote access', href: '#remote-access' },
{ label: 'Security', href: '#security' },
]
export default function IndexPage() {
return (
<EditorialPage toc={tocItems} logo="playwriter">
<EditorialPage toc={tocItems} logo='playwriter'>
<P>
You want your agent to control the browser. <strong>Your actual
Chrome</strong> {" \u2014 "} with logins, extensions, and cookies already
there. Not a headless instance that gets blocked by every captcha
and bot detector.{" "}
<A href="https://github.com/remorses/playwriter">Star on GitHub</A>.
You want your agent to control the browser. <strong>Your actual Chrome</strong> {' \u2014 '} with logins,
extensions, and cookies already there. Not a headless instance that gets blocked by every captcha and bot
detector. <A href='https://github.com/remorses/playwriter'>Star on GitHub</A>.
</P>
<div className="bleed" style={{ display: "flex", justifyContent: "center" }}>
<div className='bleed' style={{ display: 'flex', justifyContent: 'center' }}>
<PixelatedImage
src="/screenshot@2x.png"
src='/screenshot@2x.png'
placeholder={placeholderScreenshot}
alt="Playwriter controlling Chrome with accessibility labels overlay"
alt='Playwriter controlling Chrome with accessibility labels overlay'
width={1280}
height={800}
style={{ display: "block", maxWidth: "100%", height: "auto" }}
style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
/>
</div>
<Caption>
Your existing Chrome session. Extensions, logins, cookies {" \u2014 "} all there.
</Caption>
<Caption>Your existing Chrome session. Extensions, logins, cookies {' \u2014 '} all there.</Caption>
<P>
Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents
a fixed set of tools. New Chrome means no logins, no extensions,
instant bot detection, and double the memory. Fixed tools mean the
agent can{"'"}t profile performance, can{"'"}t set breakpoints,
can{"'"}t intercept network requests {" \u2014 "} it can only do what someone
decided to expose.
Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents a fixed set of tools. New Chrome
means no logins, no extensions, instant bot detection, and double the memory. Fixed tools mean the agent can
{"'"}t profile performance, can{"'"}t set breakpoints, can{"'"}t intercept network requests {' \u2014 '} it can
only do what someone decided to expose.
</P>
<P>
Playwriter gives agents the <strong>full Playwright API</strong> through
a single <Code>execute</Code> tool. One tool, any Playwright code,
no wrappers. Low context usage because there{"'"}s no schema bloat
from dozens of tool definitions. And it runs in your existing browser,
so <strong>nothing extra gets spawned</strong>.
Playwriter gives agents the <strong>full Playwright API</strong> through a single <Code>execute</Code> tool. One
tool, any Playwright code, no wrappers. Low context usage because there{"'"}s no schema bloat from dozens of
tool definitions. And it runs in your existing browser, so <strong>nothing extra gets spawned</strong>.
</P>
<Section id="getting-started" title="Getting started">
<Section id='getting-started' title='Getting started'>
<P>
<strong>Four steps</strong> and your agent is browsing.
</P>
<OL>
<Li>
Install the{" "}
<A href="https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe">Chrome extension</A>
Install the{' '}
<A href='https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe'>
Chrome extension
</A>
</Li>
<Li>Click the extension icon on a tab {" \u2014 "} it turns green</Li>
<Li>Click the extension icon on a tab {' \u2014 '} it turns green</Li>
<Li>Install the CLI:</Li>
</OL>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
npm i -g playwriter
`}</CodeBlock>
<P>
Then install the <strong>skill</strong> {" \u2014 "} it teaches your agent how to use
Playwriter: which selectors to use, how to avoid timeouts, how to
read snapshots, and all available utilities.
Then install the <strong>skill</strong> {' \u2014 '} it teaches your agent how to use Playwriter: which
selectors to use, how to avoid timeouts, how to read snapshots, and all available utilities.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
npx -y skills add remorses/playwriter
`}</CodeBlock>
<P>
The extension connects your browser to a <strong>local WebSocket relay</strong> on{" "}
<Code>localhost:19988</Code>. The CLI sends Playwright
code through the relay. No remote servers, no accounts, nothing
leaves your machine.
The extension connects your browser to a <strong>local WebSocket relay</strong> on{' '}
<Code>localhost:19988</Code>. The CLI sends Playwright code through the relay. No remote servers, no accounts,
nothing leaves your machine.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter session new # new sandbox, outputs id (e.g. 1)
playwriter -s 1 -e "await page.goto('https://example.com')"
playwriter -s 1 -e "console.log(await snapshot({ page }))"
playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"
`}</CodeBlock>
<Caption>
Extension icon green = connected. Gray = not attached to this tab.
</Caption>
<Caption>Extension icon green = connected. Gray = not attached to this tab.</Caption>
</Section>
<Section id="how-it-works" title="How it works">
<Section id='how-it-works' title='How it works'>
<P>
Click the extension icon on a tab {" \u2014 "} it attaches via{" "}
<Code>chrome.debugger</Code> and opens a WebSocket to a
local relay. Your agent (CLI, MCP, or a Playwright script) connects
to the same relay. <strong>CDP commands flow through</strong>; the extension
forwards them to Chrome and sends responses back. No Chrome restart,
no flags, no special setup.
Click the extension icon on a tab {' \u2014 '} it attaches via <Code>chrome.debugger</Code> and opens a
WebSocket to a local relay. Your agent (CLI, MCP, or a Playwright script) connects to the same relay.{' '}
<strong>CDP commands flow through</strong>; the extension forwards them to Chrome and sends responses back. No
Chrome restart, no flags, no special setup.
</P>
<CodeBlock lang="bash" lineHeight="1.3">{dedent`
<CodeBlock lang='bash' lineHeight='1.3'>{dedent`
BROWSER LOCALHOST CLIENT
@@ -180,42 +164,35 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
The relay <strong>multiplexes sessions</strong>, so multiple agents
or CLI instances can work with the same browser at the same time.
The relay <strong>multiplexes sessions</strong>, so multiple agents or CLI instances can work with the same
browser at the same time.
</P>
</Section>
<Section id="collaboration" title="Collaboration">
<Section id='collaboration' title='Collaboration'>
<P>
Because the agent works in <strong>your browser</strong>, you can
collaborate. You see everything it does in real time. When it hits
a captcha, <strong>you solve it</strong>. When a consent wall
appears, you click through it. When the agent gets stuck, you
disable the extension on that tab, fix things manually, re-enable
Because the agent works in <strong>your browser</strong>, you can collaborate. You see everything it does in
real time. When it hits a captcha, <strong>you solve it</strong>. When a consent wall appears, you click
through it. When the agent gets stuck, you disable the extension on that tab, fix things manually, re-enable
it, and the agent picks up where it left off.
</P>
<P>
You{"'"}re not watching a remote screen or reading logs after the
fact. You{"'"}re <strong>sharing a browser</strong> {" \u2014 "} the
agent does the repetitive work, you step in when it needs a human.
You{"'"}re not watching a remote screen or reading logs after the fact. You{"'"}re{' '}
<strong>sharing a browser</strong> {' \u2014 '} the agent does the repetitive work, you step in when it needs
a human.
</P>
</Section>
<Section id="snapshots" title="Accessibility snapshots">
<Section id='snapshots' title='Accessibility snapshots'>
<P>
Your agent needs to <strong>see the page</strong> before it can act.
Accessibility snapshots return every interactive element as text,
with Playwright locators attached. <strong>5{"\u2013"}20KB instead of
100KB+</strong> for a screenshot {" \u2014 "} cheaper, faster, and the
Your agent needs to <strong>see the page</strong> before it can act. Accessibility snapshots return every
interactive element as text, with Playwright locators attached.{' '}
<strong>5{'\u2013'}20KB instead of 100KB+</strong> for a screenshot {' \u2014 '} cheaper, faster, and the
agent can parse them without vision.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "await snapshot({ page })"
# Output:
@@ -227,13 +204,12 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Each line ends with a <strong>locator</strong> you can pass directly to{" "}
<Code>page.locator()</Code>. Subsequent calls return a
<strong> diff</strong>, so you only see what changed. Use{" "}
<Code>search</Code> to filter large pages.
Each line ends with a <strong>locator</strong> you can pass directly to <Code>page.locator()</Code>.
Subsequent calls return a<strong> diff</strong>, so you only see what changed. Use <Code>search</Code> to
filter large pages.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Search for specific elements
playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
@@ -242,25 +218,19 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Use snapshots as the <strong>primary way to read pages</strong>. Only
reach for screenshots when spatial layout matters {" \u2014 "} grids,
dashboards, maps.
Use snapshots as the <strong>primary way to read pages</strong>. Only reach for screenshots when spatial
layout matters {' \u2014 '} grids, dashboards, maps.
</P>
</Section>
<Section id="visual-labels" title="Visual labels">
<Section id='visual-labels' title='Visual labels'>
<P>
When the agent needs to understand <strong>where things are on
screen</strong>,{" "}
<Code>screenshotWithAccessibilityLabels</Code> overlays{" "}
<strong>Vimium-style labels</strong> on every interactive element.
The agent sees the screenshot, reads the labels, and clicks by
reference.
When the agent needs to understand <strong>where things are on screen</strong>,{' '}
<Code>screenshotWithAccessibilityLabels</Code> overlays <strong>Vimium-style labels</strong> on every
interactive element. The agent sees the screenshot, reads the labels, and clicks by reference.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })"
# Returns screenshot + accessibility snapshot with aria-ref selectors
@@ -268,29 +238,22 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Labels are <strong>color-coded by element type</strong>: yellow for links, orange for
buttons, coral for inputs, pink for checkboxes, peach for sliders,
salmon for menus, amber for tabs. The ref system is shared with{" "}
<Code>snapshot()</Code>, so you can switch between text
and visual modes freely.
Labels are <strong>color-coded by element type</strong>: yellow for links, orange for buttons, coral for
inputs, pink for checkboxes, peach for sliders, salmon for menus, amber for tabs. The ref system is shared
with <Code>snapshot()</Code>, so you can switch between text and visual modes freely.
</P>
<Caption>
Vimium-style labels. Screenshot + snapshot in one call.
</Caption>
<Caption>Vimium-style labels. Screenshot + snapshot in one call.</Caption>
</Section>
<Section id="sessions" title="Sessions">
<Section id='sessions' title='Sessions'>
<P>
Run <strong>multiple agents at once</strong> without them stepping on
each other. Each session is an isolated sandbox with its own{" "}
<Code>state</Code> object. Variables, pages, and listeners
persist between calls. Browser tabs are shared, but state is not.
Run <strong>multiple agents at once</strong> without them stepping on each other. Each session is an isolated
sandbox with its own <Code>state</Code> object. Variables, pages, and listeners persist between calls. Browser
tabs are shared, but state is not.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter session new # => 1
playwriter session new # => 2
playwriter session list # shows sessions + state keys
@@ -303,30 +266,26 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Create your own page to <strong>avoid interference</strong> from other agents. Reuse
an existing <Code>about:blank</Code> tab or create a
fresh one, and store it in <Code>state</Code>.
Create your own page to <strong>avoid interference</strong> from other agents. Reuse an existing{' '}
<Code>about:blank</Code> tab or create a fresh one, and store it in <Code>state</Code>.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')"
# All subsequent calls use state.myPage
playwriter -s 1 -e "console.log(await state.myPage.title())"
`}</CodeBlock>
</Section>
<Section id="debugger-and-editor" title="Debugger & editor">
<Section id='debugger-and-editor' title='Debugger & editor'>
<P>
Things no other browser MCP can do. <strong>Set breakpoints</strong>,
step through code, inspect variables at runtime. <strong>Live-edit
page scripts and CSS</strong> without reloading. Full Chrome DevTools
Protocol access, not a watered-down subset.
Things no other browser MCP can do. <strong>Set breakpoints</strong>, step through code, inspect variables at
runtime. <strong>Live-edit page scripts and CSS</strong> without reloading. Full Chrome DevTools Protocol
access, not a watered-down subset.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Set breakpoints and debug
playwriter -s 1 -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()"
playwriter -s 1 -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))"
@@ -338,28 +297,21 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for
toggling debug flags, patching broken code, or testing quick fixes
without touching source files. The editor also supports{" "}
Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for toggling debug flags,
patching broken code, or testing quick fixes without touching source files. The editor also supports{' '}
<Code>grep</Code> across all loaded scripts.
</P>
<Caption>
Breakpoints, stepping, variable inspection {" \u2014 "} from the CLI.
</Caption>
<Caption>Breakpoints, stepping, variable inspection {' \u2014 '} from the CLI.</Caption>
</Section>
<Section id="network-interception" title="Network interception">
<Section id='network-interception' title='Network interception'>
<P>
Let the agent <strong>watch network traffic</strong> to
reverse-engineer APIs, scrape data behind JavaScript rendering,
or debug failing requests. Captured data lives in{" "}
<Code>state</Code> and persists across calls.
Let the agent <strong>watch network traffic</strong> to reverse-engineer APIs, scrape data behind JavaScript
rendering, or debug failing requests. Captured data lives in <Code>state</Code> and persists across calls.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Start intercepting
playwriter -s 1 -e "state.responses = []; 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 {} } })"
@@ -372,24 +324,20 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
<strong>Faster than scraping the DOM.</strong> The agent captures the
real API calls, inspects their schemas, and replays them with
different parameters. Works for pagination, authenticated endpoints,
and anything behind client-side rendering.
<strong>Faster than scraping the DOM.</strong> The agent captures the real API calls, inspects their schemas,
and replays them with different parameters. Works for pagination, authenticated endpoints, and anything behind
client-side rendering.
</P>
</Section>
<Section id="screen-recording" title="Screen recording">
<Section id='screen-recording' title='Screen recording'>
<P>
Have the agent <strong>record what it{"'"}s doing</strong> as MP4
video. The recording uses <Code>chrome.tabCapture</Code> and
runs in the extension context, so it <strong>survives page
navigation</strong>.
Have the agent <strong>record what it{"'"}s doing</strong> as MP4 video. The recording uses{' '}
<Code>chrome.tabCapture</Code> and runs in the extension context, so it{' '}
<strong>survives page navigation</strong>.
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# Start recording
playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })"
@@ -403,75 +351,64 @@ export default function IndexPage() {
<P>
Unlike <Code>getDisplayMedia</Code>, this approach
<strong> persists across navigations</strong> because the extension holds the{" "}
<Code>MediaRecorder</Code>, not the page. You can also
check recording status with <Code>isRecording</Code> or
cancel without saving with <Code>cancelRecording</Code>.
<strong> persists across navigations</strong> because the extension holds the <Code>MediaRecorder</Code>, not
the page. You can also check recording status with <Code>isRecording</Code> or cancel without saving with{' '}
<Code>cancelRecording</Code>.
</P>
<Caption>
Native tab capture. 30{"\u2013"}60fps. Survives navigation.
</Caption>
<Caption>Native tab capture. 30{'\u2013'}60fps. Survives navigation.</Caption>
</Section>
<Section id="comparison" title="Comparison">
<P>
Why use this over the alternatives.
</P>
<Section id='comparison' title='Comparison'>
<P>Why use this over the alternatives.</P>
<ComparisonTable
title="vs Playwright MCP"
headers={["", "Playwright MCP", "Playwriter"]}
title='vs Playwright MCP'
headers={['', 'Playwright MCP', 'Playwriter']}
rows={[
["Browser", "Spawns new Chrome", "Uses your Chrome"],
["Extensions", "None", "Your existing ones"],
["Login state", "Fresh", "Already logged in"],
["Bot detection", "Always detected", "Can bypass"],
["Collaboration", "Separate window", "Same browser as user"],
['Browser', 'Spawns new Chrome', 'Uses your Chrome'],
['Extensions', 'None', 'Your existing ones'],
['Login state', 'Fresh', 'Already logged in'],
['Bot detection', 'Always detected', 'Can bypass'],
['Collaboration', 'Separate window', 'Same browser as user'],
]}
/>
<ComparisonTable
title="vs BrowserMCP"
headers={["", "BrowserMCP", "Playwriter"]}
title='vs BrowserMCP'
headers={['', 'BrowserMCP', 'Playwriter']}
rows={[
["Tools", "12+ dedicated tools", "1 execute tool"],
["API", "Limited actions", "Full Playwright"],
["Context usage", "High (tool schemas)", "Low"],
["LLM knowledge", "Must learn tools", "Already knows Playwright"],
['Tools', '12+ dedicated tools', '1 execute tool'],
['API', 'Limited actions', 'Full Playwright'],
['Context usage', 'High (tool schemas)', 'Low'],
['LLM knowledge', 'Must learn tools', 'Already knows Playwright'],
]}
/>
<ComparisonTable
title="vs Claude Browser Extension"
headers={["", "Claude Extension", "Playwriter"]}
title='vs Claude Browser Extension'
headers={['', 'Claude Extension', 'Playwriter']}
rows={[
["Agent support", "Claude only", "Any MCP client"],
["Windows WSL", "No", "Yes"],
["Context method", "Screenshots (100KB+)", "A11y snapshots (5\u201320KB)"],
["Playwright API", "No", "Full"],
["Debugger", "No", "Yes"],
["Live code editing", "No", "Yes"],
["Network interception", "Limited", "Full"],
["Raw CDP access", "No", "Yes"],
['Agent support', 'Claude only', 'Any MCP client'],
['Windows WSL', 'No', 'Yes'],
['Context method', 'Screenshots (100KB+)', 'A11y snapshots (5\u201320KB)'],
['Playwright API', 'No', 'Full'],
['Debugger', 'No', 'Yes'],
['Live code editing', 'No', 'Yes'],
['Network interception', 'Limited', 'Full'],
['Raw CDP access', 'No', 'Yes'],
]}
/>
</Section>
<Section id="remote-access" title="Remote access">
<Section id='remote-access' title='Remote access'>
<P>
Control Chrome on a <strong>remote machine</strong> {" \u2014 "} a headless
Mac mini, a cloud VM, a devcontainer. A{" "}
<A href="https://traforo.dev">traforo</A>{" "}
tunnel exposes the relay through Cloudflare. <strong>No VPN, no
firewall rules, no port forwarding.</strong>
Control Chrome on a <strong>remote machine</strong> {' \u2014 '} a headless Mac mini, a cloud VM, a
devcontainer. A <A href='https://traforo.dev'>traforo</A> tunnel exposes the relay through Cloudflare.{' '}
<strong>No VPN, no firewall rules, no port forwarding.</strong>
</P>
<CodeBlock lang="bash">{dedent`
<CodeBlock lang='bash'>{dedent`
# On the host machine start relay with tunnel
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
@@ -482,46 +419,36 @@ export default function IndexPage() {
`}</CodeBlock>
<P>
Also works on a <strong>LAN without tunnels</strong> {" \u2014 "} just set{" "}
<Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP
too {" \u2014 "} set <Code>PLAYWRITER_HOST</Code> and{" "}
<Code>PLAYWRITER_TOKEN</Code> in your MCP client env config.
Use cases: headless Mac mini, remote user support,
multi-machine automation, dev from a VM or devcontainer.
Also works on a <strong>LAN without tunnels</strong> {' \u2014 '} just set{' '}
<Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP too {' \u2014 '} set <Code>PLAYWRITER_HOST</Code> and{' '}
<Code>PLAYWRITER_TOKEN</Code> in your MCP client env config. Use cases: headless Mac mini, remote user
support, multi-machine automation, dev from a VM or devcontainer.
</P>
</Section>
<Section id="security" title="Security">
<Section id='security' title='Security'>
<P>
Everything runs <strong>on your machine</strong>. The relay binds
to <Code>localhost:19988</Code> and only accepts connections
from the extension. No remote server, no account, no telemetry.
Everything runs <strong>on your machine</strong>. The relay binds to <Code>localhost:19988</Code> and only
accepts connections from the extension. No remote server, no account, no telemetry.
</P>
<List>
<Li>
<strong>Local only</strong> {" \u2014 "} WebSocket server binds to
localhost. Nothing leaves your machine.
<strong>Local only</strong> {' \u2014 '} WebSocket server binds to localhost. Nothing leaves your machine.
</Li>
<Li>
<strong>Origin validation</strong> {" \u2014 "} only the Playwriter
extension origin is accepted. Browsers cannot spoof the Origin
header, so malicious websites cannot connect.
<strong>Origin validation</strong> {' \u2014 '} only the Playwriter extension origin is accepted. Browsers
cannot spoof the Origin header, so malicious websites cannot connect.
</Li>
<Li>
<strong>Explicit consent</strong> {" \u2014 "} only tabs where you
clicked the extension icon are controlled. No background access.
<strong>Explicit consent</strong> {' \u2014 '} only tabs where you clicked the extension icon are
controlled. No background access.
</Li>
<Li>
<strong>Visible automation</strong> {" \u2014 "} Chrome shows an
automation banner on controlled tabs.
<strong>Visible automation</strong> {' \u2014 '} Chrome shows an automation banner on controlled tabs.
</Li>
</List>
</Section>
</EditorialPage>
);
)
}
+7 -7
View File
@@ -1,24 +1,24 @@
import React from "react";
import { sleep } from "../lib/utils";
import { Route } from "./+types/defer-example";
import React from 'react'
import { sleep } from '../lib/utils'
import { Route } from './+types/defer-example'
async function getProjectLocation() {
return Promise.resolve().then(() => sleep(1000).then(() => "hi"));
return Promise.resolve().then(() => sleep(1000).then(() => 'hi'))
}
export async function loader({}: Route.LoaderArgs) {
return {
project: getProjectLocation(),
};
}
}
export default function ProjectRoute({ loaderData }: Route.ComponentProps) {
const location = React.use(loaderData.project);
const location = React.use(loaderData.project)
return (
<main>
<h1>Let's locate your project</h1>
<p>Your project is at {location}.</p>
</main>
);
)
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { redirect } from 'react-router';
import { redirect } from 'react-router'
export const loader = () => {
throw redirect('https://github.com/remorses/playwriter');
};
throw redirect('https://github.com/remorses/playwriter')
}
export default function Index() {
return null;
return null
}
+5 -6
View File
@@ -12,13 +12,12 @@
*/
/* Base code style - override Prism defaults */
code[class*="language-"],
pre[class*="language-"] {
code[class*='language-'],
pre[class*='language-'] {
color: #1e293b;
background: none;
text-shadow: none;
font-family: var(--font-code, "SF Mono", "SFMono-Regular", "Consolas",
"Liberation Mono", Menlo, Courier, monospace);
font-family: var(--font-code, 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo, Courier, monospace);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -163,8 +162,8 @@ pre[class*="language-"] {
}
}
code[class*="language-"],
pre[class*="language-"] {
code[class*='language-'],
pre[class*='language-'] {
@variant dark {
color: var(--prism-fg);
}
+8 -5
View File
@@ -25,11 +25,12 @@
======================================================================== */
@font-face {
font-family: "JetBrainsMono NF Mono";
font-family: 'JetBrainsMono NF Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("https://raw.githubusercontent.com/ryanoasis/nerd-fonts/refs/tags/v3.3.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf") format("truetype");
src: url('https://raw.githubusercontent.com/ryanoasis/nerd-fonts/refs/tags/v3.3.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf')
format('truetype');
}
/* ========================================================================
@@ -38,7 +39,9 @@
.editorial-page {
font-optical-sizing: auto;
font-feature-settings: "liga" 1, "calt" 1;
font-feature-settings:
'liga' 1,
'calt' 1;
}
.editorial-page ::selection {
@@ -119,7 +122,7 @@
}
.inline-code::before {
content: "";
content: '';
position: absolute;
inset: -1.26px 0;
background: rgba(0, 0, 0, 0.04);
@@ -144,7 +147,7 @@
======================================================================== */
.editorial-page::before {
content: "";
content: '';
pointer-events: none;
z-index: 9;
position: fixed;
+22 -27
View File
@@ -1,6 +1,6 @@
@import "tailwindcss";
@import "./editorial.css";
@import "./editorial-prism.css";
@import 'tailwindcss';
@import './editorial.css';
@import './editorial-prism.css';
@custom-variant dark (@media (prefers-color-scheme: dark));
@plugin "@tailwindcss/typography";
@@ -42,11 +42,13 @@
--sidebar-ring: oklch(0.871 0.006 286.286);
/* ===== Editorial design tokens ===== */
--font-primary: "Inter var", "Inter", system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-secondary: "Newsreader", Georgia, "Times New Roman", serif;
--font-code: "JetBrainsMono NF Mono", "JetBrains Mono", "SF Mono",
"SFMono-Regular", "Consolas", "Liberation Mono", Menlo, Courier, monospace;
--font-primary:
'Inter var', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
--font-secondary: 'Newsreader', Georgia, 'Times New Roman', serif;
--font-code:
'JetBrainsMono NF Mono', 'JetBrains Mono', 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo,
Courier, monospace;
/* Bleed: code blocks & images extend beyond prose column.
44px = 8px div padding-left + 36px line-number width */
@@ -84,9 +86,8 @@
/* Button / back button */
--btn-bg: #fff;
--btn-shadow: rgba(0, 0, 0, 0.08) 0px 2px 8px,
rgba(0, 0, 0, 0.04) 0px 4px 16px,
rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
--btn-shadow:
rgba(0, 0, 0, 0.08) 0px 2px 8px, rgba(0, 0, 0, 0.04) 0px 4px 16px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
/* Link accent color (renamed from --accent to avoid shadcn conflict) */
--link-accent: #0969da;
@@ -98,13 +99,10 @@
/* Overlay / glass */
--overlay-filter: blur(1rem);
--overlay-bg: hsla(0, 0%, 100%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04),
0 1.625rem 3.375rem rgba(0, 0, 0, 0.04),
0 1rem 2rem rgba(0, 0, 0, 0.03),
0 0.625rem 1rem rgba(0, 0, 0, 0.024),
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016),
0 0 0.125rem rgba(0, 0, 0, 0.01);
--overlay-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04), 0 1.625rem 3.375rem rgba(0, 0, 0, 0.04), 0 1rem 2rem rgba(0, 0, 0, 0.03),
0 0.625rem 1rem rgba(0, 0, 0, 0.024), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016), 0 0 0.125rem rgba(0, 0, 0, 0.01);
/* Header fade gradient stops (white) */
--fade-0: rgb(255, 255, 255);
@@ -172,8 +170,8 @@
--selection-bg: rgba(255, 255, 255, 0.1);
--btn-bg: rgb(30, 30, 30);
--btn-shadow: rgba(255, 255, 255, 0.05) 0px 2px 8px,
rgba(255, 255, 255, 0.02) 0px 4px 16px,
--btn-shadow:
rgba(255, 255, 255, 0.05) 0px 2px 8px, rgba(255, 255, 255, 0.02) 0px 4px 16px,
rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
--link-accent: #58a6ff;
@@ -181,13 +179,10 @@
--brand-secondary: #f5a623;
--overlay-bg: hsla(0, 0%, 7%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06),
0 1.625rem 3.375rem rgba(0, 0, 0, 0.3),
0 1rem 2rem rgba(0, 0, 0, 0.2),
0 0.625rem 1rem rgba(0, 0, 0, 0.15),
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.08),
0 0 0.125rem rgba(0, 0, 0, 0.05);
--overlay-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06), 0 1.625rem 3.375rem rgba(0, 0, 0, 0.3), 0 1rem 2rem rgba(0, 0, 0, 0.2),
0 0.625rem 1rem rgba(0, 0, 0, 0.15), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.08), 0 0 0.125rem rgba(0, 0, 0, 0.05);
/* Header fade gradient stops (dark) */
--fade-0: rgb(17, 17, 17);
+15 -15
View File
@@ -1,26 +1,26 @@
/// <reference types="vitest/config" />
import { reactRouter } from "@react-router/dev/vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import EnvironmentPlugin from "vite-plugin-environment";
import { defineConfig } from "vite";
import { reactRouter } from '@react-router/dev/vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import EnvironmentPlugin from 'vite-plugin-environment'
import { defineConfig } from 'vite'
import {
viteExternalsPlugin,
enablePreserveModulesPlugin,
} from "@xmorse/deployment-utils/dist/vite-externals-plugin.js";
import { reactRouterServerPlugin } from "@xmorse/deployment-utils/dist/react-router.js";
import tsconfigPaths from "vite-tsconfig-paths";
} from '@xmorse/deployment-utils/dist/vite-externals-plugin.js'
import { reactRouterServerPlugin } from '@xmorse/deployment-utils/dist/react-router.js'
import tsconfigPaths from 'vite-tsconfig-paths'
const NODE_ENV = JSON.stringify(process.env.NODE_ENV || "production");
const NODE_ENV = JSON.stringify(process.env.NODE_ENV || 'production')
export default defineConfig({
clearScreen: false,
define: {
"process.env.NODE_ENV": NODE_ENV,
'process.env.NODE_ENV': NODE_ENV,
},
test: {
pool: "threads",
exclude: ["**/dist/**", "**/esm/**", "**/node_modules/**", "**/e2e/**"],
pool: 'threads',
exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'],
poolOptions: {
threads: {
isolate: false,
@@ -28,8 +28,8 @@ export default defineConfig({
},
},
plugins: [
EnvironmentPlugin("all", { prefix: "PUBLIC" }),
EnvironmentPlugin("all", { prefix: "NEXT_PUBLIC" }),
EnvironmentPlugin('all', { prefix: 'PUBLIC' }),
EnvironmentPlugin('all', { prefix: 'NEXT_PUBLIC' }),
process.env.VITEST ? react() : reactRouter(),
tsconfigPaths(),
// viteExternalsPlugin({
@@ -44,4 +44,4 @@ export default defineConfig({
// transformMixedEsModules: true,
// },
// },
});
})