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, "jsxSingleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all" "trailingComma": "all"
} }
+32 -28
View File
@@ -99,7 +99,7 @@ tests use these utilities from `test-utils.ts`:
const testCtx = await setupTestContext({ const testCtx = await setupTestContext({
port: 19987, port: 19987,
tempDirPrefix: 'pw-test-', 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 // 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 { client, cleanup } = await createMCPClient({ port: 19987 })
const result = await client.callTool({ const result = await client.callTool({
name: 'execute', 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! 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` 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 ## 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 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: this does:
1. `git submodule update --init` - init the playwright submodule 1. `git submodule update --init` - init the playwright submodule
2. `pnpm install` - install deps and link workspace packages 2. `pnpm install` - install deps and link workspace packages
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/` 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 **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: **2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
```ts ```ts
// before (bundled) // before (bundled)
export const ws = require('./utilsBundleImpl').ws; export const ws = require('./utilsBundleImpl').ws
// after (direct) // after (direct)
import wsLibrary from 'ws'; import wsLibrary from 'ws'
export const ws = wsLibrary; export const ws = wsLibrary
``` ```
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files: **3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
```bash ```bash
# transpile src/**/*.ts → lib/**/*.js (0.1s) # transpile src/**/*.ts → lib/**/*.js (0.1s)
# copy third_party/lockfile.js, third_party/extract-zip.js # 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. **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 | | | upstream | ours |
|---|---|---| | ------------ | ----------- | -------------- |
| build time | ~30s | 0.1s | | build time | ~30s | 0.1s |
| dependencies | 0 (bundled) | ~20 (external) | | dependencies | 0 (bundled) | ~20 (external) |
| trace-viewer | built | skipped | | 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. - 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 - NEVER silently suppress errors in catch {} blocks if they contain more than one function call
```ts ```ts
// BAD. DO NOT DO THIS // BAD. DO NOT DO THIS
let favicon: string | undefined; let favicon: string | undefined
if (docsConfig?.favicon) { if (docsConfig?.favicon) {
if (typeof docsConfig.favicon === "string") { if (typeof docsConfig.favicon === 'string') {
favicon = docsConfig.favicon; favicon = docsConfig.favicon
} else if (docsConfig.favicon?.light) { } else if (docsConfig.favicon?.light) {
// Use light favicon as default, could be enhanced with theme detection // 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 // DO THIS. use an iife. Immediately Invoked Function Expression
const favicon: string = (() => { const favicon: string = (() => {
if (!docsConfig?.favicon) { if (!docsConfig?.favicon) {
return ""; return ''
} }
if (typeof docsConfig.favicon === "string") { if (typeof docsConfig.favicon === 'string') {
return docsConfig.favicon; return docsConfig.favicon
} }
if (docsConfig.favicon?.light) { if (docsConfig.favicon?.light) {
// Use light favicon as default, could be enhanced with theme detection // 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: // if you already know the type use it:
const favicon: string = () => { 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:*"` - 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 ```ts
// BAD: Type will be never[] // BAD: Type will be never[]
const items = []; const items = []
// GOOD: Specify the expected type // GOOD: Specify the expected type
const items: string[] = []; const items: string[] = []
const numbers: number[] = []; const numbers: number[] = []
const users: User[] = []; const users: User[] = []
``` ```
remember to always add the explicit type to avoid unexpected type inference. 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. 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 changelogs.md
# writing docs # 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 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. 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 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) 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 # 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. 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. 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 ```ts
import { toJSONSchema } from "zod"; import { toJSONSchema } from 'zod'
const mySchema = z.object({ const mySchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
name: z.string().min(3).max(100), name: z.string().min(3).max(100),
age: z.number().min(0).optional(), age: z.number().min(0).optional(),
}); })
const jsonSchema = toJSONSchema(mySchema, { const jsonSchema = toJSONSchema(mySchema, {
removeAdditionalStrategy: "strict", removeAdditionalStrategy: 'strict',
}); })
``` ```
github.md 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 3. Use the `execute` tool to run Playwright code
The MCP exposes: The MCP exposes:
- `execute` tool - run Playwright code snippets - `execute` tool - run Playwright code snippets
- `reset` tool - reconnect if connection issues occur - `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({ const testCtx = await setupTestContext({
port: 19987, port: 19987,
tempDirPrefix: 'pw-test-', 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 // 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 { client, cleanup } = await createMCPClient({ port: 19987 })
const result = await client.callTool({ const result = await client.callTool({
name: 'execute', 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! 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` 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 ## 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 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: this does:
1. `git submodule update --init` - init the playwright submodule 1. `git submodule update --init` - init the playwright submodule
2. `pnpm install` - install deps and link workspace packages 2. `pnpm install` - install deps and link workspace packages
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/` 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 **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: **2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
```ts ```ts
// before (bundled) // before (bundled)
export const ws = require('./utilsBundleImpl').ws; export const ws = require('./utilsBundleImpl').ws
// after (direct) // after (direct)
import wsLibrary from 'ws'; import wsLibrary from 'ws'
export const ws = wsLibrary; export const ws = wsLibrary
``` ```
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files: **3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
```bash ```bash
# transpile src/**/*.ts → lib/**/*.js (0.1s) # transpile src/**/*.ts → lib/**/*.js (0.1s)
# copy third_party/lockfile.js, third_party/extract-zip.js # 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. **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 | | | upstream | ours |
|---|---|---| | ------------ | ----------- | -------------- |
| build time | ~30s | 0.1s | | build time | ~30s | 0.1s |
| dependencies | 0 (bundled) | ~20 (external) | | dependencies | 0 (bundled) | ~20 (external) |
| trace-viewer | built | skipped | | 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. 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 | | | Playwright MCP | Playwriter |
|---|---|---| | ------------- | ----------------- | --------------------------------- |
| Browser | Spawns new Chrome | **Uses your Chrome** | | Browser | Spawns new Chrome | **Uses your Chrome** |
| Extensions | None | Your existing ones | | Extensions | None | Your existing ones |
| Login state | Fresh | Already logged in | | 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 2. Click extension icon on a tab → turns green when connected
3. Install the CLI and start automating the browser: 3. Install the CLI and start automating the browser:
```bash ```bash
npm i -g playwriter npm i -g playwriter
playwriter -s 1 -e "await page.goto('https://example.com')" 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. Variables in scope: `page`, `context`, `state` (persists between calls), `require`, and Node.js globals.
**Persist data in state:** **Persist data in state:**
```bash ```bash
playwriter -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))" playwriter -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))"
playwriter -e "console.log(state.users)" playwriter -e "console.log(state.users)"
``` ```
**Intercept network requests:** **Intercept network requests:**
```bash ```bash
playwriter -e "state.requests = []; page.on('response', r => { if (r.url().includes('/api/')) state.requests.push(r.url()) })" 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')])" 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:** **Set breakpoints and debug:**
```bash ```bash
playwriter -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()" 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))" 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:** **Live edit page code:**
```bash ```bash
playwriter -e "state.cdp = await getCDPSession({ page }); state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()" 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' })" playwriter -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"
``` ```
**Screenshot with labels:** **Screenshot with labels:**
```bash ```bash
playwriter -e "await screenshotWithAccessibilityLabels({ page })" playwriter -e "await screenshotWithAccessibilityLabels({ page })"
``` ```
@@ -134,7 +140,7 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=
### vs BrowserMCP ### vs BrowserMCP
| | BrowserMCP | Playwriter | | | BrowserMCP | Playwriter |
|---|---|---| | ------------- | ------------------- | ------------------------ |
| Tools | 12+ dedicated tools | 1 `execute` tool | | Tools | 12+ dedicated tools | 1 `execute` tool |
| API | Limited actions | Full Playwright | | API | Limited actions | Full Playwright |
| Context usage | High (tool schemas) | Low | | Context usage | High (tool schemas) | Low |
@@ -143,7 +149,7 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=
### vs Antigravity (Jetski) ### vs Antigravity (Jetski)
| | Jetski | Playwriter | | | Jetski | Playwriter |
|---|---|---| | -------- | ---------------------------- | ---------------- |
| Tools | 17+ tools | 1 tool | | Tools | 17+ tools | 1 tool |
| Subagent | Spawns for each browser task | Direct execution | | Subagent | Spawns for each browser task | Direct execution |
| Latency | High (agent overhead) | Low | | Latency | High (agent overhead) | Low |
@@ -151,7 +157,7 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=
### vs Claude Browser Extension ### vs Claude Browser Extension
| | Claude Extension | Playwriter | | | Claude Extension | Playwriter |
|---|---|---| | -------------------- | -------------------- | ----------------------- |
| Agent support | Claude only | Any MCP client | | Agent support | Claude only | Any MCP client |
| Windows WSL | No | Yes | | Windows WSL | No | Yes |
| Context method | Screenshots (100KB+) | A11y snapshots (5-20KB) | | 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: Control Chrome on a remote machine over the internet using [traforo](https://traforo.dev) tunnels:
**On host:** **On host:**
```bash ```bash
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret> npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
``` ```
**From remote:** **From remote:**
```bash ```bash
export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=<secret> 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). - 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: Use this pattern to pick an existing page first, then navigate only if needed:
```bash ```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());" 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. - 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): - Verify the palette is open (look for the command dialog and MCP entry in the snapshot output):
```bash ```bash
playwriter -s 1 -e "console.log(await snapshot({ page, search: /dialog|Search…|MCP/ }));" 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. - 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`): - Verify the plugin iframe exists (should include `plugins.framercdn.com`):
```bash ```bash
playwriter -s 1 -e "const iframes = await page.locator('iframe').all(); for (const f of iframes) { console.log(await f.getAttribute('src')); }" 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): - Wait until the MCP iframe is present (verifies the action worked):
```bash ```bash
playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); await iframe.first().waitFor({ timeout: 10000 }); console.log('iframe ready');" 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: - Grab the iframes locator by URL:
```bash ```bash
playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await iframe.count());" 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): - Run the accessibility snapshot on that iframe using `contentFrame()` (FrameLocator is auto-resolved to Frame):
```bash ```bash
playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await snapshot({ page, frame }));" 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: - Alternative: use `page.frames()` to get the Frame directly:
```bash ```bash
playwriter -s 1 -e "const frame = page.frames().find(f => f.url().includes('plugins.framercdn.com')); console.log(await snapshot({ page, frame }));" 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): - Validate the snapshot contains MCP UI text (confirms the panel is actually loaded):
```bash ```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/ }));" 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 ```typescript
import { chromium } from 'playwright-core' import { chromium } from 'playwright-core'
const browser = await chromium.connectOverCDP( const browser = await chromium.connectOverCDP('wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN')
'wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN'
)
const page = browser.contexts()[0].pages()[0] const page = browser.contexts()[0].pages()[0]
await page.goto('https://example.com') await page.goto('https://example.com')
// Don't call browser.close() - it would close the user's Chrome // Don't call browser.close() - it would close the user's Chrome
@@ -177,7 +175,7 @@ done
### Environment variables ### Environment variables
| Variable | Description | | Variable | Description |
|---|---| | ------------------ | ---------------------------------------------------------------------------------- |
| `PLAYWRITER_HOST` | Remote relay URL (e.g. `https://x-tunnel.traforo.dev`) or IP (e.g. `192.168.1.10`) | | `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_TOKEN` | Authentication token for the relay server |
| `PLAYWRITER_PORT` | Override relay port (default: `19988`, not needed with traforo) | | `PLAYWRITER_PORT` | Override relay port (default: `19988`, not needed with traforo) |
+10 -1
View File
@@ -3,7 +3,16 @@
"name": "Playwriter", "name": "Playwriter",
"version": "0.0.71", "version": "0.0.71",
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", "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>"], "host_permissions": ["<all_urls>"],
"background": { "background": {
"service_worker": "background.js", "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.** **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: The tabs permission is only needed during development/testing to:
- Access the URL property of tabs for test identification (finding tabs by URL pattern) - Access the URL property of tabs for test identification (finding tabs by URL pattern)
- Query all tabs with full information for test assertions - Query all tabs with full information for test assertions
In production, the extension functions perfectly without the tabs permission because: In production, the extension functions perfectly without the tabs permission because:
- Tab event listeners (onRemoved, onActivated, onUpdated) work without it - Tab event listeners (onRemoved, onActivated, onUpdated) work without it
- chrome.tabs.create() and chrome.tabs.remove() work without it - chrome.tabs.create() and chrome.tabs.remove() work without it
- chrome.tabs.query() for active tab works 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. 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:** **What the WebSocket is used for:**
- Receiving CDP (Chrome DevTools Protocol) command messages in JSON format from local Playwright scripts - 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 - Forwarding these command messages to attached browser tabs via the `chrome.debugger` API
- Sending CDP event messages back to the local Playwright scripts - Sending CDP event messages back to the local Playwright scripts
**What it is NOT used for:** **What it is NOT used for:**
- Downloading or executing JavaScript, WebAssembly, or any other executable code - Downloading or executing JavaScript, WebAssembly, or any other executable code
- Connecting to external/remote servers (strictly localhost only) - Connecting to external/remote servers (strictly localhost only)
- Loading remote configurations that modify extension behavior - 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 ## Screenshots Required
Need to provide at least one screenshot showing: Need to provide at least one screenshot showing:
- Extension icon in toolbar (gray when disconnected, green when connected) - Extension icon in toolbar (gray when disconnected, green when connected)
- Extension attached to a tab with Chrome's "debugging this browser" banner visible - Extension attached to a tab with Chrome's "debugging this browser" banner visible
- Welcome page or usage demonstration - 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> { function download(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
https.get(url, (res) => { https
.get(url, (res) => {
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
reject(new Error(`Failed to download ${url}: ${res.statusCode}`)) reject(new Error(`Failed to download ${url}: ${res.statusCode}`))
return return
} }
const chunks: Buffer[] = [] const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => { chunks.push(chunk) }) res.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
res.on('end', () => { res.on('end', () => {
fs.writeFileSync(dest, Buffer.concat(chunks)) fs.writeFileSync(dest, Buffer.concat(chunks))
resolve() resolve()
}) })
res.on('error', reject) res.on('error', reject)
}).on('error', reject) })
.on('error', reject)
}) })
} }
@@ -34,7 +38,7 @@ async function main() {
await Promise.all( await Promise.all(
files.map(([src, dest]) => { files.map(([src, dest]) => {
return download(BASE + src, path.join(DEST, dest)) return download(BASE + src, path.join(DEST, dest))
}) }),
) )
console.log(`Downloaded ${files.length} Prism.js files to ${DEST}`) console.log(`Downloaded ${files.length} Prism.js files to ${DEST}`)
} }
+71 -24
View File
@@ -32,7 +32,6 @@ type ExtensionIdentity = {
id: string id: string
} }
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
@@ -126,10 +125,12 @@ function flushRecordingChunkBuffer(ws: WebSocket): void {
const { tabId, data, final } = chunk const { tabId, data, final } = chunk
// Send metadata message first // Send metadata message first
ws.send(JSON.stringify({ ws.send(
JSON.stringify({
method: 'recordingData', method: 'recordingData',
params: { tabId, final }, params: { tabId, final },
})) }),
)
// Then send binary data if not final // Then send binary data if not final
if (data && !final) { if (data && !final) {
@@ -168,7 +169,7 @@ class ConnectionManager {
setTimeout(() => { setTimeout(() => {
reject(new Error('Connection timeout (global)')) reject(new Error('Connection timeout (global)'))
}, GLOBAL_TIMEOUT_MS) }, GLOBAL_TIMEOUT_MS)
}) }),
]) ])
try { try {
@@ -417,7 +418,9 @@ class ConnectionManager {
const mem = performance.memory const mem = performance.memory
if (mem) { if (mem) {
const formatMB = (b: number) => (b / 1024 / 1024).toFixed(2) + 'MB' 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 {} } catch {}
logger.warn(`DISCONNECT: WS closed code=${code} reason=${reason || 'none'} stack=${getCallStack()}`) 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. // Slot is free when: no extension connected, OR connected but no active tabs.
if (store.getState().connectionState === 'extension-replaced') { if (store.getState().connectionState === 'extension-replaced') {
try { try {
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) }) const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, {
const data = await response.json() as { connected: boolean; activeTargets: number } method: 'GET',
signal: AbortSignal.timeout(2000),
})
const data = (await response.json()) as { connected: boolean; activeTargets: number }
const slotAvailable = !data.connected || data.activeTargets === 0 const slotAvailable = !data.connected || data.activeTargets === 0
if (slotAvailable) { if (slotAvailable) {
store.setState({ connectionState: 'idle', errorText: undefined }) 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 { } else {
logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...') 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 { function emitChildDetachesForTab(tabId: number): void {
const childEntries = Array.from(childSessions.entries()) const childEntries = Array.from(childSessions.entries()).filter(([_, parentTab]) => parentTab.tabId === tabId)
.filter(([_, parentTab]) => parentTab.tabId === tabId)
childEntries.forEach(([childSessionId, parentTab]) => { childEntries.forEach(([childSessionId, parentTab]) => {
const childDetachParams: Protocol.Target.DetachedFromTargetEvent = parentTab.targetId const childDetachParams: Protocol.Target.DetachedFromTargetEvent = parentTab.targetId
@@ -843,13 +854,15 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise<any> {
.filter(([_, info]) => info.state === 'connected') .filter(([_, info]) => info.state === 'connected')
.map(([tabId]) => tabId) .map(([tabId]) => tabId)
await Promise.all(connectedTabIds.map(async (tabId) => { await Promise.all(
connectedTabIds.map(async (tabId) => {
try { try {
await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params) await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params)
} catch (error) { } catch (error) {
logger.debug('Failed to set auto-attach for tab:', tabId, error) logger.debug('Failed to set auto-attach for tab:', tabId, error)
} }
})) }),
)
return {} return {}
} }
@@ -1007,7 +1020,10 @@ type AttachTabResult = {
sessionId: string 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 } const debuggee = { tabId }
let debuggerAttached = false 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 // Log error if URL is empty - this causes Playwright to create broken pages
if (!targetInfo.url || targetInfo.url === '' || targetInfo.url === ':') { 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 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 } return { targetInfo, sessionId }
} catch (error) { } catch (error) {
// Clean up debugger if we attached but failed later // 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> { async function connectTab(tabId: number): Promise<void> {
try { try {
logger.debug(`Starting connection to tab ${tabId}`) logger.debug(`Starting connection to tab ${tabId}`)
@@ -1264,7 +1294,13 @@ function isRestrictedUrl(url: string | undefined): boolean {
return !OUR_EXTENSION_IDS.includes(extensionId) 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)) return restrictedPrefixes.some((prefix) => url.startsWith(prefix))
} }
@@ -1434,7 +1470,10 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
resetDebugger() resetDebugger()
connectionManager.maintainLoop() connectionManager.maintainLoop()
chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => { chrome.contextMenus
.remove('playwriter-pin-element')
.catch(() => {})
.finally(() => {
chrome.contextMenus.create({ chrome.contextMenus.create({
id: 'playwriter-pin-element', id: 'playwriter-pin-element',
title: 'Copy Playwriter Element Reference', title: 'Copy Playwriter Element Reference',
@@ -1501,11 +1540,17 @@ function checkMemory(): void {
// Log if memory is high or growing rapidly // Log if memory is high or growing rapidly
if (used > MEMORY_CRITICAL_THRESHOLD) { 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) { } 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) { } 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 lastMemoryUsage = used
@@ -1528,7 +1573,8 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
void updateIcons() void updateIcons()
if (changeInfo.groupId !== undefined) { if (changeInfo.groupId !== undefined) {
// Queue tab group operations to serialize with syncTabGroup and disconnectEverything // 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 // Query for playwriter group by title - no stale cached ID
const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' }) const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' })
const groupId = existingGroups[0]?.id 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) logger.debug('Tab manually removed from playwriter group:', tabId)
await disconnectTab(tabId) await disconnectTab(tabId)
} }
}).catch((e) => { })
.catch((e) => {
logger.debug('onTabUpdated handler error:', e) logger.debug('onTabUpdated handler error:', e)
}) })
} }
+71 -190
View File
@@ -93,10 +93,7 @@ declare namespace chrome {
* identity: chrome.ghostPublicAPI.NEW_TEMPORARY_IDENTITY * identity: chrome.ghostPublicAPI.NEW_TEMPORARY_IDENTITY
* }, (tabId) => console.log('Opened tab:', tabId)) * }, (tabId) => console.log('Opened tab:', tabId))
*/ */
export function openTab( export function openTab(params: OpenTabParams, callback?: (tabId: number) => void): Promise<number>
params: OpenTabParams,
callback?: (tabId: number) => void
): Promise<number>
} }
// ============================================================================ // ============================================================================
@@ -151,107 +148,73 @@ declare namespace chrome {
} }
// Proxy CRUD operations // Proxy CRUD operations
export function add( export function add(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
proxy: AddProxyParams,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function import_( export function import_(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
proxy: AddProxyParams,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function remove( export function remove(proxy_id: string, callback?: (success: boolean) => void): Promise<boolean>
proxy_id: string,
callback?: (success: boolean) => void
): Promise<boolean>
export function removeAll(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 getList(callback?: (proxies: GhostProxy[]) => void): Promise<GhostProxy[]>
export function get( export function get(proxy_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
proxy_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function move( export function move(proxy_id: string, new_index: number, callback?: (success: boolean) => void): Promise<boolean>
proxy_id: string,
new_index: number,
callback?: (success: boolean) => void
): Promise<boolean>
export function modify( export function modify(
proxy_id: string, proxy_id: string,
params: ModifyProxyParams, params: ModifyProxyParams,
callback?: (proxy: GhostProxy) => void callback?: (proxy: GhostProxy) => void,
): Promise<GhostProxy> ): Promise<GhostProxy>
// Set proxy at different levels // Set proxy at different levels
export function setProjectProxy( export function setProjectProxy(
proxy_id: string, proxy_id: string,
keep_overrides: boolean, keep_overrides: boolean,
callback?: (success: boolean) => void callback?: (success: boolean) => void,
): Promise<boolean> ): Promise<boolean>
export function setSessionProxy( export function setSessionProxy(
session_id: string, session_id: string,
proxy_id: string, proxy_id: string,
keep_overrides: boolean, keep_overrides: boolean,
callback?: (success: boolean) => void callback?: (success: boolean) => void,
): Promise<boolean> ): Promise<boolean>
export function setIdentityProxy( export function setIdentityProxy(
identity_id: string, identity_id: string,
proxy_id: string, proxy_id: string,
callback?: (success: boolean) => void callback?: (success: boolean) => void,
): Promise<boolean> ): Promise<boolean>
export function setTabProxy( export function setTabProxy(
tab_id: number, tab_id: number,
proxy_id: string, proxy_id: string,
callback?: (success: boolean) => void callback?: (success: boolean) => void,
): Promise<boolean> ): Promise<boolean>
// Get proxy at different levels // Get proxy at different levels
export function getProjectProxy(callback?: (proxy: GhostProxy) => void): Promise<GhostProxy> export function getProjectProxy(callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function getSessionProxy( export function getSessionProxy(session_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
session_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getIdentityProxy( export function getIdentityProxy(identity_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
identity_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getTabProxy( export function getTabProxy(tab_id: number, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
tab_id: number,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
// Clear proxy at different levels // Clear proxy at different levels
export function clearProjectProxy( export function clearProjectProxy(keep_overrides: boolean, callback?: (success: boolean) => void): Promise<boolean>
keep_overrides: boolean,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearSessionProxy( export function clearSessionProxy(
session_id: string, session_id: string,
keep_overrides: boolean, keep_overrides: boolean,
callback?: (success: boolean) => void callback?: (success: boolean) => void,
): Promise<boolean> ): Promise<boolean>
export function clearIdentityProxy( export function clearIdentityProxy(identity_id: string, callback?: (success: boolean) => void): Promise<boolean>
identity_id: string,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearTabProxy( export function clearTabProxy(tab_id: number, callback?: (success: boolean) => void): Promise<boolean>
tab_id: number,
callback?: (success: boolean) => void
): Promise<boolean>
// Events // Events
export const onAdded: chrome.events.Event<(proxy: GhostProxy) => void> 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 onChanged: chrome.events.Event<(proxy: GhostProxy) => void>
export const onMoved: chrome.events.Event<(proxy: GhostProxy, old_index: number) => void> export const onMoved: chrome.events.Event<(proxy: GhostProxy, old_index: number) => void>
export const onProjectProxyChanged: chrome.events.Event<(proxy: GhostProxy) => void> export const onProjectProxyChanged: chrome.events.Event<(proxy: GhostProxy) => void>
export const onSessionProxyChanged: chrome.events.Event< export const onSessionProxyChanged: chrome.events.Event<(session_id: string, proxy: GhostProxy) => void>
(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 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 // Project functions
export function getProjectsList( export function getProjectsList(callback?: (projects: GhostProject[]) => void): Promise<GhostProject[]>
callback?: (projects: GhostProject[]) => void
): Promise<GhostProject[]>
export function getProject( export function getProject(project_id: string, callback?: (project: GhostProject) => void): Promise<GhostProject>
project_id: string,
callback?: (project: GhostProject) => void
): Promise<GhostProject>
export function getActiveProject( export function getActiveProject(callback?: (project: GhostProject) => void): Promise<GhostProject>
callback?: (project: GhostProject) => void
): Promise<GhostProject>
export function addProject( export function addProject(project: AddProjectDetails, callback?: () => void): Promise<void>
project: AddProjectDetails,
callback?: () => void
): Promise<void>
export function removeProject(project_id: string, callback?: () => void): Promise<void> export function removeProject(project_id: string, callback?: () => void): Promise<void>
export function moveProject( export function moveProject(project_id: string, new_index: number, callback?: () => void): Promise<void>
project_id: string,
new_index: number,
callback?: () => void
): Promise<void>
export function renameProject( export function renameProject(project_id: string, project_name: string, callback?: () => void): Promise<void>
project_id: string,
project_name: string,
callback?: () => void
): Promise<void>
export function setProjectDescription( export function setProjectDescription(
project_id: string, project_id: string,
project_description: string, project_description: string,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function lockProject(project_id: string, 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> export function openProject(project_id: string, callback?: () => void): Promise<void>
// Archived projects // Archived projects
export function getArchivedProjects( export function getArchivedProjects(callback?: (projects: ArchivedProject[]) => void): Promise<ArchivedProject[]>
callback?: (projects: ArchivedProject[]) => void
): Promise<ArchivedProject[]>
export function archiveProject(project_id: string, callback?: () => void): Promise<void> export function archiveProject(project_id: string, callback?: () => void): Promise<void>
export function restoreArchivedProject( export function restoreArchivedProject(project_id: string, callback?: () => void): Promise<void>
project_id: string,
callback?: () => void
): Promise<void>
export function deleteArchivedProject( export function deleteArchivedProject(project_id: string, callback?: () => void): Promise<void>
project_id: string,
callback?: () => void
): Promise<void>
// Session functions // Session functions
export function getSessionsList( export function getSessionsList(
project_id: string, project_id: string,
callback?: (sessions: GhostSession[]) => void callback?: (sessions: GhostSession[]) => void,
): Promise<GhostSession[]> ): Promise<GhostSession[]>
export function getSession( export function getSession(
project_id: string, project_id: string,
session_id: string, session_id: string,
callback?: (session: GhostSession) => void callback?: (session: GhostSession) => void,
): Promise<GhostSession> ): Promise<GhostSession>
export function renameSession( export function renameSession(
project_id: string, project_id: string,
session_id: string, session_id: string,
session_name: string, session_name: string,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function changeSessionColor( export function changeSessionColor(
project_id: string, project_id: string,
session_id: string, session_id: string,
session_color: string, session_color: string,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function clearSessionData( export function clearSessionData(
project_id: string, project_id: string,
session_id: string, session_id: string,
type: ClearSessionDataType, type: ClearSessionDataType,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
// Identity functions // Identity functions
export function getIdentitiesList( export function getIdentitiesList(callback?: (identities: GhostIdentity[]) => void): Promise<GhostIdentity[]>
callback?: (identities: GhostIdentity[]) => void
): Promise<GhostIdentity[]>
export function sortIdentitiesList( export function sortIdentitiesList(
condition: IdentitySortCondition, condition: IdentitySortCondition,
desc: boolean, desc: boolean,
callback?: (identities: GhostIdentity[]) => void callback?: (identities: GhostIdentity[]) => void,
): Promise<GhostIdentity[]> ): Promise<GhostIdentity[]>
export function getIdentity( export function getIdentity(
identity_id: string, identity_id: string,
callback?: (identity: GhostIdentity) => void callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity> ): Promise<GhostIdentity>
export function addIdentity( export function addIdentity(
identity: AddIdentityDetails, identity: AddIdentityDetails,
callback?: (identity: GhostIdentity) => void callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity> ): Promise<GhostIdentity>
export function removeIdentity(identity_id: string, callback?: () => void): Promise<void> export function removeIdentity(identity_id: string, callback?: () => void): Promise<void>
export function moveIdentity( export function moveIdentity(identity_id: string, new_index: number, callback?: () => void): Promise<void>
identity_id: string,
new_index: number,
callback?: () => void
): Promise<void>
export function renameIdentity( export function renameIdentity(identity_id: string, identity_name: string, callback?: () => void): Promise<void>
identity_id: string,
identity_name: string,
callback?: () => void
): Promise<void>
export function changeIdentityColor( export function changeIdentityColor(
identity_id: string, identity_id: string,
identity_color: string, identity_color: string,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function setIdentityTag( export function setIdentityTag(identity_id: string, identity_tag: string, callback?: () => void): Promise<void>
identity_id: string,
identity_tag: string,
callback?: () => void
): Promise<void>
export function setIdentityDescription( export function setIdentityDescription(
identity_id: string, identity_id: string,
identity_description: string, identity_description: string,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function setIdentityDedication( export function setIdentityDedication(
identity_id: string, identity_id: string,
identity_dedication: string, identity_dedication: string,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function setIdentityDedicationIsStrict( export function setIdentityDedicationIsStrict(
identity_id: string, identity_id: string,
identity_dedication_is_strict: boolean, identity_dedication_is_strict: boolean,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function setIdentityUserAgent( export function setIdentityUserAgent(identity_id: string, user_agent: string, callback?: () => void): Promise<void>
identity_id: string,
user_agent: string,
callback?: () => void
): Promise<void>
export function resetIdentity( export function resetIdentity(
identity_id: string, identity_id: string,
callback?: (identity: GhostIdentity) => void callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity> ): Promise<GhostIdentity>
export function clearIdentityData( export function clearIdentityData(
identity_id: string, identity_id: string,
type: ClearIdentityDataType, type: ClearIdentityDataType,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
export function getIdentityTabsList( export function getIdentityTabsList(
project_id: string, project_id: string,
identity_id: string, identity_id: string,
callback?: (tabs: GhostTab[]) => void callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]> ): Promise<GhostTab[]>
/** Opens a new tab in a new identity */ /** Opens a new tab in a new identity */
@@ -594,110 +507,80 @@ declare namespace chrome {
// Window functions // Window functions
export function getWindowsList( export function getWindowsList(
project_id: string, project_id: string,
callback?: (windows: GhostWindow[]) => void callback?: (windows: GhostWindow[]) => void,
): Promise<GhostWindow[]> ): Promise<GhostWindow[]>
export function getWindowTabsList( export function getWindowTabsList(
project_id: string, project_id: string,
window_id: number, window_id: number,
callback?: (tabs: GhostTab[]) => void callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]> ): Promise<GhostTab[]>
export function addWindow(window: AddWindowDetails, callback?: () => void): Promise<void> export function addWindow(window: AddWindowDetails, callback?: () => void): Promise<void>
export function removeWindow( export function removeWindow(project_id: string, window_id: number, callback?: () => void): Promise<void>
project_id: string,
window_id: number,
callback?: () => void
): Promise<void>
// Tab functions // Tab functions
export function getSessionTabsList( export function getSessionTabsList(
project_id: string, project_id: string,
session_id: string, session_id: string,
callback?: (tabs: GhostTab[]) => void callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]> ): Promise<GhostTab[]>
export function getTab( export function getTab(project_id: string, tab_id: number, callback?: (tab: GhostTab) => void): Promise<GhostTab>
project_id: string,
tab_id: number,
callback?: (tab: GhostTab) => void
): Promise<GhostTab>
export function addTab(tab: AddTabDetails, callback?: () => void): Promise<void> export function addTab(tab: AddTabDetails, callback?: () => void): Promise<void>
export function removeTab( export function removeTab(project_id: string, tab_id: number, callback?: () => void): Promise<void>
project_id: string,
tab_id: number,
callback?: () => void
): Promise<void>
export function updateTab( export function updateTab(
project_id: string, project_id: string,
tab_id: number, tab_id: number,
tab_info: TabInfo, tab_info: TabInfo,
callback?: () => void callback?: () => void,
): Promise<void> ): Promise<void>
// Multi-extension options // Multi-extension options
export function isMultiExtensionEnabled( export function isMultiExtensionEnabled(callback?: (enabled: boolean) => void): Promise<boolean>
callback?: (enabled: boolean) => void
): Promise<boolean>
export function getMultiExtensionOptions( export function getMultiExtensionOptions(
identity_id: string, identity_id: string,
callback?: (options: GhostMultiExtensionOption[]) => void callback?: (options: GhostMultiExtensionOption[]) => void,
): Promise<GhostMultiExtensionOption[]> ): Promise<GhostMultiExtensionOption[]>
export function setMultiExtensionOption( export function setMultiExtensionOption(
identity_id: string, identity_id: string,
id: string, id: string,
value: number, value: number,
callback?: (success: boolean) => void callback?: (success: boolean) => void,
): Promise<boolean> ): Promise<boolean>
export function clearMultiExtensionOptions( export function clearMultiExtensionOptions(
identity_id: string, identity_id: string,
callback?: (success: boolean) => void callback?: (success: boolean) => void,
): Promise<boolean> ): Promise<boolean>
// Events // Events
export const onProjectWillOpen: chrome.events.Event< export const onProjectWillOpen: chrome.events.Event<(project_id: string, first_time: boolean) => void>
(project_id: string, first_time: boolean) => void export const onProjectOpened: 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 onProjectClosed: chrome.events.Event<(project_id: string) => void>
export const onProjectAdded: chrome.events.Event<(project: GhostProject) => void> export const onProjectAdded: chrome.events.Event<(project: GhostProject) => void>
export const onProjectRemoved: chrome.events.Event<(project_id: string) => void> export const onProjectRemoved: chrome.events.Event<(project_id: string) => void>
export const onProjectNameChanged: chrome.events.Event< export const onProjectNameChanged: chrome.events.Event<(project_id: string, new_name: string) => void>
(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 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 onIdentityAdded: chrome.events.Event<(identity: GhostIdentity) => void>
export const onIdentityRemoved: chrome.events.Event<(identity_id: string) => void> export const onIdentityRemoved: chrome.events.Event<(identity_id: string) => void>
export const onIdentityNameChanged: chrome.events.Event< export const onIdentityNameChanged: chrome.events.Event<(identity_id: string, identity_name: string) => void>
(identity_id: string, identity_name: string) => void export const onIdentityColorChanged: chrome.events.Event<(identity_id: string, identity_color: string) => void>
>
export const onIdentityColorChanged: chrome.events.Event<
(identity_id: string, identity_color: string) => void
>
export const onIdentityUserAgentChanged: chrome.events.Event< export const onIdentityUserAgentChanged: chrome.events.Event<
(identity_id: string, identity_user_agent: string) => void (identity_id: string, identity_user_agent: string) => void
> >
export const onIdentitiesChanged: chrome.events.Event<() => void> export const onIdentitiesChanged: chrome.events.Event<() => void>
export const onSessionAdded: chrome.events.Event<(session: GhostSession) => void> export const onSessionAdded: chrome.events.Event<(session: GhostSession) => void>
export const onSessionRemoved: chrome.events.Event< export const onSessionRemoved: chrome.events.Event<(project_id: string, session_id: string) => void>
(project_id: string, session_id: string) => void
>
export const onSessionNameChanged: chrome.events.Event< export const onSessionNameChanged: chrome.events.Event<
(project_id: string, session_id: string, new_name: string) => void (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 onTabUpdated: chrome.events.Event<(project_id: string, tab_id: number) => void>
export const onWindowAdded: chrome.events.Event<(window: GhostWindow) => void> export const onWindowAdded: chrome.events.Event<(window: GhostWindow) => void>
export const onWindowRemoved: chrome.events.Event< export const onWindowRemoved: chrome.events.Event<(project_id: string, window_id: number) => void>
(project_id: string, window_id: number) => void
>
} }
// ============================================================================ // ============================================================================
+13 -9
View File
@@ -55,21 +55,25 @@ export type OffscreenMessage =
| OffscreenCancelRecordingMessage | OffscreenCancelRecordingMessage
// Offscreen document response types // Offscreen document response types
export type OffscreenStartRecordingResult = { export type OffscreenStartRecordingResult =
| {
success: true success: true
tabId: number tabId: number
startedAt: number startedAt: number
mimeType: string mimeType: string
} | { }
| {
success: false success: false
error: string error: string
} }
export type OffscreenStopRecordingResult = { export type OffscreenStopRecordingResult =
| {
success: true success: true
tabId: number tabId: number
duration: number duration: number
} | { }
| {
success: false success: false
error: string error: string
} }
@@ -80,10 +84,12 @@ export interface OffscreenIsRecordingResult {
startedAt?: number startedAt?: number
} }
export type OffscreenCancelRecordingResult = { export type OffscreenCancelRecordingResult =
| {
success: true success: true
tabId: number tabId: number
} | { }
| {
success: false success: false
error: string error: string
} }
@@ -101,6 +107,4 @@ export interface OffscreenRecordingCancelledMessage {
tabId: number tabId: number
} }
export type OffscreenOutgoingMessage = export type OffscreenOutgoingMessage = OffscreenRecordingChunkMessage | OffscreenRecordingCancelledMessage
| OffscreenRecordingChunkMessage
| OffscreenRecordingCancelledMessage
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<title>Playwriter Offscreen</title> <title>Playwriter Offscreen</title>
+16 -6
View File
@@ -61,7 +61,11 @@ interface OffscreenRecordingState {
// Map of tabId -> recording state for concurrent recording support // Map of tabId -> recording state for concurrent recording support
const recordings = new Map<number, OffscreenRecordingState>() 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) => { chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => {
handleMessage(message).then(sendResponse) handleMessage(message).then(sendResponse)
@@ -93,12 +97,14 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
try { try {
// Build Chrome-specific tabCapture constraints // Build Chrome-specific tabCapture constraints
// These use Chrome's proprietary API that TypeScript doesn't have built-in types for // 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: { mandatory: {
chromeMediaSource: 'tab', chromeMediaSource: 'tab',
chromeMediaSourceId: params.streamId, chromeMediaSourceId: params.streamId,
},
} }
} : false : false
const videoConstraints: ChromeTabCaptureVideoConstraints = { const videoConstraints: ChromeTabCaptureVideoConstraints = {
mandatory: { mandatory: {
@@ -106,7 +112,7 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
chromeMediaSourceId: params.streamId, chromeMediaSourceId: params.streamId,
minFrameRate: params.frameRate || 30, minFrameRate: params.frameRate || 30,
maxFrameRate: params.frameRate || 30, maxFrameRate: params.frameRate || 30,
} },
} }
// Get media stream from the streamId provided by tabCapture.getMediaStreamId // Get media stream from the streamId provided by tabCapture.getMediaStreamId
@@ -206,7 +212,9 @@ async function handleStopRecording(params: OffscreenStopRecordingMessage): Promi
}) })
// Stop all tracks // Stop all tracks
stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() }) stream.getTracks().forEach((track: MediaStreamTrack) => {
track.stop()
})
const duration = Date.now() - startedAt const duration = Date.now() - startedAt
@@ -260,7 +268,9 @@ function handleCancelRecordingForTab(tabId: number): OffscreenCancelRecordingRes
if (recorder.state !== 'inactive') { if (recorder.state !== 'inactive') {
recorder.stop() recorder.stop()
} }
stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() }) stream.getTracks().forEach((track: MediaStreamTrack) => {
track.stop()
})
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
action: 'recordingCancelled', 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> { export async function handleStartRecording(params: StartRecordingParams): Promise<StartRecordingResult> {
const tabId = resolveTabIdFromSessionId(params.sessionId) const tabId = resolveTabIdFromSessionId(params.sessionId)
if (!tabId) { 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)) { 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) + '...') logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...')
// Send message to offscreen document to start recording // Send message to offscreen document to start recording
const result = await chrome.runtime.sendMessage({ const result = (await chrome.runtime.sendMessage({
action: 'startRecording', action: 'startRecording',
tabId, tabId,
streamId, streamId,
@@ -141,7 +144,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis
videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000, videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000,
audioBitsPerSecond: params.audioBitsPerSecond ?? 128000, audioBitsPerSecond: params.audioBitsPerSecond ?? 128000,
audio: params.audio ?? false, audio: params.audio ?? false,
}) as OffscreenStartRecordingResult })) as OffscreenStartRecordingResult
if (!result.success) { if (!result.success) {
return { success: false, error: result.error || 'Failed to start recording in offscreen document' } 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 { try {
// Send message to offscreen document to stop recording - include tabId for concurrent support // 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', action: 'stopRecording',
tabId, tabId,
}) as OffscreenStopRecordingResult })) as OffscreenStopRecordingResult
if (!result.success) { if (!result.success) {
return { success: false, error: result.error || 'Failed to stop recording in offscreen document' } 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 // Clean up
activeRecordings.delete(tabId) 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 // Check with offscreen document for actual recording state - include tabId for concurrent support
try { try {
const result = await chrome.runtime.sendMessage({ const result = (await chrome.runtime.sendMessage({
action: 'isRecording', action: 'isRecording',
tabId, tabId,
}) as OffscreenIsRecordingResult })) as OffscreenIsRecordingResult
return { return {
isRecording: result.isRecording, isRecording: result.isRecording,
+23 -24
View File
@@ -1,29 +1,27 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path'
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'; import { viteStaticCopy } from 'vite-plugin-static-copy'
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename); const __dirname = dirname(__filename)
// Bundle the playwriter package version into the extension so it can report // 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 // which playwriter version it was built against. CLI/MCP use this to warn
// when the extension is outdated. // when the extension is outdated.
const playwriterPkg = JSON.parse( const playwriterPkg = JSON.parse(readFileSync(resolve(__dirname, '../playwriter/package.json'), 'utf-8'))
readFileSync(resolve(__dirname, '../playwriter/package.json'), 'utf-8')
);
const defineEnv: Record<string, string> = { const defineEnv: Record<string, string> = {
'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'), '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) { 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. // 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({ export default defineConfig({
plugins: [ plugins: [
@@ -31,33 +29,34 @@ export default defineConfig({
targets: [ targets: [
{ {
src: resolve(__dirname, 'icons/*'), src: resolve(__dirname, 'icons/*'),
dest: 'icons' dest: 'icons',
}, },
{ {
src: resolve(__dirname, 'manifest.json'), src: resolve(__dirname, 'manifest.json'),
dest: '.', dest: '.',
transform: (content) => { transform: (content) => {
const manifest = JSON.parse(content); const manifest = JSON.parse(content)
// Only include tabs permission during testing // Only include tabs permission during testing
if (process.env.TESTING) { if (process.env.TESTING) {
if (!manifest.permissions.includes('tabs')) { 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) // Inject key for stable extension ID in dev/test builds (not production)
// This ensures all developers get the same extension ID: pebbngnfojnignonigcnkdilknapkgid // This ensures all developers get the same extension ID: pebbngnfojnignonigcnkdilknapkgid
if (!process.env.PRODUCTION) { 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: { build: {
@@ -76,5 +75,5 @@ export default defineConfig({
}, },
}, },
}, },
define: defineEnv define: defineEnv,
}); })
+1
View File
@@ -4,6 +4,7 @@
"scripts": { "scripts": {
"test": "pnpm --filter playwriter test", "test": "pnpm --filter playwriter test",
"watch": "pnpm -r watch", "watch": "pnpm -r watch",
"format": "prettier --write .",
"cli": "pnpm --filter playwriter cli", "cli": "pnpm --filter playwriter cli",
"build": "pnpm --filter playwriter --filter mcp-extension build", "build": "pnpm --filter playwriter --filter mcp-extension build",
"reload": "pnpm --filter playwriter build && pnpm --filter mcp-extension reload", "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 - **`getCleanHTML` utility**: New function to get cleaned HTML from a locator or page
- Removes script, style, svg, head tags - 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 `search` option to filter results (returns first 10 matching lines)
- Supports `showDiffSinceLastCall` to see changes since last snapshot - Supports `showDiffSinceLastCall` to see changes since last snapshot
- Supports `includeStyles` to optionally keep style/class attributes - Supports `includeStyles` to optionally keep style/class attributes
@@ -342,9 +342,9 @@
### Usage ### Usage
```js ```js
const { snapshot, labelCount } = await showAriaRefLabels({ page }); const { snapshot, labelCount } = await showAriaRefLabels({ page })
await page.screenshot({ path: '/tmp/labeled-page.png' }); await page.screenshot({ path: '/tmp/labeled-page.png' })
await page.locator('aria-ref=e5').click(); await page.locator('aria-ref=e5').click()
// Labels auto-hide after 30 seconds, or call hideAriaRefLabels({ page }) manually // 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 { function cleanTypes(typesContent: string): string {
return typesContent return typesContent.replace(/\/\/# sourceMappingURL=.*$/gm, '').trim()
.replace(/\/\/# sourceMappingURL=.*$/gm, '')
.trim()
} }
function buildDebuggerApi() { function buildDebuggerApi() {
@@ -163,7 +161,14 @@ function stripCliSectionsFromSkill(skillContent: string): string {
} }
// Reconstruct markdown from tokens // 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() { function buildPromptFromSkill() {
@@ -245,16 +250,12 @@ function buildWellKnownSkills() {
{ {
name: frontmatter.name || 'playwriter', name: frontmatter.name || 'playwriter',
description: frontmatter.description || '', description: frontmatter.description || '',
files: ['SKILL.md'] files: ['SKILL.md'],
} },
] ],
} }
fs.writeFileSync( fs.writeFileSync(path.join(wellKnownDir, 'index.json'), JSON.stringify(indexJson, null, 2) + '\n', 'utf-8')
path.join(wellKnownDir, 'index.json'),
JSON.stringify(indexJson, null, 2) + '\n',
'utf-8'
)
console.log('Generated website/public/.well-known/skills/index.json') 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() { async function main() {
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}` 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() const contexts = browser.contexts()
console.log(`Found ${contexts.length} browser context(s)`) console.log(`Found ${contexts.length} browser context(s)`)
@@ -16,7 +14,7 @@ async function main() {
console.log(`Context has ${pages.length} page(s):`) console.log(`Context has ${pages.length} page(s):`)
for (const page of pages) { for (const page of pages) {
await page.emulateMedia({colorScheme: null, }) await page.emulateMedia({ colorScheme: null })
const url = page.url() const url = page.url()
console.log(`\nPage URL: ${url}`) console.log(`\nPage URL: ${url}`)
@@ -40,14 +38,8 @@ async function main() {
console.log(`Sum result evaluated in browser: ${sumResult}`) console.log(`Sum result evaluated in browser: ${sumResult}`)
if ((page as any)._snapshotForAI) { if ((page as any)._snapshotForAI) {
const snapshot = await (page as any)._snapshotForAI() const snapshot = await (page as any)._snapshotForAI()
const snapshotStr = const snapshotStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot)
typeof snapshot === 'string' console.log('First 100 chars of _snapshotForAI():', snapshotStr.slice(0, 100))
? snapshot
: JSON.stringify(snapshot)
console.log(
'First 100 chars of _snapshotForAI():',
snapshotStr.slice(0, 100),
)
} else { } else {
console.log('_snapshotForAI is not available on this page.') console.log('_snapshotForAI is not available on this page.')
} }
@@ -2,9 +2,7 @@ import playwright from 'playwright-core'
async function main() { async function main() {
const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}` 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() const contexts = browser.contexts()
console.log(`Found ${contexts.length} browser context(s)`) console.log(`Found ${contexts.length} browser context(s)`)
-1
View File
@@ -4,7 +4,6 @@ async function main() {
const server = await startPlayWriterCDPRelayServer({ port: 19988 }) const server = await startPlayWriterCDPRelayServer({ port: 19988 })
console.log('Server running. Press Ctrl+C to stop.') console.log('Server running. Press Ctrl+C to stop.')
} }
main().catch(console.error) 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 // 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, renderA11yLabels,
hideA11yLabels, hideA11yLabels,
} }
+5 -2
View File
@@ -64,7 +64,7 @@ async function createHtmlServer({ htmlByPath }: { htmlByPath: Record<string, str
resolve() resolve()
}) })
}) })
} },
} }
} }
@@ -175,7 +175,10 @@ describe('aria-snapshot', () => {
try { try {
await page.goto(outerServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 10000 }) await page.goto(outerServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 10000 })
await page.locator('[data-testid="external-iframe"]').waitFor({ timeout: 5000 }) 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 // Convert iframe Locator to Frame
const iframeHandle = await page.locator('[data-testid="external-iframe"]').elementHandle() 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 type { ICDPSession } from './cdp-session.js'
import { getCDPSessionForPage } from './cdp-session.js' import { getCDPSessionForPage } from './cdp-session.js'
// Import sharp at module level - resolves to null if not available // Import sharp at module level - resolves to null if not available
const sharpPromise = import('sharp') const sharpPromise = import('sharp')
.then((m) => { return m.default }) .then((m) => {
.catch(() => { return null }) return m.default
})
.catch(() => {
return null
})
// ============================================================================ // ============================================================================
// Snapshot Format Types // Snapshot Format Types
@@ -142,9 +145,7 @@ const INTERACTIVE_ROLES = new Set([
'audio', 'audio',
]) ])
const LABEL_ROLES = new Set([ const LABEL_ROLES = new Set(['labeltext'])
'labeltext',
])
const MAX_LABEL_POSITION_CONCURRENCY = 24 const MAX_LABEL_POSITION_CONCURRENCY = 24
const BOX_MODEL_TIMEOUT_MS = 5000 const BOX_MODEL_TIMEOUT_MS = 5000
@@ -165,12 +166,7 @@ const CONTEXT_ROLES = new Set([
'cell', 'cell',
]) ])
const SKIP_WRAPPER_ROLES = new Set([ const SKIP_WRAPPER_ROLES = new Set(['generic', 'group', 'none', 'presentation'])
'generic',
'group',
'none',
'presentation',
])
const TEST_ID_ATTRS = [ const TEST_ID_ATTRS = [
'data-testid', 'data-testid',
@@ -231,7 +227,15 @@ function buildLocatorFromStable(stable: { value: string; attr: string }): string
return `[${stable.attr}="${escaped}"]` 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) { if (stable) {
return buildLocatorFromStable(stable) return buildLocatorFromStable(stable)
} }
@@ -243,7 +247,6 @@ function buildBaseLocator({ role, name, stable }: { role: string; name: string;
return `role=${role}` return `role=${role}`
} }
function getAxValueString(value?: Protocol.Accessibility.AXValue): string { function getAxValueString(value?: Protocol.Accessibility.AXValue): string {
if (!value) { if (!value) {
return '' return ''
@@ -283,7 +286,13 @@ export type SnapshotNode = {
children: SnapshotNode[] children: SnapshotNode[]
} }
function buildSnapshotLine({ role, name, baseLocator, indent, hasChildren }: { function buildSnapshotLine({
role,
name,
baseLocator,
indent,
hasChildren,
}: {
role: string role: string
name: string name: string
baseLocator?: string baseLocator?: string
@@ -308,7 +317,8 @@ function buildTextLine(text: string, indent: number): SnapshotLine {
export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] { export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] {
return nodes.flatMap((node) => { return nodes.flatMap((node) => {
const nodeIndent = indent + (node.indentOffset ?? 0) const nodeIndent = indent + (node.indentOffset ?? 0)
const line = node.role === 'text' const line =
node.role === 'text'
? buildTextLine(node.name, nodeIndent) ? buildTextLine(node.name, nodeIndent)
: buildSnapshotLine({ : buildSnapshotLine({
role: node.role, role: node.role,
@@ -339,13 +349,15 @@ export function buildRawSnapshotTree(options: {
const role = getAxRole(node) const role = getAxRole(node)
const name = getAxValueString(node.name).trim() const name = getAxValueString(node.name).trim()
const children = (node.childIds ?? []).map((childId) => { const children = (node.childIds ?? [])
.map((childId) => {
return buildRawSnapshotTree({ return buildRawSnapshotTree({
nodeId: childId, nodeId: childId,
axById: options.axById, axById: options.axById,
isNodeInScope: options.isNodeInScope, isNodeInScope: options.isNodeInScope,
}) })
}).filter(isTruthy) })
.filter(isTruthy)
const inScope = options.isNodeInScope(node) || children.length > 0 const inScope = options.isNodeInScope(node) || children.length > 0
if (!inScope) { if (!inScope) {
@@ -367,7 +379,11 @@ export function filterInteractiveSnapshotTree(options: {
labelContext: boolean labelContext: boolean
refFilter?: (entry: { role: string; name: string }) => boolean refFilter?: (entry: { role: string; name: string }) => boolean
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo> 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> } { }): { nodes: SnapshotNode[]; names: Set<string> } {
const role = options.node.role const role = options.node.role
const name = options.node.name const name = options.node.name
@@ -476,7 +492,11 @@ export function filterFullSnapshotTree(options: {
ancestorNames: string[] ancestorNames: string[]
refFilter?: (entry: { role: string; name: string }) => boolean refFilter?: (entry: { role: string; name: string }) => boolean
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo> 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> } { }): { nodes: SnapshotNode[]; names: Set<string> } {
const role = options.node.role const role = options.node.role
const name = options.node.name const name = options.node.name
@@ -613,7 +633,8 @@ export function finalizeSnapshotOutput(
}, []) }, [])
let lineLocatorIndex = 0 let lineLocatorIndex = 0
const snapshot = lines.map((line) => { const snapshot = lines
.map((line) => {
let text = line.text let text = line.text
if (line.baseLocator) { if (line.baseLocator) {
const locator = locatorSequence[lineLocatorIndex] const locator = locatorSequence[lineLocatorIndex]
@@ -624,7 +645,8 @@ export function finalizeSnapshotOutput(
text += ':' text += ':'
} }
return text return text
}).join('\n') })
.join('\n')
let nodeLocatorIndex = 0 let nodeLocatorIndex = 0
const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => { const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => {
@@ -676,7 +698,11 @@ function buildDomIndex(nodes: Protocol.DOM.Node[]): {
return { domById, domByBackendId, childrenByParent } 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) { for (const node of nodes) {
if (!node.attributes) { if (!node.attributes) {
continue continue
@@ -692,7 +718,11 @@ function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrV
return null 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 result = new Set<Protocol.DOM.BackendNodeId>()
const stack: Protocol.DOM.NodeId[] = [rootNodeId] const stack: Protocol.DOM.NodeId[] = [rootNodeId]
while (stack.length > 0) { while (stack.length > 0) {
@@ -786,7 +816,14 @@ async function resolveFrame({ frame, page }: { frame?: Frame | FrameLocator; pag
* await page.locator(selector).click() * 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 page: Page
frame?: Frame | FrameLocator frame?: Frame | FrameLocator
locator?: Locator locator?: Locator
@@ -794,7 +831,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
interactiveOnly?: boolean interactiveOnly?: boolean
cdp?: ICDPSession cdp?: ICDPSession
}): Promise<AriaSnapshotResult> { }): Promise<AriaSnapshotResult> {
const session = cdp || await getCDPSessionForPage({ page }) const session = cdp || (await getCDPSessionForPage({ page }))
// Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame()) // 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() // 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 const frameId = resolvedFrame?.frameId() ?? null
if (frameId) { 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 frameUrl = resolvedFrame!.url()
const iframeTarget = targetInfos.find((t) => { const iframeTarget = targetInfos.find((t) => {
return t.type === 'iframe' && t.url === frameUrl return t.type === 'iframe' && t.url === frameUrl
}) })
if (iframeTarget) { if (iframeTarget) {
const { sessionId } = await session.send('Target.attachToTarget', { const { sessionId } = (await session.send('Target.attachToTarget', {
targetId: iframeTarget.targetId, targetId: iframeTarget.targetId,
flatten: true, flatten: true,
}) as Protocol.Target.AttachToTargetResponse })) as Protocol.Target.AttachToTargetResponse
oopifSessionId = sessionId oopifSessionId = sessionId
await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId) await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId)
} }
@@ -831,13 +868,20 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
try { try {
if (scopeLocator) { if (scopeLocator) {
await scopeLocator.evaluate((element, data) => { await scopeLocator.evaluate(
(element, data) => {
element.setAttribute(data.attr, data.value) element.setAttribute(data.attr, data.value)
}, { attr: scopeAttr, value: scopeValue }) },
{ attr: scopeAttr, value: scopeValue },
)
scopeApplied = true 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) const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes)
let scopeRootNodeId: Protocol.DOM.NodeId | null = null let scopeRootNodeId: Protocol.DOM.NodeId | null = null
@@ -852,12 +896,14 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
} }
} }
const allowedBackendIds = scopeRootNodeId const allowedBackendIds = scopeRootNodeId ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) : null
? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById)
: null
const axParams = !oopifSessionId && frameId ? { frameId } : undefined 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>() const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
for (const node of axNodes) { for (const node of axNodes) {
@@ -937,15 +983,17 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
return allowedBackendIds.has(node.backendDOMNodeId) return allowedBackendIds.has(node.backendDOMNodeId)
} }
let snapshotNodes: SnapshotNode[] = [] let snapshotNodes: SnapshotNode[] = []
if (rootAxNodeId) { if (rootAxNodeId) {
const rootNode = axById.get(rootAxNodeId) const rootNode = axById.get(rootAxNodeId)
const rootRole = rootNode ? getAxRole(rootNode) : '' const rootRole = rootNode ? getAxRole(rootNode) : ''
const rawRoots = rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds const rawRoots =
? rootNode.childIds.map((childId) => { rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
? rootNode.childIds
.map((childId) => {
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope }) return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
}).filter(isTruthy) })
.filter(isTruthy)
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy) : [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
const filtered = rawRoots.flatMap((rawNode) => { const filtered = rawRoots.flatMap((rawNode) => {
@@ -1027,7 +1075,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
} catch { } catch {
return null return null
} }
}) }),
) )
const matchingRefs = await page.evaluate( const matchingRefs = await page.evaluate(
@@ -1037,7 +1085,16 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
return null 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) { for (const attr of testIdAttrs) {
const value = target.getAttribute(attr) const value = target.getAttribute(attr)
if (value) { if (value) {
@@ -1066,7 +1123,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
{ {
targets: targetHandles, targets: targetHandles,
refData: result.refs, refData: result.refs,
} },
) )
return matchingRefs.map((ref) => { return matchingRefs.map((ref) => {
@@ -1140,7 +1197,7 @@ async function getLabelBoxesForRefs({
cdp?: ICDPSession cdp?: ICDPSession
}): Promise<AriaLabel[]> { }): Promise<AriaLabel[]> {
const log = logger?.info ?? logger?.error ?? console.error 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 sema = new Sema(maxConcurrency)
const labelRefs = refs.filter((ref) => { const labelRefs = refs.filter((ref) => {
return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role) return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)
@@ -1161,14 +1218,20 @@ async function getLabelBoxesForRefs({
await sema.acquire() await sema.acquire()
try { try {
const response = await Promise.race([ 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) => { new Promise<null>((resolve) => {
setTimeout(() => { resolve(null) }, BOX_MODEL_TIMEOUT_MS) setTimeout(() => {
resolve(null)
}, BOX_MODEL_TIMEOUT_MS)
}), }),
]) ])
completed++ completed++
if (completed % 50 === 0 || completed === labelRefs.length) { 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) { if (!response) {
timedOut++ timedOut++
@@ -1186,9 +1249,11 @@ async function getLabelBoxesForRefs({
} finally { } finally {
sema.release() 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) return labels.filter(isTruthy)
} finally { } finally {
if (!cdp) { if (!cdp) {
@@ -1217,7 +1282,12 @@ async function getLabelBoxesForRefs({
* await page.locator('[data-testid="submit-btn"]').click() * 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 page: Page
locator?: Locator locator?: Locator
interactiveOnly?: boolean interactiveOnly?: boolean
@@ -1240,11 +1310,15 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true,
try { try {
const snapshotStart = Date.now() const snapshotStart = Date.now()
const { snapshot, refs } = await getAriaSnapshot({ page, locator, interactiveOnly, cdp }) 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] return [entry.ref, entry.shortRef]
})) }),
)
const interactiveRefs = refs.filter((ref) => Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)) 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 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)`) log(`[showAriaRefLabels] getLabelBoxesForRefs: ${Date.now() - labelsStart}ms (${labels.length} boxes)`)
const renderStart = Date.now() const renderStart = Date.now()
const labelCount = await page.evaluate(({ entries, root, interactiveOnly: intOnly }) => { const labelCount = await page.evaluate(
const a11y = (globalThis as { ({ entries, root, interactiveOnly: intOnly }) => {
const a11y = (
globalThis as {
__a11y?: { __a11y?: {
renderA11yLabels?: (labels: typeof entries) => number 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) { if (a11y?.renderA11yLabels) {
return a11y.renderA11yLabels(entries) return a11y.renderA11yLabels(entries)
} }
if (a11y?.computeA11ySnapshot) { if (a11y?.computeA11ySnapshot) {
const rootElement = root || document.body 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') 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] renderA11yLabels: ${Date.now() - renderStart}ms (${labelCount} labels)`)
log(`[showAriaRefLabels] total: ${Date.now() - startTime}ms`) 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() * 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 page: Page
locator?: Locator locator?: Locator
interactiveOnly?: boolean interactiveOnly?: boolean
@@ -1351,7 +1439,10 @@ export async function screenshotWithAccessibilityLabels({ page, locator, interac
const screenshotPath = path.join(tmpDir, filename) const screenshotPath = path.join(tmpDir, filename)
// Get viewport size to clip screenshot to visible area // 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) // Max 1568px on any edge (larger gets auto-resized by Claude, adding latency)
// Token formula: tokens = (width * height) / 750 // 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 buttonId = '8' as Protocol.Accessibility.AXNodeId
const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>([ const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>([
[rootId, { [
rootId,
{
nodeId: rootId, nodeId: rootId,
ignored: false, ignored: false,
role: roleValue('rootwebarea'), role: roleValue('rootwebarea'),
childIds: [mainId, navId], childIds: [mainId, navId],
}], },
[mainId, { ],
[
mainId,
{
nodeId: mainId, nodeId: mainId,
ignored: false, ignored: false,
role: roleValue('main'), role: roleValue('main'),
childIds: [headingId, buttonId], childIds: [headingId, buttonId],
backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId,
}], },
[navId, { ],
[
navId,
{
nodeId: navId, nodeId: navId,
ignored: false, ignored: false,
role: roleValue('navigation'), role: roleValue('navigation'),
childIds: [listId], childIds: [listId],
backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId,
}], },
[listId, { ],
[
listId,
{
nodeId: listId, nodeId: listId,
ignored: false, ignored: false,
role: roleValue('list'), role: roleValue('list'),
childIds: [listItemId], childIds: [listItemId],
backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId,
}], },
[listItemId, { ],
[
listItemId,
{
nodeId: listItemId, nodeId: listItemId,
ignored: false, ignored: false,
role: roleValue('listitem'), role: roleValue('listitem'),
childIds: [linkId], childIds: [linkId],
backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId,
}], },
[linkId, { ],
[
linkId,
{
nodeId: linkId, nodeId: linkId,
ignored: false, ignored: false,
role: roleValue('link'), role: roleValue('link'),
name: nameValue('Docs'), name: nameValue('Docs'),
backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId,
}], },
[headingId, { ],
[
headingId,
{
nodeId: headingId, nodeId: headingId,
ignored: false, ignored: false,
role: roleValue('heading'), role: roleValue('heading'),
name: nameValue('Title'), name: nameValue('Title'),
backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId,
}], },
[buttonId, { ],
[
buttonId,
{
nodeId: buttonId, nodeId: buttonId,
ignored: false, ignored: false,
role: roleValue('button'), role: roleValue('button'),
name: nameValue('Submit'), name: nameValue('Submit'),
backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId,
}], },
],
]) ])
const allowed = new Set<Protocol.DOM.BackendNodeId>([204 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', role: 'navigation',
name: '', name: '',
ignored: false, ignored: false,
children: [ children: [{ role: 'link', name: 'Home', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] }],
{ role: 'link', name: 'Home', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] },
],
}, },
{ {
role: 'labeltext', role: 'labeltext',
name: '', name: '',
ignored: false, ignored: false,
children: [ children: [{ role: 'statictext', name: 'Email', ignored: false, children: [] }],
{ role: 'statictext', name: 'Email', ignored: false, children: [] },
],
}, },
{ {
role: 'generic', role: 'generic',
name: '', name: '',
ignored: false, ignored: false,
children: [ children: [{ role: 'button', name: 'Save', backendNodeId: 1 as Protocol.DOM.BackendNodeId, children: [] }],
{ role: 'button', name: 'Save', backendNodeId: 1 as Protocol.DOM.BackendNodeId, children: [] },
],
}, },
{ {
role: 'generic', 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 nodeId: Protocol.DOM.NodeId
parentId?: Protocol.DOM.NodeId parentId?: Protocol.DOM.NodeId
backendNodeId: Protocol.DOM.BackendNodeId backendNodeId: Protocol.DOM.BackendNodeId
nodeName: string nodeName: string
attributes: Map<string, string> attributes: Map<string, string>
}>([ }
[1 as Protocol.DOM.BackendNodeId, { >([
[
1 as Protocol.DOM.BackendNodeId,
{
nodeId: 10 as Protocol.DOM.NodeId, nodeId: 10 as Protocol.DOM.NodeId,
backendNodeId: 1 as Protocol.DOM.BackendNodeId, backendNodeId: 1 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON', nodeName: 'BUTTON',
attributes: new Map([['id', 'save-btn']]), attributes: new Map([['id', 'save-btn']]),
}], },
[2 as Protocol.DOM.BackendNodeId, { ],
[
2 as Protocol.DOM.BackendNodeId,
{
nodeId: 11 as Protocol.DOM.NodeId, nodeId: 11 as Protocol.DOM.NodeId,
backendNodeId: 2 as Protocol.DOM.BackendNodeId, backendNodeId: 2 as Protocol.DOM.BackendNodeId,
nodeName: 'A', nodeName: 'A',
attributes: new Map([['data-testid', 'nav-home']]), attributes: new Map([['data-testid', 'nav-home']]),
}], },
[3 as Protocol.DOM.BackendNodeId, { ],
[
3 as Protocol.DOM.BackendNodeId,
{
nodeId: 12 as Protocol.DOM.NodeId, nodeId: 12 as Protocol.DOM.NodeId,
backendNodeId: 3 as Protocol.DOM.BackendNodeId, backendNodeId: 3 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON', nodeName: 'BUTTON',
attributes: new Map([['id', 'ignored-action']]), attributes: new Map([['id', 'ignored-action']]),
}], },
],
]) ])
let refCounter = 0 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 refCounter += 1
return `${options.role}-${options.name}-${refCounter}` 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 nodeId: Protocol.DOM.NodeId
parentId?: Protocol.DOM.NodeId parentId?: Protocol.DOM.NodeId
backendNodeId: Protocol.DOM.BackendNodeId backendNodeId: Protocol.DOM.BackendNodeId
nodeName: string nodeName: string
attributes: Map<string, string> attributes: Map<string, string>
}>([ }
[2 as Protocol.DOM.BackendNodeId, { >([
[
2 as Protocol.DOM.BackendNodeId,
{
nodeId: 20 as Protocol.DOM.NodeId, nodeId: 20 as Protocol.DOM.NodeId,
backendNodeId: 2 as Protocol.DOM.BackendNodeId, backendNodeId: 2 as Protocol.DOM.BackendNodeId,
nodeName: 'INPUT', nodeName: 'INPUT',
attributes: new Map([['data-testid', 'email-input']]), attributes: new Map([['data-testid', 'email-input']]),
}], },
[3 as Protocol.DOM.BackendNodeId, { ],
[
3 as Protocol.DOM.BackendNodeId,
{
nodeId: 21 as Protocol.DOM.NodeId, nodeId: 21 as Protocol.DOM.NodeId,
backendNodeId: 3 as Protocol.DOM.BackendNodeId, backendNodeId: 3 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON', nodeName: 'BUTTON',
attributes: new Map([['id', 'save-primary']]), attributes: new Map([['id', 'save-primary']]),
}], },
[4 as Protocol.DOM.BackendNodeId, { ],
[
4 as Protocol.DOM.BackendNodeId,
{
nodeId: 22 as Protocol.DOM.NodeId, nodeId: 22 as Protocol.DOM.NodeId,
backendNodeId: 4 as Protocol.DOM.BackendNodeId, backendNodeId: 4 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON', nodeName: 'BUTTON',
attributes: new Map([['id', 'save-secondary']]), attributes: new Map([['id', 'save-secondary']]),
}], },
],
]) ])
let refCounter = 0 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 nodeId: Protocol.DOM.NodeId
parentId?: Protocol.DOM.NodeId parentId?: Protocol.DOM.NodeId
backendNodeId: Protocol.DOM.BackendNodeId backendNodeId: Protocol.DOM.BackendNodeId
nodeName: string nodeName: string
attributes: Map<string, string> attributes: Map<string, string>
}>() }
>()
const createRefForNode = (): string | null => { const createRefForNode = (): string | null => {
return 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 nodeId: Protocol.DOM.NodeId
parentId?: Protocol.DOM.NodeId parentId?: Protocol.DOM.NodeId
backendNodeId: Protocol.DOM.BackendNodeId backendNodeId: Protocol.DOM.BackendNodeId
nodeName: string nodeName: string
attributes: Map<string, string> attributes: Map<string, string>
}>([ }
[5 as Protocol.DOM.BackendNodeId, { >([
[
5 as Protocol.DOM.BackendNodeId,
{
nodeId: 30 as Protocol.DOM.NodeId, nodeId: 30 as Protocol.DOM.NodeId,
backendNodeId: 5 as Protocol.DOM.BackendNodeId, backendNodeId: 5 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON', nodeName: 'BUTTON',
attributes: new Map([['id', 'delete']]), attributes: new Map([['id', 'delete']]),
}], },
[6 as Protocol.DOM.BackendNodeId, { ],
[
6 as Protocol.DOM.BackendNodeId,
{
nodeId: 31 as Protocol.DOM.NodeId, nodeId: 31 as Protocol.DOM.NodeId,
backendNodeId: 6 as Protocol.DOM.BackendNodeId, backendNodeId: 6 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON', nodeName: 'BUTTON',
attributes: new Map([['id', 'save']]), attributes: new Map([['id', 'save']]),
}], },
],
]) ])
let refCounter = 0 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 resolvedLogFilePath = logFilePath || LOG_CDP_FILE_PATH
const logDir = path.dirname(resolvedLogFilePath) const logDir = path.dirname(resolvedLogFilePath)
if (!fs.existsSync(logDir)) { if (!fs.existsSync(logDir)) {
+183 -105
View File
@@ -93,7 +93,6 @@ type ExtensionConnection = {
pingInterval: ReturnType<typeof setInterval> | null pingInterval: ReturnType<typeof setInterval> | null
} }
export type RelayServer = { export type RelayServer = {
close(): void close(): void
on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
@@ -129,7 +128,7 @@ export async function startPlayWriterCDPRelayServer({
const getExtensionConnection = ( const getExtensionConnection = (
extensionId?: string | null, extensionId?: string | null,
options: { allowFallback?: boolean } = {} options: { allowFallback?: boolean } = {},
): ExtensionConnection | null => { ): ExtensionConnection | null => {
if (extensionId) { if (extensionId) {
const direct = extensionConnections.get(extensionId) const direct = extensionConnections.get(extensionId)
@@ -177,7 +176,7 @@ export async function startPlayWriterCDPRelayServer({
const getPageTargetForFrameId = ({ const getPageTargetForFrameId = ({
connection, connection,
frameId frameId,
}: { }: {
connection: ExtensionConnection connection: ExtensionConnection
frameId: string frameId: string
@@ -216,7 +215,7 @@ export async function startPlayWriterCDPRelayServer({
sessionId, sessionId,
params, params,
id, id,
source source,
}: { }: {
direction: 'to-playwright' | 'from-playwright' | 'from-extension' direction: 'to-playwright' | 'from-playwright' | 'from-extension'
clientId?: string clientId?: string
@@ -232,7 +231,7 @@ export async function startPlayWriterCDPRelayServer({
'Network.responseReceivedExtraInfo', 'Network.responseReceivedExtraInfo',
'Network.dataReceived', 'Network.dataReceived',
'Network.requestWillBeSent', 'Network.requestWillBeSent',
'Network.loadingFinished' 'Network.loadingFinished',
] ]
if (noisyEvents.includes(method)) { if (noisyEvents.includes(method)) {
@@ -287,9 +286,7 @@ export async function startPlayWriterCDPRelayServer({
source?: 'extension' | 'server' source?: 'extension' | 'server'
extensionId?: string | null extensionId?: string | null
}) { }) {
const messageToSend = source === 'server' && 'method' in message const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message
? { ...message, __serverGenerated: true }
: message
logCdpJson({ logCdpJson({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -306,7 +303,7 @@ export async function startPlayWriterCDPRelayServer({
method: message.method, method: message.method,
sessionId: 'sessionId' in message ? message.sessionId : undefined, sessionId: 'sessionId' in message ? message.sessionId : undefined,
params: 'params' in message ? message.params : undefined, params: 'params' in message ? message.params : undefined,
source source,
}) })
} }
@@ -409,7 +406,7 @@ export async function startPlayWriterCDPRelayServer({
reject: (error) => { reject: (error) => {
clearTimeout(timeoutId) clearTimeout(timeoutId)
reject(error) reject(error)
} },
}) })
}) })
} }
@@ -429,7 +426,7 @@ export async function startPlayWriterCDPRelayServer({
(params) => sendToExtension({ extensionId: connection.id, ...params }), (params) => sendToExtension({ extensionId: connection.id, ...params }),
() => extensionConnections.has(connection.id), () => extensionConnections.has(connection.id),
logger, logger,
) ),
) )
} }
return recordingRelays.get(connection.id) || null return recordingRelays.get(connection.id) || null
@@ -451,7 +448,7 @@ export async function startPlayWriterCDPRelayServer({
try { try {
logger?.log(pc.blue('Auto-creating initial tab for Playwright client')) 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 success: boolean
tabId: number tabId: number
sessionId: string sessionId: string
@@ -462,9 +459,13 @@ export async function startPlayWriterCDPRelayServer({
sessionId: result.sessionId, sessionId: result.sessionId,
targetId: result.targetInfo.targetId, targetId: result.targetInfo.targetId,
targetInfo: result.targetInfo, 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) { } catch (e) {
logger?.error('Failed to auto-create initial tab:', e) logger?.error('Failed to auto-create initial tab:', e)
@@ -493,7 +494,7 @@ export async function startPlayWriterCDPRelayServer({
product: 'Chrome/Extension-Bridge', product: 'Chrome/Extension-Bridge',
revision: '1.0.0', revision: '1.0.0',
userAgent: 'CDP-Bridge-Server/1.0.0', userAgent: 'CDP-Bridge-Server/1.0.0',
jsVersion: 'V8' jsVersion: 'V8',
} satisfies Protocol.Browser.GetVersionResponse } satisfies Protocol.Browser.GetVersionResponse
} }
@@ -516,7 +517,7 @@ export async function startPlayWriterCDPRelayServer({
await sendToExtension({ await sendToExtension({
extensionId: extension?.id || extensionId, extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand', method: 'forwardCDPCommand',
params: { method, params, source } params: { method, params, source },
}) })
return {} return {}
} }
@@ -568,8 +569,8 @@ export async function startPlayWriterCDPRelayServer({
.filter((t) => !isRestrictedTarget(t.targetInfo)) .filter((t) => !isRestrictedTarget(t.targetInfo))
.map((t) => ({ .map((t) => ({
...t.targetInfo, ...t.targetInfo,
attached: true attached: true,
})) })),
} }
} }
@@ -577,7 +578,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({ return await sendToExtension({
extensionId: extension?.id || extensionId, extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand', method: 'forwardCDPCommand',
params: { method, params, source } params: { method, params, source },
}) })
} }
@@ -585,7 +586,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({ return await sendToExtension({
extensionId: extension?.id || extensionId, extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand', method: 'forwardCDPCommand',
params: { method, params, source } params: { method, params, source },
}) })
} }
@@ -594,7 +595,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({ return await sendToExtension({
extensionId: extension?.id || extensionId, extensionId: extension?.id || extensionId,
method: 'ghost-browser', method: 'ghost-browser',
params params,
}) })
} }
@@ -616,7 +617,11 @@ export async function startPlayWriterCDPRelayServer({
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
emitter.off('cdp:event', handler) 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() resolve()
}, 3000) }, 3000)
emitter.on('cdp:event', handler) emitter.on('cdp:event', handler)
@@ -625,7 +630,7 @@ export async function startPlayWriterCDPRelayServer({
const result = await sendToExtension({ const result = await sendToExtension({
extensionId: extension?.id || extensionId, extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand', method: 'forwardCDPCommand',
params: { sessionId, method, params, source } params: { sessionId, method, params, source },
}) })
await contextCreatedPromise await contextCreatedPromise
@@ -637,7 +642,7 @@ export async function startPlayWriterCDPRelayServer({
return await sendToExtension({ return await sendToExtension({
extensionId: extension?.id || extensionId, extensionId: extension?.id || extensionId,
method: 'forwardCDPCommand', 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. // CORS middleware for HTTP endpoints - only allows our specific extension IDs.
// This prevents other extensions from reading responses via fetch/XHR. // This prevents other extensions from reading responses via fetch/XHR.
// WebSocket connections have their own separate origin validation. // WebSocket connections have their own separate origin validation.
app.use('*', cors({ app.use(
'*',
cors({
origin: (origin) => { origin: (origin) => {
if (!origin.startsWith('chrome-extension://')) { if (!origin.startsWith('chrome-extension://')) {
return null return null
@@ -657,7 +664,8 @@ export async function startPlayWriterCDPRelayServer({
return origin return origin
}, },
allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'], allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'],
})) }),
)
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
const getCdpWsUrl = (c: { req: { header: (name: string) => string | undefined } }) => { const getCdpWsUrl = (c: { req: { header: (name: string) => string | undefined } }) => {
@@ -709,76 +717,76 @@ export async function startPlayWriterCDPRelayServer({
app app
.on(['GET', 'PUT'], '/json/version', (c) => { .on(['GET', 'PUT'], '/json/version', (c) => {
return c.json({ return c.json({
'Browser': `Playwriter/${VERSION}`, Browser: `Playwriter/${VERSION}`,
'Protocol-Version': '1.3', 'Protocol-Version': '1.3',
'webSocketDebuggerUrl': getCdpWsUrl(c) webSocketDebuggerUrl: getCdpWsUrl(c),
}) })
}) })
.on(['GET', 'PUT'], '/json/version/', (c) => { .on(['GET', 'PUT'], '/json/version/', (c) => {
return c.json({ return c.json({
'Browser': `Playwriter/${VERSION}`, Browser: `Playwriter/${VERSION}`,
'Protocol-Version': '1.3', 'Protocol-Version': '1.3',
'webSocketDebuggerUrl': getCdpWsUrl(c) webSocketDebuggerUrl: getCdpWsUrl(c),
}) })
}) })
.on(['GET', 'PUT'], '/json/list', (c) => { .on(['GET', 'PUT'], '/json/list', (c) => {
const wsUrl = getCdpWsUrl(c) const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json( return c.json(
Array.from(defaultTargets.values()).map(t => ({ Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId, id: t.targetId,
type: t.targetInfo.type, type: t.targetInfo.type,
title: t.targetInfo.title, title: t.targetInfo.title,
description: t.targetInfo.title, description: t.targetInfo.title,
url: t.targetInfo.url, url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl, webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}` devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
})) })),
) )
}) })
.on(['GET', 'PUT'], '/json/list/', (c) => { .on(['GET', 'PUT'], '/json/list/', (c) => {
const wsUrl = getCdpWsUrl(c) const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json( return c.json(
Array.from(defaultTargets.values()).map(t => ({ Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId, id: t.targetId,
type: t.targetInfo.type, type: t.targetInfo.type,
title: t.targetInfo.title, title: t.targetInfo.title,
description: t.targetInfo.title, description: t.targetInfo.title,
url: t.targetInfo.url, url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl, webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}` devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
})) })),
) )
}) })
.on(['GET', 'PUT'], '/json', (c) => { .on(['GET', 'PUT'], '/json', (c) => {
const wsUrl = getCdpWsUrl(c) const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json( return c.json(
Array.from(defaultTargets.values()).map(t => ({ Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId, id: t.targetId,
type: t.targetInfo.type, type: t.targetInfo.type,
title: t.targetInfo.title, title: t.targetInfo.title,
description: t.targetInfo.title, description: t.targetInfo.title,
url: t.targetInfo.url, url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl, webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}` devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
})) })),
) )
}) })
.on(['GET', 'PUT'], '/json/', (c) => { .on(['GET', 'PUT'], '/json/', (c) => {
const wsUrl = getCdpWsUrl(c) const wsUrl = getCdpWsUrl(c)
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
return c.json( return c.json(
Array.from(defaultTargets.values()).map(t => ({ Array.from(defaultTargets.values()).map((t) => ({
id: t.targetId, id: t.targetId,
type: t.targetInfo.type, type: t.targetInfo.type,
title: t.targetInfo.title, title: t.targetInfo.title,
description: t.targetInfo.title, description: t.targetInfo.title,
url: t.targetInfo.url, url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl, 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. // 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 // We only allow our specific extension IDs to prevent malicious websites or extensions
// from connecting to the local WebSocket server. // 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') const origin = c.req.header('origin')
// Validate Origin header if present (Node.js clients don't send it) // Validate Origin header if present (Node.js clients don't send it)
@@ -823,7 +833,8 @@ export async function startPlayWriterCDPRelayServer({
} }
} }
return next() return next()
}, upgradeWebSocket((c) => { },
upgradeWebSocket((c) => {
const clientId = c.req.param('clientId') || 'default' const clientId = c.req.param('clientId') || 'default'
const url = new URL(c.req.url, 'http://localhost') const url = new URL(c.req.url, 'http://localhost')
const requestedExtensionId = url.searchParams.get('extensionId') const requestedExtensionId = url.searchParams.get('extensionId')
@@ -853,7 +864,11 @@ export async function startPlayWriterCDPRelayServer({
playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId }) playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId })
const extensionConnection = getExtensionConnection(clientExtensionId) const extensionConnection = getExtensionConnection(clientExtensionId)
const targetCount = extensionConnection?.connectedTargets.size || 0 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) { async onMessage(event, ws) {
@@ -879,7 +894,7 @@ export async function startPlayWriterCDPRelayServer({
clientId, clientId,
method, method,
sessionId, sessionId,
id id,
}) })
emitter.emit('cdp:command', { clientId, command: message }) emitter.emit('cdp:command', { clientId, command: message })
@@ -890,15 +905,21 @@ export async function startPlayWriterCDPRelayServer({
message: { message: {
id, id,
sessionId, sessionId,
error: { message: 'Extension not connected' } error: { message: 'Extension not connected' },
}, },
clientId clientId,
}) })
return return
} }
try { 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) { if (method === 'Target.setAutoAttach' && !sessionId) {
for (const target of extensionConnection.connectedTargets.values()) { for (const target of extensionConnection.connectedTargets.values()) {
@@ -912,19 +933,25 @@ export async function startPlayWriterCDPRelayServer({
sessionId: target.sessionId, sessionId: target.sessionId,
targetInfo: { targetInfo: {
...target.targetInfo, ...target.targetInfo,
attached: true attached: true,
},
waitingForDebugger: false,
}, },
waitingForDebugger: false
}
} satisfies CDPEventFor<'Target.attachedToTarget'> } satisfies CDPEventFor<'Target.attachedToTarget'>
if (!target.targetInfo.url) { 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({ sendToPlaywright({
message: attachedPayload, message: attachedPayload,
clientId, clientId,
source: 'server' source: 'server',
}) })
} }
} }
@@ -940,25 +967,33 @@ export async function startPlayWriterCDPRelayServer({
params: { params: {
targetInfo: { targetInfo: {
...target.targetInfo, ...target.targetInfo,
attached: true attached: true,
} },
} },
} satisfies CDPEventFor<'Target.targetCreated'> } satisfies CDPEventFor<'Target.targetCreated'>
if (!target.targetInfo.url) { 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({ sendToPlaywright({
message: targetCreatedPayload, message: targetCreatedPayload,
clientId, clientId,
source: 'server' source: 'server',
}) })
} }
} }
if (method === 'Target.attachToTarget' && result?.sessionId) { if (method === 'Target.attachToTarget' && result?.sessionId) {
const targetId = params?.targetId 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) { if (target) {
const attachedPayload = { const attachedPayload = {
method: 'Target.attachedToTarget', method: 'Target.attachedToTarget',
@@ -966,19 +1001,25 @@ export async function startPlayWriterCDPRelayServer({
sessionId: result.sessionId, sessionId: result.sessionId,
targetInfo: { targetInfo: {
...target.targetInfo, ...target.targetInfo,
attached: true attached: true,
},
waitingForDebugger: false,
}, },
waitingForDebugger: false
}
} satisfies CDPEventFor<'Target.attachedToTarget'> } satisfies CDPEventFor<'Target.attachedToTarget'>
if (!target.targetInfo.url) { 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({ sendToPlaywright({
message: attachedPayload, message: attachedPayload,
clientId, clientId,
source: 'server' source: 'server',
}) })
} }
} }
@@ -991,7 +1032,7 @@ export async function startPlayWriterCDPRelayServer({
const errorResponse: CDPResponseBase = { const errorResponse: CDPResponseBase = {
id, id,
sessionId, sessionId,
error: { message: (e as Error).message } error: { message: (e as Error).message },
} }
sendToPlaywright({ message: errorResponse, clientId }) sendToPlaywright({ message: errorResponse, clientId })
emitter.emit('cdp:response', { clientId, response: errorResponse, command: message }) emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
@@ -1005,9 +1046,10 @@ export async function startPlayWriterCDPRelayServer({
onError(event) { onError(event) {
logger?.error(`Playwright WebSocket error [${clientId}]:`, event) logger?.error(`Playwright WebSocket error [${clientId}]:`, event)
},
} }
} }),
})) )
const getExtensionInfoFromRequest = (c: { req: { query: (name: string) => string | undefined } }): ExtensionInfo => { const getExtensionInfoFromRequest = (c: { req: { query: (name: string) => string | undefined } }): ExtensionInfo => {
const browser = c.req.query('browser') 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. // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
// This prevents attackers on the network from hijacking the browser session // This prevents attackers on the network from hijacking the browser session
// even if the server is exposed via 0.0.0.0. // 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. // is coming from our specific Chrome Extension, not a malicious website.
const origin = c.req.header('origin') const origin = c.req.header('origin')
if (!origin || !origin.startsWith('chrome-extension://')) { 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) return c.text('Forbidden', 403)
} }
@@ -1051,7 +1097,8 @@ export async function startPlayWriterCDPRelayServer({
} }
return next() return next()
}, upgradeWebSocket((c) => { },
upgradeWebSocket((c) => {
const incomingExtensionInfo = getExtensionInfoFromRequest(c) const incomingExtensionInfo = getExtensionInfoFromRequest(c)
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
return { return {
@@ -1158,7 +1205,7 @@ export async function startPlayWriterCDPRelayServer({
direction: 'from-extension', direction: 'from-extension',
method, method,
sessionId, sessionId,
params params,
}) })
const cdpEvent: CDPEventBase = { method, sessionId, params } const cdpEvent: CDPEventBase = { method, sessionId, params }
@@ -1168,7 +1215,8 @@ export async function startPlayWriterCDPRelayServer({
const targetParams = params as Protocol.Target.AttachedToTargetEvent const targetParams = params as Protocol.Target.AttachedToTargetEvent
const incomingSessionId = sessionId const incomingSessionId = sessionId
const iframeParentFrameId = targetParams.targetInfo.parentFrameId const iframeParentFrameId = targetParams.targetInfo.parentFrameId
const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId const iframeOwnerSessionId =
targetParams.targetInfo.type === 'iframe' && iframeParentFrameId
? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId ? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId
: undefined : undefined
@@ -1189,14 +1237,24 @@ export async function startPlayWriterCDPRelayServer({
logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), message) 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 return
} }
if (!targetParams.targetInfo.url) { 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) // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId) const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId)
@@ -1207,7 +1265,7 @@ export async function startPlayWriterCDPRelayServer({
sessionId: targetParams.sessionId, sessionId: targetParams.sessionId,
targetId: targetParams.targetInfo.targetId, targetId: targetParams.targetInfo.targetId,
targetInfo: targetParams.targetInfo, 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 // 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. // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
sessionId: iframeOwnerSessionId ?? incomingSessionId, sessionId: iframeOwnerSessionId ?? incomingSessionId,
method: 'Target.attachedToTarget', method: 'Target.attachedToTarget',
params: targetParams params: targetParams,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1235,7 +1293,7 @@ export async function startPlayWriterCDPRelayServer({
sendToPlaywright({ sendToPlaywright({
message: { message: {
method: 'Target.detachedFromTarget', method: 'Target.detachedFromTarget',
params: detachParams params: detachParams,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1253,7 +1311,7 @@ export async function startPlayWriterCDPRelayServer({
sendToPlaywright({ sendToPlaywright({
message: { message: {
method: 'Target.targetCrashed', method: 'Target.targetCrashed',
params: crashParams params: crashParams,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1270,7 +1328,7 @@ export async function startPlayWriterCDPRelayServer({
sendToPlaywright({ sendToPlaywright({
message: { message: {
method: 'Target.targetInfoChanged', method: 'Target.targetInfoChanged',
params: infoParams params: infoParams,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1288,7 +1346,7 @@ export async function startPlayWriterCDPRelayServer({
message: { message: {
sessionId, sessionId,
method, method,
params params,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1304,7 +1362,7 @@ export async function startPlayWriterCDPRelayServer({
message: { message: {
sessionId, sessionId,
method, method,
params params,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1325,7 +1383,10 @@ export async function startPlayWriterCDPRelayServer({
url: frameParams.frame.url, url: frameParams.frame.url,
title: frameParams.frame.name || target.targetInfo.title, 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: { message: {
sessionId, sessionId,
method, method,
params params,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1347,7 +1408,10 @@ export async function startPlayWriterCDPRelayServer({
...target.targetInfo, ...target.targetInfo,
url: navParams.url, 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: { message: {
sessionId, sessionId,
method, method,
params params,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1365,7 +1429,7 @@ export async function startPlayWriterCDPRelayServer({
message: { message: {
sessionId, sessionId,
method, method,
params params,
} as CDPEventBase, } as CDPEventBase,
source: 'extension', source: 'extension',
extensionId: connectionId, extensionId: connectionId,
@@ -1415,9 +1479,10 @@ export async function startPlayWriterCDPRelayServer({
onError(event) { onError(event) {
logger?.error('Extension WebSocket error:', event) logger?.error('Extension WebSocket error:', event)
},
} }
} }),
})) )
// ============================================================================ // ============================================================================
// CLI Execute Endpoints - For stateful code execution via CLI // 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. // preflight as a fallback, which our CORS policy already blocks.
// 3. When token mode is enabled (remote access), require the token. // 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. // Block cross-origin browser requests via Sec-Fetch-Site header.
// Browsers always set this forbidden header; it cannot be spoofed. // Browsers always set this forbidden header; it cannot be spoofed.
// Non-browser clients (Node.js, curl, MCP) don't send it. // 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) => { app.post('/cli/execute', async (c) => {
try { 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 sessionId = normalizeSessionId(body.sessionId)
const { code, timeout = 10000 } = body const { code, timeout = 10000 } = body
@@ -1508,7 +1576,10 @@ export async function startPlayWriterCDPRelayServer({
const manager = await getExecutorManager() const manager = await getExecutorManager()
const existingExecutor = manager.getSession(sessionId) const existingExecutor = manager.getSession(sessionId)
if (!existingExecutor) { 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) const result = await existingExecutor.execute(code, timeout)
@@ -1521,7 +1592,7 @@ export async function startPlayWriterCDPRelayServer({
app.post('/cli/reset', async (c) => { app.post('/cli/reset', async (c) => {
try { 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) const sessionId = normalizeSessionId(body.sessionId)
if (!sessionId) { if (!sessionId) {
@@ -1556,7 +1627,7 @@ export async function startPlayWriterCDPRelayServer({
}) })
app.post('/cli/session/new', async (c) => { 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 sessionId = String(nextSessionNumber++)
const extensionId = body.extensionId || null const extensionId = body.extensionId || null
const cwd = body.cwd const cwd = body.cwd
@@ -1605,7 +1676,7 @@ export async function startPlayWriterCDPRelayServer({
app.post('/cli/session/delete', async (c) => { app.post('/cli/session/delete', async (c) => {
try { 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) const sessionId = normalizeSessionId(body.sessionId)
if (!sessionId) { if (!sessionId) {
@@ -1630,7 +1701,14 @@ export async function startPlayWriterCDPRelayServer({
// ============================================================================ // ============================================================================
app.post('/recording/start', async (c) => { 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 = normalizeSessionId(body.sessionId)
const { sessionId: _sessionId, ...recordingOptions } = body const { sessionId: _sessionId, ...recordingOptions } = body
const manager = await getExecutorManager() const manager = await getExecutorManager()
@@ -1645,12 +1723,12 @@ export async function startPlayWriterCDPRelayServer({
} }
const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions) as StartRecordingBody const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions) as StartRecordingBody
const result = await relay.startRecording(recordingParams) 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) return c.json(result, status)
}) })
app.post('/recording/stop', async (c) => { 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 sessionId = normalizeSessionId(body.sessionId)
const manager = await getExecutorManager() const manager = await getExecutorManager()
const executor = sessionId ? manager.getSession(sessionId) : null const executor = sessionId ? manager.getSession(sessionId) : null
@@ -1664,7 +1742,7 @@ export async function startPlayWriterCDPRelayServer({
} }
const stopParams: StopRecordingParams = sessionId ? { sessionId } : {} const stopParams: StopRecordingParams = sessionId ? { sessionId } : {}
const result = await relay.stopRecording(stopParams) 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) return c.json(result, status)
}) })
@@ -1684,7 +1762,7 @@ export async function startPlayWriterCDPRelayServer({
}) })
app.post('/recording/cancel', async (c) => { 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 sessionId = normalizeSessionId(body.sessionId)
const manager = await getExecutorManager() const manager = await getExecutorManager()
const executor = sessionId ? manager.getSession(sessionId) : null 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]) { off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
emitter.off(event, listener as (...args: unknown[]) => void) emitter.off(event, listener as (...args: unknown[]) => void)
} },
} }
} }
+12 -3
View File
@@ -14,9 +14,15 @@ export interface ICDPSession {
sessionId?: string | null, sessionId?: string | null,
): Promise<ProtocolMapping.Commands[K]['returnType']> ): 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> detach(): Promise<void>
getSessionId?(): string | null getSessionId?(): string | null
@@ -46,7 +52,10 @@ export class PlaywrightCDPSessionAdapter implements ICDPSession {
return this 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) this._playwrightSession.off(event as never, callback as never)
return this return this
} }
+51 -51
View File
@@ -1,55 +1,55 @@
import type { Protocol } from 'devtools-protocol'; import type { Protocol } from 'devtools-protocol'
import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; 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> = { export type CDPCommandFor<T extends keyof ProtocolMapping.Commands> = {
id: number; id: number
sessionId?: string; sessionId?: string
method: T; method: T
params?: ProtocolMapping.Commands[T]['paramsType'][0]; params?: ProtocolMapping.Commands[T]['paramsType'][0]
source?: CDPCommandSource; source?: CDPCommandSource
}; }
export type CDPCommand = { export type CDPCommand = {
[K in keyof ProtocolMapping.Commands]: CDPCommandFor<K>; [K in keyof ProtocolMapping.Commands]: CDPCommandFor<K>
}[keyof ProtocolMapping.Commands]; }[keyof ProtocolMapping.Commands]
export type CDPResponseFor<T extends keyof ProtocolMapping.Commands> = { export type CDPResponseFor<T extends keyof ProtocolMapping.Commands> = {
id: number; id: number
sessionId?: string; sessionId?: string
result?: ProtocolMapping.Commands[T]['returnType']; result?: ProtocolMapping.Commands[T]['returnType']
error?: { code?: number; message: string }; error?: { code?: number; message: string }
}; }
export type CDPResponse = { export type CDPResponse = {
[K in keyof ProtocolMapping.Commands]: CDPResponseFor<K>; [K in keyof ProtocolMapping.Commands]: CDPResponseFor<K>
}[keyof ProtocolMapping.Commands]; }[keyof ProtocolMapping.Commands]
export type CDPEventFor<T extends keyof ProtocolMapping.Events> = { export type CDPEventFor<T extends keyof ProtocolMapping.Events> = {
method: T; method: T
sessionId?: string; sessionId?: string
params?: ProtocolMapping.Events[T][0]; params?: ProtocolMapping.Events[T][0]
}; }
export type CDPEvent = { export type CDPEvent = {
[K in keyof ProtocolMapping.Events]: CDPEventFor<K>; [K in keyof ProtocolMapping.Events]: CDPEventFor<K>
}[keyof ProtocolMapping.Events]; }[keyof ProtocolMapping.Events]
export type CDPResponseBase = { export type CDPResponseBase = {
id: number; id: number
sessionId?: string; sessionId?: string
result?: unknown; result?: unknown
error?: { code?: number; message: string }; error?: { code?: number; message: string }
}; }
export type CDPEventBase = { export type CDPEventBase = {
method: string; method: string
sessionId?: string; sessionId?: string
params?: unknown; params?: unknown
}; }
export type CDPMessage = CDPCommand | CDPResponse | CDPEvent; export type CDPMessage = CDPCommand | CDPResponse | CDPEvent
export type RelayServerEvents = { export type RelayServerEvents = {
'cdp:command': (data: { clientId: string; command: CDPCommand }) => void '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 '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 // types tests. to see if types are right with some simple examples
if (false as any) { if (false as any) {
const browserVersionCommand = { const browserVersionCommand = {
id: 1, id: 1,
method: 'Browser.getVersion', method: 'Browser.getVersion',
} satisfies CDPCommand; } satisfies CDPCommand
const browserVersionResponse = { const browserVersionResponse = {
id: 1, id: 1,
@@ -74,8 +74,8 @@ if (false as any) {
revision: '123', revision: '123',
userAgent: 'Mozilla/5.0', userAgent: 'Mozilla/5.0',
jsVersion: 'V8', jsVersion: 'V8',
} },
} satisfies CDPResponse; } satisfies CDPResponse
const targetAttachCommand = { const targetAttachCommand = {
id: 2, id: 2,
@@ -83,13 +83,13 @@ if (false as any) {
params: { params: {
autoAttach: true, autoAttach: true,
waitForDebuggerOnStart: false, waitForDebuggerOnStart: false,
} },
} satisfies CDPCommand; } satisfies CDPCommand
const targetAttachResponse = { const targetAttachResponse = {
id: 2, id: 2,
result: undefined, result: undefined,
} satisfies CDPResponse; } satisfies CDPResponse
const attachedToTargetEvent = { const attachedToTargetEvent = {
method: 'Target.attachedToTarget', method: 'Target.attachedToTarget',
@@ -104,8 +104,8 @@ if (false as any) {
canAccessOpener: false, canAccessOpener: false,
}, },
waitingForDebugger: false, waitingForDebugger: false,
} },
} satisfies CDPEvent; } satisfies CDPEvent
const consoleMessageEvent = { const consoleMessageEvent = {
method: 'Runtime.consoleAPICalled', method: 'Runtime.consoleAPICalled',
@@ -114,23 +114,23 @@ if (false as any) {
args: [], args: [],
executionContextId: 1, executionContextId: 1,
timestamp: 123456789, timestamp: 123456789,
} },
} satisfies CDPEvent; } satisfies CDPEvent
const pageNavigateCommand = { const pageNavigateCommand = {
id: 3, id: 3,
method: 'Page.navigate', method: 'Page.navigate',
params: { params: {
url: 'https://example.com', url: 'https://example.com',
} },
} satisfies CDPCommand; } satisfies CDPCommand
const pageNavigateResponse = { const pageNavigateResponse = {
id: 3, id: 3,
result: { result: {
frameId: 'frame-1', frameId: 'frame-1',
} },
} satisfies CDPResponse; } satisfies CDPResponse
const networkRequestEvent = { const networkRequestEvent = {
method: 'Network.requestWillBeSent', method: 'Network.requestWillBeSent',
@@ -153,6 +153,6 @@ if (false as any) {
}, },
redirectHasExtraInfo: false, redirectHasExtraInfo: false,
type: 'XHR', 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 { killPortProcess } from './kill-port.js'
import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.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)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -76,7 +82,7 @@ async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]>
if (!fallback.ok) { if (!fallback.ok) {
return [] return []
} }
const fallbackData = await fallback.json() as { const fallbackData = (await fallback.json()) as {
connected: boolean connected: boolean
activeTargets: number activeTargets: number
browser: string | null browser: string | null
@@ -86,16 +92,18 @@ async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]>
if (!fallbackData?.connected) { if (!fallbackData?.connected) {
return [] return []
} }
return [{ return [
{
extensionId: 'default', extensionId: 'default',
stableKey: undefined, stableKey: undefined,
browser: fallbackData?.browser, browser: fallbackData?.browser,
profile: fallbackData?.profile, profile: fallbackData?.profile,
activeTargets: fallbackData?.activeTargets, activeTargets: fallbackData?.activeTargets,
playwriterVersion: fallbackData?.playwriterVersion || null, playwriterVersion: fallbackData?.playwriterVersion || null,
}] },
]
} }
const data = await response.json() as { const data = (await response.json()) as {
extensions: ExtensionStatus[] extensions: ExtensionStatus[]
} }
return data?.extensions || [] return data?.extensions || []
@@ -150,7 +158,9 @@ async function executeCode(options: {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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 }), body: JSON.stringify({ sessionId, code, timeout, cwd }),
}) })
@@ -161,7 +171,11 @@ async function executeCode(options: {
process.exit(1) 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 // Print output
if (result.text) { if (result.text) {
@@ -206,7 +220,6 @@ cli
}) })
} }
const extensions = await fetchExtensionsStatus(options.host) const extensions = await fetchExtensionsStatus(options.host)
if (extensions.length === 0) { if (extensions.length === 0) {
console.error('No connected browsers detected. Click the Playwriter extension icon.') console.error('No connected browsers detected. Click the Playwriter extension icon.')
@@ -253,9 +266,10 @@ cli
try { try {
const serverUrl = await getServerUrl(options.host) const serverUrl = await getServerUrl(options.host)
const extensionId = selectedExtension.extensionId === 'default' const extensionId =
selectedExtension.extensionId === 'default'
? null ? null
: (selectedExtension.stableKey || selectedExtension.extensionId) : selectedExtension.stableKey || selectedExtension.extensionId
const cwd = process.cwd() const cwd = process.cwd()
const response = await fetch(`${serverUrl}/cli/session/new`, { const response = await fetch(`${serverUrl}/cli/session/new`, {
method: 'POST', method: 'POST',
@@ -267,7 +281,7 @@ cli
console.error(`Error: ${response.status} ${text}`) console.error(`Error: ${response.status} ${text}`)
process.exit(1) 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 "..."`) console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
} catch (error: any) { } catch (error: any) {
console.error(`Error: ${error.message}`) console.error(`Error: ${error.message}`)
@@ -300,7 +314,7 @@ cli
console.error(`Error: ${response.status} ${await response.text()}`) console.error(`Error: ${response.status} ${await response.text()}`)
process.exit(1) process.exit(1)
} }
const result = await response.json() as { const result = (await response.json()) as {
sessions: Array<{ sessions: Array<{
id: string id: string
stateKeys: string[] stateKeys: string[]
@@ -335,7 +349,7 @@ cli
' ' + ' ' +
'EXT'.padEnd(extensionWidth) + 'EXT'.padEnd(extensionWidth) +
' ' + ' ' +
'STATE KEYS' 'STATE KEYS',
) )
console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8)) console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8))
@@ -351,7 +365,7 @@ cli
' ' + ' ' +
(session.extensionId || '-').padEnd(extensionWidth) + (session.extensionId || '-').padEnd(extensionWidth) +
' ' + ' ' +
stateStr stateStr,
) )
} }
}) })
@@ -374,7 +388,7 @@ cli
}) })
if (!response.ok) { if (!response.ok) {
const result = await response.json() as { error: string } const result = (await response.json()) as { error: string }
console.error(`Error: ${result.error}`) console.error(`Error: ${result.error}`)
process.exit(1) process.exit(1)
} }
@@ -410,8 +424,10 @@ cli
process.exit(1) process.exit(1)
} }
const result = await response.json() as { success: boolean; pageUrl: string; pagesCount: number } 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}`) console.log(
`Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`,
)
} catch (error: any) { } catch (error: any) {
console.error(`Error: ${error.message}`) console.error(`Error: ${error.message}`)
process.exit(1) process.exit(1)
@@ -512,16 +528,12 @@ cli
}) })
}) })
cli cli.command('logfile', 'Print the path to the relay server log file').action(() => {
.command('logfile', 'Print the path to the relay server log file')
.action(() => {
console.log(`relay: ${LOG_FILE_PATH}`) console.log(`relay: ${LOG_FILE_PATH}`)
console.log(`cdp: ${LOG_CDP_FILE_PATH}`) console.log(`cdp: ${LOG_CDP_FILE_PATH}`)
}) })
cli cli.command('skill', 'Print the full playwriter usage instructions').action(() => {
.command('skill', 'Print the full playwriter usage instructions')
.action(() => {
const skillPath = path.join(__dirname, '..', 'src', 'skill.md') const skillPath = path.join(__dirname, '..', 'src', 'skill.md')
const content = fs.readFileSync(skillPath, 'utf-8') const content = fs.readFileSync(skillPath, 'utf-8')
console.log(content) console.log(content)
+5 -3
View File
@@ -21,9 +21,11 @@ export function createFileLogger({ logFilePath }: { logFilePath?: string } = {})
let queue: Promise<void> = Promise.resolve() let queue: Promise<void> = Promise.resolve()
const log = (...args: unknown[]): Promise<void> => { const log = (...args: unknown[]): Promise<void> => {
const message = args.map(arg => const message = args
typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false, maxStringLength: 1000 }) .map((arg) =>
).join(' ') typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false, maxStringLength: 1000 }),
)
.join(' ')
queue = queue.then(() => fs.promises.appendFile(resolvedLogFilePath, stripAnsi(message) + '\n')) queue = queue.then(() => fs.promises.appendFile(resolvedLogFilePath, stripAnsi(message) + '\n'))
return queue 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 getCDPSession: (options: { page: Page }) => Promise<ICDPSession>
export declare const createDebugger: (options: { cdp: ICDPSession }) => Debugger export declare const createDebugger: (options: { cdp: ICDPSession }) => Debugger
export declare const createEditor: (options: { cdp: ICDPSession }) => Editor 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 formatStylesAsText: (styles: StylesResult) => string
export declare const console: { log: (...args: unknown[]) => void } export declare const console: { log: (...args: unknown[]) => void }
+1 -5
View File
@@ -24,8 +24,6 @@ export interface EvaluateResult {
value: unknown value: unknown
} }
export interface ScriptInfo { export interface ScriptInfo {
scriptId: string scriptId: string
url: string url: string
@@ -533,9 +531,7 @@ export class Debugger {
async listScripts({ search }: { search?: string } = {}): Promise<ScriptInfo[]> { async listScripts({ search }: { search?: string } = {}): Promise<ScriptInfo[]> {
await this.enable() await this.enable()
const scripts = Array.from(this.scripts.values()) const scripts = Array.from(this.scripts.values())
const filtered = search const filtered = search ? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase())) : scripts
? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase()))
: scripts
return filtered.slice(0, 20) 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% const changeRatio = Math.min(changedLines / maxLines, 1) // Cap at 100%
// Build unified diff string from structured patch // Build unified diff string from structured patch
const diffLines: string[] = [ const diffLines: string[] = [`--- ${label} (previous)`, `+++ ${label} (current)`]
`--- ${label} (previous)`,
`+++ ${label} (current)`,
]
for (const hunk of patch.hunks) { for (const hunk of patch.hunks) {
diffLines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`) diffLines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`)
diffLines.push(...hunk.lines) diffLines.push(...hunk.lines)
+11 -1
View File
@@ -145,4 +145,14 @@ async function searchStyles() {
console.log(matches) 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( private async setSource(
id: { scriptId: string } | { styleSheetId: string }, id: { scriptId: string } | { styleSheetId: string },
content: string, content: string,
dryRun = false dryRun = false,
): Promise<EditResult> { ): Promise<EditResult> {
if ('styleSheetId' in id) { if ('styleSheetId' in id) {
await this.cdp.send('CSS.setStyleSheetText', { styleSheetId: id.styleSheetId, text: content }) 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.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS) * @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() await this.enable()
const id = this.getIdByUrl(url) const id = this.getIdByUrl(url)
return this.setSource(id, content, dryRun) return this.setSource(id, content, dryRun)
+52 -14
View File
@@ -452,7 +452,7 @@ export class PlaywrightExecutor {
private setupPageConsoleListener(page: Page) { private setupPageConsoleListener(page: Page) {
// Use targetId() if available, fallback to internal _guid for CDP connections // 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) { if (!targetId) {
return 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 { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
const { httpBaseUrl } = parseRelayHost(host, port) const { httpBaseUrl } = parseRelayHost(host, port)
const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null } const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
@@ -504,10 +508,19 @@ export class PlaywrightExecutor {
if (!fallback.ok) { if (!fallback.ok) {
return notConnected 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) => { const extension = data.extensions.find((item) => {
return item.extensionId === extensionId || item.stableKey === extensionId return item.extensionId === extensionId || item.stableKey === extensionId
@@ -515,7 +528,11 @@ export class PlaywrightExecutor {
if (!extension) { if (!extension) {
return notConnected 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`, { const response = await fetch(`${httpBaseUrl}/extension/status`, {
@@ -663,7 +680,13 @@ export class PlaywrightExecutor {
const formattedArgs = args const formattedArgs = args
.map((arg) => { .map((arg) => {
if (typeof arg === 'string') return 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(' ') .join(' ')
text += `[${method}] ${formattedArgs}\n` text += `[${method}] ${formattedArgs}\n`
@@ -710,14 +733,25 @@ export class PlaywrightExecutor {
/** Only include interactive elements (default: true) */ /** Only include interactive elements (default: true) */
interactiveOnly?: boolean 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 const resolvedPage = targetPage || page
if (!resolvedPage) { if (!resolvedPage) {
throw new Error('snapshot requires a page') throw new Error('snapshot requires a page')
} }
// Use new in-page implementation via getAriaSnapshot // Use new in-page implementation via getAriaSnapshot
const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({ const {
snapshot: rawSnapshot,
refs,
getSelectorForRef,
} = await getAriaSnapshot({
page: resolvedPage, page: resolvedPage,
frame, frame,
locator, locator,
@@ -829,7 +863,7 @@ export class PlaywrightExecutor {
if (filterPage) { if (filterPage) {
// Use targetId() if available, fallback to internal _guid for CDP connections // 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) { if (!targetId) {
throw new Error('Could not get page targetId') throw new Error('Could not get page targetId')
} }
@@ -984,9 +1018,7 @@ export class PlaywrightExecutor {
const vmContext = vm.createContext(vmContextObj) const vmContext = vm.createContext(vmContextObj)
const autoReturn = shouldAutoReturn(code) const autoReturn = shouldAutoReturn(code)
const wrappedCode = autoReturn const wrappedCode = autoReturn ? `(async () => { return await (${code}) })()` : `(async () => { ${code} })()`
? `(async () => { return await (${code}) })()`
: `(async () => { ${code} })()`
const hasExplicitReturn = autoReturn || /\breturn\b/.test(code) const hasExplicitReturn = autoReturn || /\breturn\b/.test(code)
const result = await Promise.race([ const result = await Promise.race([
@@ -1003,7 +1035,13 @@ export class PlaywrightExecutor {
const formatted = const formatted =
typeof resolvedResult === 'string' typeof resolvedResult === 'string'
? resolvedResult ? 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()) { if (formatted.trim()) {
responseText += `[return value] ${formatted}\n` responseText += `[return value] ${formatted}\n`
} }
+52 -41
View File
@@ -53,7 +53,7 @@ describe('Extension Connection Tests', () => {
let contexts = directBrowser.contexts() let contexts = directBrowser.contexts()
let pages = contexts[0].pages() 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).toBeDefined()
expect(foundPage?.url()).toBe(testUrl) expect(foundPage?.url()).toBe(testUrl)
@@ -73,7 +73,7 @@ describe('Extension Connection Tests', () => {
contexts = directBrowser.contexts() contexts = directBrowser.contexts()
pages = contexts[0].pages() pages = contexts[0].pages()
foundPage = pages.find(p => p.url() === testUrl) foundPage = pages.find((p) => p.url() === testUrl)
expect(foundPage).toBeUndefined() expect(foundPage).toBeUndefined()
await directBrowser.close() await directBrowser.close()
@@ -86,15 +86,15 @@ describe('Extension Connection Tests', () => {
// 7. Verify page is back // 7. Verify page is back
directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) 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() contexts = directBrowser.contexts()
if (contexts[0].pages().length === 0) { if (contexts[0].pages().length === 0) {
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
} }
pages = contexts[0].pages() pages = contexts[0].pages()
foundPage = pages.find(p => p.url() === testUrl) foundPage = pages.find((p) => p.url() === testUrl)
expect(foundPage).toBeDefined() expect(foundPage).toBeDefined()
expect(foundPage?.url()).toBe(testUrl) expect(foundPage?.url()).toBe(testUrl)
@@ -110,7 +110,7 @@ describe('Extension Connection Tests', () => {
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)
const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) 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 // 1. Create a new page
const page = await browserContext.newPage() const page = await browserContext.newPage()
@@ -127,9 +127,9 @@ describe('Extension Connection Tests', () => {
let foundPage let foundPage
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
const pages = directBrowser.contexts()[0].pages() const pages = directBrowser.contexts()[0].pages()
foundPage = pages.find(p => p.url() === testUrl) foundPage = pages.find((p) => p.url() === testUrl)
if (foundPage) break if (foundPage) break
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
} }
expect(foundPage).toBeDefined() expect(foundPage).toBeDefined()
expect(foundPage?.url()).toBe(testUrl) expect(foundPage?.url()).toBe(testUrl)
@@ -145,9 +145,9 @@ describe('Extension Connection Tests', () => {
// 5. Verify page disappears (polling) // 5. Verify page disappears (polling)
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
const pages = directBrowser.contexts()[0].pages() const pages = directBrowser.contexts()[0].pages()
foundPage = pages.find(p => p.url() === testUrl) foundPage = pages.find((p) => p.url() === testUrl)
if (!foundPage) break if (!foundPage) break
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
} }
expect(foundPage).toBeUndefined() expect(foundPage).toBeUndefined()
@@ -159,9 +159,9 @@ describe('Extension Connection Tests', () => {
// 7. Verify page reappears (polling) // 7. Verify page reappears (polling)
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
const pages = directBrowser.contexts()[0].pages() const pages = directBrowser.contexts()[0].pages()
foundPage = pages.find(p => p.url() === testUrl) foundPage = pages.find((p) => p.url() === testUrl)
if (foundPage) break if (foundPage) break
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
} }
expect(foundPage).toBeDefined() expect(foundPage).toBeDefined()
expect(foundPage?.url()).toBe(testUrl) expect(foundPage?.url()).toBe(testUrl)
@@ -191,7 +191,10 @@ describe('Extension Connection Tests', () => {
// 3. Connect via CDP // 3. Connect via CDP
const cdpUrl = getCdpUrl({ port: TEST_PORT }) const cdpUrl = getCdpUrl({ port: TEST_PORT })
const directBrowser = await chromium.connectOverCDP(cdpUrl) 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(connectedPage).toBeDefined()
expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2) expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2)
@@ -220,13 +223,13 @@ describe('Extension Connection Tests', () => {
it('should support multiple concurrent tabs', async () => { it('should support multiple concurrent tabs', async () => {
const browserContext = getBrowserContext() const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
// Tab A // Tab A
const pageA = await browserContext.newPage() const pageA = await browserContext.newPage()
await pageA.goto('https://example.com/tab-a') await pageA.goto('https://example.com/tab-a')
await pageA.bringToFront() await pageA.bringToFront()
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
@@ -235,7 +238,7 @@ describe('Extension Connection Tests', () => {
const pageB = await browserContext.newPage() const pageB = await browserContext.newPage()
await pageB.goto('https://example.com/tab-b') await pageB.goto('https://example.com/tab-b')
await pageB.bringToFront() await pageB.bringToFront()
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
@@ -249,19 +252,22 @@ describe('Extension Connection Tests', () => {
const tabB = tabs.find((t: any) => t.url?.includes('tab-b')) const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
return { return {
idA: state.tabs.get(tabA?.id ?? -1)?.targetId, 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), idA: expect.any(String),
idB: expect.any(String) idB: expect.any(String),
}, ` },
`
{ {
"idA": Any<String>, "idA": Any<String>,
"idB": Any<String>, "idB": Any<String>,
} }
`) `,
)
expect(targetIds.idA).not.toBe(targetIds.idB) expect(targetIds.idA).not.toBe(targetIds.idB)
// Verify independent connections // Verify independent connections
@@ -269,10 +275,12 @@ describe('Extension Connection Tests', () => {
const pages = browser.contexts()[0].pages() 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(), url: p.url(),
title: await p.title() title: await p.title(),
}))) })),
)
expect(results).toMatchInlineSnapshot(` expect(results).toMatchInlineSnapshot(`
[ [
@@ -292,8 +300,8 @@ describe('Extension Connection Tests', () => {
`) `)
// Verify execution on both pages // Verify execution on both pages
const pageA_CDP = pages.find(p => p.url().includes('tab-a')) const pageA_CDP = pages.find((p) => p.url().includes('tab-a'))
const pageB_CDP = pages.find(p => p.url().includes('tab-b')) const pageB_CDP = pages.find((p) => p.url().includes('tab-b'))
expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20) expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40) 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 })) 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).toBeDefined()
expect(cdpPage?.url()).toBe(targetUrl) expect(cdpPage?.url()).toBe(targetUrl)
@@ -462,7 +473,7 @@ describe('Extension Connection Tests', () => {
console.log('Initial enable result:', initialEnable) console.log('Initial enable result:', initialEnable)
expect(initialEnable.isConnected).toBe(true) 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 // Verify MCP can see the page
const beforeDisconnect = await client.callTool({ const beforeDisconnect = await client.callTool({
@@ -488,7 +499,7 @@ describe('Extension Connection Tests', () => {
await globalThis.disconnectEverything() 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) // 3. Verify MCP cannot execute code anymore (no pages available)
const afterDisconnect = await client.callTool({ const afterDisconnect = await client.callTool({
@@ -521,7 +532,7 @@ describe('Extension Connection Tests', () => {
expect(reconnectResult.isConnected).toBe(true) expect(reconnectResult.isConnected).toBe(true)
console.log('Waiting for reconnection to stabilize...') 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 // 5. Reset the MCP client's playwright connection
console.log('Resetting MCP playwright connection...') console.log('Resetting MCP playwright connection...')
@@ -582,7 +593,7 @@ describe('Extension Connection Tests', () => {
return await globalThis.toggleExtensionForActiveTab() return await globalThis.toggleExtensionForActiveTab()
}) })
expect(initialEnable.isConnected).toBe(true) 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 // 2. Verify MCP can execute commands
const beforeResult = await client.callTool({ const beforeResult = await client.callTool({
@@ -610,7 +621,7 @@ describe('Extension Connection Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything() await globalThis.disconnectEverything()
}) })
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
// Re-enable extension // Re-enable extension
await page.bringToFront() await page.bringToFront()
@@ -618,7 +629,7 @@ describe('Extension Connection Tests', () => {
return await globalThis.toggleExtensionForActiveTab() return await globalThis.toggleExtensionForActiveTab()
}) })
expect(reconnectResult.isConnected).toBe(true) 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() // 4. Execute command WITHOUT calling resetPlaywright()
const afterResult = await client.callTool({ const afterResult = await client.callTool({
@@ -662,11 +673,11 @@ describe('Extension Connection Tests', () => {
await globalThis.toggleExtensionForActiveTab() 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 browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPages = browser.contexts()[0].pages() 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).toBeDefined()
expect(testPage?.url()).toContain('sw-test') expect(testPage?.url()).toContain('sw-test')
@@ -692,13 +703,13 @@ describe('Extension Connection Tests', () => {
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPages = browser.contexts()[0].pages() 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).toBeDefined()
expect(testPage?.url()).toBe(targetUrl) expect(testPage?.url()).toBe(targetUrl)
await browser.close() await browser.close()
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
} }
await page.close() await page.close()
@@ -717,7 +728,7 @@ describe('Extension Connection Tests', () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
const [mcpResult, cdpBrowser] = await Promise.all([ const [mcpResult, cdpBrowser] = await Promise.all([
client.callTool({ 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 const mcpOutput = (mcpResult as any).content[0].text
expect(mcpOutput).toContain(targetUrl) expect(mcpOutput).toContain(targetUrl)
const cdpPages = cdpBrowser.contexts()[0].pages() 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) expect(cdpPage?.url()).toBe(targetUrl)
await cdpBrowser.close() await cdpBrowser.close()
+4 -6
View File
@@ -40,9 +40,7 @@ export type GhostBrowserCommandParams = {
args: unknown[] args: unknown[]
} }
export type GhostBrowserCommandResult = export type GhostBrowserCommandResult = { success: true; result: unknown } | { success: false; error: string }
| { success: true; result: unknown }
| { success: false; error: string }
/** /**
* Function signature for sending ghost-browser commands. * Function signature for sending ghost-browser commands.
@@ -52,7 +50,7 @@ export type GhostBrowserCommandResult =
export type SendGhostBrowserCommand = ( export type SendGhostBrowserCommand = (
namespace: GhostBrowserNamespace, namespace: GhostBrowserNamespace,
method: string, method: string,
args: unknown[] args: unknown[],
) => Promise<unknown> ) => Promise<unknown>
// ============================================================================= // =============================================================================
@@ -66,7 +64,7 @@ export type SendGhostBrowserCommand = (
function createGhostBrowserProxy( function createGhostBrowserProxy(
namespace: GhostBrowserNamespace, namespace: GhostBrowserNamespace,
constants: Record<string, unknown>, constants: Record<string, unknown>,
sendCommand: SendGhostBrowserCommand sendCommand: SendGhostBrowserCommand,
) { ) {
return new Proxy(constants, { return new Proxy(constants, {
get(target, prop: string) { get(target, prop: string) {
@@ -108,7 +106,7 @@ export function createGhostBrowserChrome(sendCommand: SendGhostBrowserCommand) {
*/ */
export async function handleGhostBrowserCommand( export async function handleGhostBrowserCommand(
params: GhostBrowserCommandParams, params: GhostBrowserCommandParams,
chromeApi: typeof chrome chromeApi: typeof chrome,
): Promise<GhostBrowserCommandResult> { ): Promise<GhostBrowserCommandResult> {
const { namespace, method, args } = params 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(`\n📊 x.com.html processing stats:`)
console.log(` Original: ${originalSize.toLocaleString()} chars (${originalTokens.toLocaleString()} tokens)`) console.log(` Original: ${originalSize.toLocaleString()} chars (${originalTokens.toLocaleString()} tokens)`)
console.log(` Without styles: ${processedSize.toLocaleString()} chars (${processedTokens.toLocaleString()} tokens) - ${savingsPercent}% savings`) console.log(
console.log(` With styles: ${withStylesSize.toLocaleString()} chars (${withStylesTokens.toLocaleString()} tokens) - ${withStylesPercent}% savings`) ` 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(result).toMatchFileSnapshot('./__snapshots__/x.com.processed.html')
await expect(resultWithStyles).toMatchFileSnapshot('./__snapshots__/x.com.processed.withStyles.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, maxAttrLen = 200,
maxContentLen = 500, maxContentLen = 500,
}: FormatHtmlOptions) { }: FormatHtmlOptions) {
const tagsToRemove = [ const tagsToRemove = ['hint', 'style', 'link', 'script', 'meta', 'noscript', 'svg', 'head']
'hint',
'style',
'link',
'script',
'meta',
'noscript',
'svg',
'head',
]
const attributesToKeep = [ const attributesToKeep = [
// Standard descriptive attributes // Standard descriptive attributes
@@ -108,15 +99,11 @@ export async function formatHtmlForPrompt({
if (node.attrs) { if (node.attrs) {
const newAttrs: typeof node.attrs = {} const newAttrs: typeof node.attrs = {}
for (const [attr, value] of Object.entries(node.attrs)) { for (const [attr, value] of Object.entries(node.attrs)) {
const shouldKeep = const shouldKeep = attr.startsWith('data-') || attributesToKeep.includes(attr)
attr.startsWith('data-') ||
attributesToKeep.includes(attr)
if (shouldKeep) { if (shouldKeep) {
// Truncate attribute values // Truncate attribute values
newAttrs[attr] = typeof value === 'string' newAttrs[attr] = typeof value === 'string' ? truncate(value, maxAttrLen) : value
? truncate(value, maxAttrLen)
: value
} }
} }
node.attrs = newAttrs node.attrs = newAttrs
@@ -124,9 +111,7 @@ export async function formatHtmlForPrompt({
// Process content recursively // Process content recursively
if (node.content && Array.isArray(node.content)) { if (node.content && Array.isArray(node.content)) {
node.content = node.content node.content = node.content.map(processNode).filter((item) => {
.map(processNode)
.filter(item => {
if (item === null) return false if (item === null) return false
if (typeof item === 'string') { if (typeof item === 'string') {
const trimmed = item.trim() const trimmed = item.trim()
@@ -140,7 +125,7 @@ export async function formatHtmlForPrompt({
} }
// Process all root nodes // 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 // Process children recursively
if (node.content && Array.isArray(node.content)) { if (node.content && Array.isArray(node.content)) {
node.content = node.content node.content = node.content.map(processNode).filter((item) => item !== null)
.map(processNode)
.filter((item) => item !== null)
} }
return node return node
@@ -189,9 +172,7 @@ export async function formatHtmlForPrompt({
// Process children recursively // Process children recursively
if (node.content && Array.isArray(node.content)) { if (node.content && Array.isArray(node.content)) {
node.content = node.content node.content = node.content.map(processNode).filter((item) => item !== null)
.map(processNode)
.filter((item) => item !== null)
} }
return node return node
@@ -207,15 +188,7 @@ export async function formatHtmlForPrompt({
// - No actionable elements with meaningful attributes // - No actionable elements with meaningful attributes
const removeDecorativeSubtreesPlugin = () => { const removeDecorativeSubtreesPlugin = () => {
const actionableTags = ['button', 'a', 'input', 'select', 'textarea'] const actionableTags = ['button', 'a', 'input', 'select', 'textarea']
const meaningfulAttrs = [ const meaningfulAttrs = ['aria-label', 'title', 'alt', 'value', 'placeholder', 'href', 'name']
'aria-label',
'title',
'alt',
'value',
'placeholder',
'href',
'name',
]
// Form elements are always actionable, keep unconditionally // Form elements are always actionable, keep unconditionally
const formTags = ['input', 'select', 'textarea'] const formTags = ['input', 'select', 'textarea']
@@ -271,24 +244,12 @@ export async function formatHtmlForPrompt({
// First process children // First process children
if (node.content && Array.isArray(node.content)) { if (node.content && Array.isArray(node.content)) {
node.content = node.content node.content = node.content.map(processNode).filter((item) => item !== null)
.map(processNode)
.filter((item) => item !== null)
} }
// After processing children, check if this subtree is now decorative // After processing children, check if this subtree is now decorative
// Skip root-level semantic elements (body, main, etc.) // Skip root-level semantic elements (body, main, etc.)
const semanticTags = [ const semanticTags = ['html', 'body', 'main', 'header', 'footer', 'nav', 'section', 'article', 'aside']
'html',
'body',
'main',
'header',
'footer',
'nav',
'section',
'article',
'aside',
]
if (semanticTags.includes(node.tag.toLowerCase())) { if (semanticTags.includes(node.tag.toLowerCase())) {
return node return node
} }
@@ -330,7 +291,7 @@ export async function formatHtmlForPrompt({
// - has no attributes // - has no attributes
// - has exactly one non-whitespace child that is an element // - has exactly one non-whitespace child that is an element
if (hasNoAttrs(node) && node.content && Array.isArray(node.content)) { 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) { if (nonWhitespaceChildren.length === 1) {
const onlyChild = nonWhitespaceChildren[0] const onlyChild = nonWhitespaceChildren[0]
@@ -367,9 +328,8 @@ export async function formatHtmlForPrompt({
if (typeof node === 'string') return false if (typeof node === 'string') return false
if (!node.tag) return false if (!node.tag) return false
const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0 const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0
const hasContent = node.content && node.content.some(c => const hasContent =
typeof c === 'string' ? c.trim().length > 0 : true node.content && node.content.some((c) => (typeof c === 'string' ? c.trim().length > 0 : true))
)
return !hasAttrs && !hasContent return !hasAttrs && !hasContent
} }
@@ -377,14 +337,14 @@ export async function formatHtmlForPrompt({
if (!content || !Array.isArray(content)) return content if (!content || !Array.isArray(content)) return content
return content return content
.map(node => { .map((node) => {
if (typeof node === 'string') return node if (typeof node === 'string') return node
if (node.content) { if (node.content) {
node.content = removeEmpty(node.content) node.content = removeEmpty(node.content)
} }
return node return node
}) })
.filter(node => !isEmptyElement(node)) .filter((node) => !isEmptyElement(node))
} }
// Apply multiple passes until stable // Apply multiple passes until stable
@@ -409,17 +369,19 @@ export async function formatHtmlForPrompt({
.use(removeDecorativeSubtreesPlugin()) .use(removeDecorativeSubtreesPlugin())
.use(removeEmptyElementsPlugin()) .use(removeEmptyElementsPlugin())
.use(unwrapNestedWrappersPlugin()) .use(unwrapNestedWrappersPlugin())
.use(beautify({ .use(
beautify({
rules: { rules: {
indent: 1, // 1-space indent indent: 1, // 1-space indent
blankLines: false, // no extra blank lines blankLines: false, // no extra blank lines
maxlen: 100000 // effectively never wrap by content length maxlen: 100000, // effectively never wrap by content length
}, },
jsBeautifyOptions: { jsBeautifyOptions: {
wrap_line_length: 0, // disable js-beautify wrapping wrap_line_length: 0, // disable js-beautify wrapping
preserve_newlines: false // reduce stray newlines preserve_newlines: false, // reduce stray newlines
} },
})) }),
)
// Process with await // Process with await
const result = await processor.process(html) 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}`) throw new Error(`Invalid port: ${port}`)
} }
return os.platform() === 'win32' return os.platform() === 'win32' ? await getPidsForPortWindows(port) : await getPidsForPortUnix(port)
? await getPidsForPortWindows(port)
: await getPidsForPortUnix(port)
} }
function toError(value: unknown): Error { 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 stderr: Stream | null
}> { }> {
const env: Record<string, string> = { const env: Record<string, string> = {
...process.env as Record<string, string>, ...(process.env as Record<string, string>),
DEBUG: 'playwriter:mcp:test', DEBUG: 'playwriter:mcp:test',
DEBUG_COLORS: '0', DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1', DEBUG_HIDE_DATE: '1',
+4 -1
View File
@@ -262,7 +262,10 @@ server.tool(
const pagesCount = context.pages().length const pagesCount = context.pages().length
return { return {
content: [ 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) { } catch (error: any) {
+2 -2
View File
@@ -81,7 +81,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
} }
// Extract content using Readability // Extract content using Readability
const result = await page.evaluate(() => { const result = (await page.evaluate(() => {
const readability = (globalThis as any).__readability const readability = (globalThis as any).__readability
if (!readability) { if (!readability) {
throw new Error('Readability not loaded') throw new Error('Readability not loaded')
@@ -131,7 +131,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
publishedTime: article.publishedTime || null, publishedTime: article.publishedTime || null,
wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length, wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length,
} }
}) as PageMarkdownResult & { _notReadable?: boolean } })) as PageMarkdownResult & { _notReadable?: boolean }
// Format output // Format output
const lines: string[] = [] const lines: string[] = []
+25 -13
View File
@@ -2,8 +2,7 @@ import { CDPEventFor, ProtocolMapping } from './cdp-types.js'
export const VERSION = 1 export const VERSION = 1
type ForwardCDPCommand = type ForwardCDPCommand = {
{
[K in keyof ProtocolMapping.Commands]: { [K in keyof ProtocolMapping.Commands]: {
id: number id: number
method: 'forwardCDPCommand' method: 'forwardCDPCommand'
@@ -29,8 +28,7 @@ export type ExtensionResponseMessage = {
* This produces a discriminated union for narrowing, similar to ForwardCDPCommand, * This produces a discriminated union for narrowing, similar to ForwardCDPCommand,
* but for forwarded CDP events. Uses CDPEvent to maintain proper type extraction. * but for forwarded CDP events. Uses CDPEvent to maintain proper type extraction.
*/ */
export type ExtensionEventMessage = export type ExtensionEventMessage = {
{
[K in keyof ProtocolMapping.Events]: { [K in keyof ProtocolMapping.Events]: {
id?: undefined id?: undefined
method: 'forwardCDPEvent' 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) // Recording command messages (MCP -> Extension via relay)
export type StartRecordingParams = { export type StartRecordingParams = {
@@ -137,33 +141,39 @@ export type RecordingCommandMessage =
| CancelRecordingMessage | CancelRecordingMessage
// Recording result types // Recording result types
export type StartRecordingResult = { export type StartRecordingResult =
| {
success: true success: true
tabId: number tabId: number
startedAt: number startedAt: number
} | { }
| {
success: false success: false
error: string error: string
} }
/** Result from extension - doesn't include path/size since relay writes the file */ /** Result from extension - doesn't include path/size since relay writes the file */
export type ExtensionStopRecordingResult = { export type ExtensionStopRecordingResult =
| {
success: true success: true
tabId: number tabId: number
duration: number duration: number
} | { }
| {
success: false success: false
error: string error: string
} }
/** Final result from relay - includes path/size after file is written */ /** Final result from relay - includes path/size after file is written */
export type StopRecordingResult = { export type StopRecordingResult =
| {
success: true success: true
tabId: number tabId: number
duration: number duration: number
path: string path: string
size: number size: number
} | { }
| {
success: false success: false
error: string error: string
} }
@@ -193,10 +203,12 @@ export type GhostBrowserCommandMessage = {
} }
} }
export type GhostBrowserCommandResult = { export type GhostBrowserCommandResult =
| {
success: true success: true
result: unknown result: unknown
} | { }
| {
success: false success: false
error: string error: string
} }
+19 -11
View File
@@ -40,7 +40,7 @@ export class RecordingRelay {
constructor( constructor(
sendToExtension: (params: { method: string; params?: unknown; timeout?: number }) => Promise<unknown>, sendToExtension: (params: { method: string; params?: unknown; timeout?: number }) => Promise<unknown>,
isExtensionConnected: () => boolean, isExtensionConnected: () => boolean,
logger?: { log(...args: unknown[]): void; error(...args: unknown[]): void } logger?: { log(...args: unknown[]): void; error(...args: unknown[]): void },
) { ) {
this.sendToExtension = sendToExtension this.sendToExtension = sendToExtension
this.isExtensionConnected = isExtensionConnected this.isExtensionConnected = isExtensionConnected
@@ -58,7 +58,11 @@ export class RecordingRelay {
const recording = this.activeRecordings.get(tabId) const recording = this.activeRecordings.get(tabId)
if (recording) { if (recording) {
recording.chunks.push(buffer) 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 { } else {
this.logger?.log(pc.yellow(`Received recording chunk for unknown tab ${tabId}, ignoring`)) this.logger?.log(pc.yellow(`Received recording chunk for unknown tab ${tabId}, ignoring`))
} }
@@ -140,11 +144,11 @@ export class RecordingRelay {
} }
try { try {
const result = await this.sendToExtension({ const result = (await this.sendToExtension({
method: 'startRecording', method: 'startRecording',
params: recordingParams, params: recordingParams,
timeout: 10000, timeout: 10000,
}) as StartRecordingResult })) as StartRecordingResult
if (!result) { if (!result) {
return { success: false, error: 'Extension returned empty result' } return { success: false, error: 'Extension returned empty result' }
@@ -158,7 +162,11 @@ export class RecordingRelay {
chunks: [], chunks: [],
startedAt: result.startedAt, 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 return result
@@ -211,11 +219,11 @@ export class RecordingRelay {
}) })
try { try {
const result = await this.sendToExtension({ const result = (await this.sendToExtension({
method: 'stopRecording', method: 'stopRecording',
params, params,
timeout: 10000, timeout: 10000,
}) as StopRecordingResult })) as StopRecordingResult
if (!result.success) { if (!result.success) {
recording.resolveStop = undefined recording.resolveStop = undefined
@@ -237,11 +245,11 @@ export class RecordingRelay {
} }
try { try {
return await this.sendToExtension({ return (await this.sendToExtension({
method: 'isRecording', method: 'isRecording',
params, params,
timeout: 5000, timeout: 5000,
}) as IsRecordingResult })) as IsRecordingResult
} catch { } catch {
return { isRecording: false } return { isRecording: false }
} }
@@ -253,11 +261,11 @@ export class RecordingRelay {
} }
try { try {
return await this.sendToExtension({ return (await this.sendToExtension({
method: 'cancelRecording', method: 'cancelRecording',
params, params,
timeout: 5000, timeout: 5000,
}) as CancelRecordingResult })) as CancelRecordingResult
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
this.logger?.error('Cancel recording error:', 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 { try {
const response = await fetch(`http://127.0.0.1:${port}/extension/status`, { const response = await fetch(`http://127.0.0.1:${port}/extension/status`, {
signal: AbortSignal.timeout(500), signal: AbortSignal.timeout(500),
@@ -38,7 +40,7 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c
if (!response.ok) { if (!response.ok) {
return null 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 { } catch {
return null 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. * Wait for the extension to connect to the relay server.
* Returns true if connected within timeout, false otherwise. * Returns true if connected within timeout, false otherwise.
*/ */
export async function waitForExtension(options: { export async function waitForExtension(
options: {
port?: number port?: number
timeoutMs?: number timeoutMs?: number
logger?: { log: (...args: any[]) => void } logger?: { log: (...args: any[]) => void }
} = {}): Promise<boolean> { } = {},
): Promise<boolean> {
const { port = RELAY_PORT, timeoutMs = 5000, logger } = options const { port = RELAY_PORT, timeoutMs = 5000, logger } = options
const startTime = Date.now() const startTime = Date.now()
@@ -155,7 +159,9 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
if (serverVersion !== null) { if (serverVersion !== null) {
if (restartOnVersionMismatch) { 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 }) await killRelayServer({ port: RELAY_PORT })
} else { } else {
// Server is running but different version, just use it // Server is running but different version, just use it
@@ -164,7 +170,11 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
} else { } else {
const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => []) const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => [])
if (listeningPids.length > 0) { 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 }) 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 { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { getCDPSessionForPage } from './cdp-session.js' import { getCDPSessionForPage } from './cdp-session.js'
import { getCdpUrl } from './utils.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' import './test-declarations.js'
const TEST_PORT = 19987 const TEST_PORT = 19987
@@ -52,7 +60,9 @@ describe('Relay Core Tests', () => {
timeoutMs: 10000, timeoutMs: 10000,
errorMessage: 'Timed out toggling extension for active tab', 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({ const cdpSession = await withTimeout({
promise: getCDPSessionForPage({ page }), promise: getCDPSessionForPage({ page }),
@@ -150,7 +160,7 @@ describe('Relay Core Tests', () => {
connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id), connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
tabId: testTab?.id, tabId: testTab?.id,
tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null, 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 typeof interactiveResult === 'object' && interactiveResult.content?.[0]?.text
? tryJsonParse(interactiveResult.content[0].text) ? tryJsonParse(interactiveResult.content[0].text)
: interactiveResult : interactiveResult
await expect(interactiveData).toMatchFileSnapshot( await expect(interactiveData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-interactive.md`)
`snapshots/${testCase.name}-accessibility-interactive.md`,
)
expect(interactiveResult.content).toBeDefined() expect(interactiveResult.content).toBeDefined()
for (const expected of testCase.expectedContent) { for (const expected of testCase.expectedContent) {
expect(interactiveData).toContain(expected) expect(interactiveData).toContain(expected)
@@ -238,9 +246,7 @@ describe('Relay Core Tests', () => {
typeof fullResult === 'object' && fullResult.content?.[0]?.text typeof fullResult === 'object' && fullResult.content?.[0]?.text
? tryJsonParse(fullResult.content[0].text) ? tryJsonParse(fullResult.content[0].text)
: fullResult : fullResult
await expect(fullData).toMatchFileSnapshot( await expect(fullData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-full.md`)
`snapshots/${testCase.name}-accessibility-full.md`,
)
expect(fullResult.content).toBeDefined() expect(fullResult.content).toBeDefined()
for (const expected of testCase.expectedContent) { for (const expected of testCase.expectedContent) {
expect(fullData).toContain(expected) expect(fullData).toContain(expected)
@@ -265,7 +271,6 @@ describe('Relay Core Tests', () => {
`, `,
}, },
}) })
}) })
it('should capture browser console logs with getLatestLogs', async () => { it('should capture browser console logs with getLatestLogs', async () => {
@@ -611,7 +616,9 @@ describe('Relay Core Tests', () => {
}, 30000) }, 30000)
// right now our extension always forces light mode because of a playwright cdp bug // 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 browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)
@@ -627,7 +634,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const result = await client.callTool({ const result = await client.callTool({
name: 'execute', name: 'execute',
@@ -658,7 +665,9 @@ describe('Relay Core Tests', () => {
`) `)
await page.close() await page.close()
}, 60000) },
60000,
)
it('should get clean HTML with getCleanHTML', async () => { it('should get clean HTML with getCleanHTML', async () => {
const browserContext = getBrowserContext() const browserContext = getBrowserContext()
@@ -686,7 +695,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
// Test basic getCleanHTML // Test basic getCleanHTML
const result = await client.callTool({ const result = await client.callTool({
@@ -787,7 +796,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
// Test basic getPageMarkdown // Test basic getPageMarkdown
const result = await client.callTool({ const result = await client.callTool({
@@ -859,7 +868,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything() await globalThis.disconnectEverything()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
// 2. Create first page and enable extension // 2. Create first page and enable extension
const page1 = await browserContext.newPage() const page1 = await browserContext.newPage()
@@ -869,7 +878,7 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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) // 3. Reset MCP to ensure page1 becomes the default page (only page available)
const resetResult = await client.callTool({ const resetResult = await client.callTool({
@@ -899,11 +908,11 @@ describe('Relay Core Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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) // 6. Close the first page (which is the default `page` in MCP scope)
await page1.close() 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 // 7. Execute code via MCP - should NOT fail with "page closed" error
// Instead, it should automatically switch to the second page // 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 { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { chromium, type Page } from '@xmorse/playwright-core' import { chromium, type Page } from '@xmorse/playwright-core'
import { getCdpUrl } from './utils.js' 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' import './test-declarations.js'
const TEST_PORT = 19992 const TEST_PORT = 19992
@@ -23,13 +30,7 @@ describe('Relay Navigation Tests', () => {
return testCtx.browserContext return testCtx.browserContext
} }
const waitForStableDocumentReadyState = async ({ const waitForStableDocumentReadyState = async ({ page, timeoutMs }: { page: Page; timeoutMs: number }) => {
page,
timeoutMs,
}: {
page: Page
timeoutMs: number
}) => {
const startTime = Date.now() const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) { while (Date.now() - startTime < timeoutMs) {
@@ -132,7 +133,9 @@ describe('Relay Navigation Tests', () => {
timeoutMs: 5000, timeoutMs: 5000,
errorMessage: 'Timed out toggling extension for iframe test', 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({ const browser = await withTimeout({
promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
@@ -164,10 +167,7 @@ describe('Relay Navigation Tests', () => {
timeoutMs: 5000, timeoutMs: 5000,
errorMessage: 'Timed out closing page for iframe test', errorMessage: 'Timed out closing page for iframe test',
}) })
await Promise.all([ await Promise.all([parentServer.close(), childServer.close()])
parentServer.close(),
childServer.close(),
])
} }
}, 60000) }, 60000)
@@ -282,10 +282,7 @@ describe('Relay Navigation Tests', () => {
timeoutMs: 5000, timeoutMs: 5000,
errorMessage: 'Timed out closing page for empty-src iframe test', errorMessage: 'Timed out closing page for empty-src iframe test',
}) })
await Promise.all([ await Promise.all([parentServer.close(), childServer.close()])
parentServer.close(),
childServer.close(),
])
} }
}, 60000) }, 60000)
@@ -305,7 +302,10 @@ describe('Relay Navigation Tests', () => {
const context = browser.contexts()[0] const context = browser.contexts()[0]
const pages = context.pages() 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) expect(pages.length).toBeGreaterThan(0)
for (const p of pages) { for (const p of pages) {
@@ -314,7 +314,7 @@ describe('Relay Navigation Tests', () => {
expect(p.url()).not.toBeUndefined() 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() expect(discordPage).toBeDefined()
const result = await discordPage!.evaluate(() => window.location.href) const result = await discordPage!.evaluate(() => window.location.href)
@@ -337,10 +337,13 @@ describe('Relay Navigation Tests', () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const response = await cdpPage!.goto('https://www.notion.so', { waitUntil: 'domcontentloaded', timeout: 20000 }) 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 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 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() expect(cdpPage).toBeDefined()
const response = await cdpPage!.goto('https://www.youtube.com', { waitUntil: 'domcontentloaded', timeout: 20000 }) 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 globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
@@ -425,7 +431,7 @@ describe('Relay Navigation Tests', () => {
expect(iframePage?.url()).toContain('about:') expect(iframePage?.url()).toContain('about:')
await browser.close() await browser.close()
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
} }
await page.close() await page.close()
@@ -438,20 +444,20 @@ describe('Relay Navigation Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything() await globalThis.disconnectEverything()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const targetUrl = 'https://example.com/' const targetUrl = 'https://example.com/'
const enableResult = await serviceWorker.evaluate(async (url) => { const enableResult = await serviceWorker.evaluate(async (url) => {
const tab = await chrome.tabs.create({ url, active: true }) 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() return await globalThis.toggleExtensionForActiveTab()
}, targetUrl) }, targetUrl)
console.log('Extension enabled:', enableResult) console.log('Extension enabled:', enableResult)
expect(enableResult.isConnected).toBe(true) 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') const { Stagehand } = await import('@browserbasehq/stagehand')
@@ -472,9 +478,13 @@ describe('Relay Navigation Tests', () => {
expect(context).toBeDefined() expect(context).toBeDefined()
const pages = context.pages() 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() expect(stagehandPage).toBeDefined()
const url = stagehandPage!.url() const url = stagehandPage!.url()
@@ -495,16 +505,16 @@ describe('Relay Navigation Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 200)) await new Promise((r) => setTimeout(r, 200))
// Test /json/version // Test /json/version
const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`) const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`)
expect(versionRes.status).toBe(200) expect(versionRes.status).toBe(200)
const versionJson = await versionRes.json() as { webSocketDebuggerUrl: string } const versionJson = (await versionRes.json()) as { webSocketDebuggerUrl: string }
expect(versionJson).toMatchObject({ expect(versionJson).toMatchObject({
'Browser': expect.stringContaining('Playwriter/'), Browser: expect.stringContaining('Playwriter/'),
'Protocol-Version': '1.3', 'Protocol-Version': '1.3',
'webSocketDebuggerUrl': expect.stringContaining('ws://'), webSocketDebuggerUrl: expect.stringContaining('ws://'),
}) })
expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`) expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`)
@@ -515,7 +525,7 @@ describe('Relay Navigation Tests', () => {
// Test /json/list // Test /json/list
const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`) const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`)
expect(listRes.status).toBe(200) 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(Array.isArray(listJson)).toBe(true)
expect(listJson.length).toBeGreaterThan(0) expect(listJson.length).toBeGreaterThan(0)
@@ -555,7 +565,7 @@ describe('Relay Navigation Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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') const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.mp4')
if (!fs.existsSync(path.dirname(outputPath))) { if (!fs.existsSync(path.dirname(outputPath))) {
@@ -576,7 +586,7 @@ describe('Relay Navigation Tests', () => {
await recordingPage.locator('.titleline a').first().click() await recordingPage.locator('.titleline a').first().click()
await recordingPage.waitForLoadState('domcontentloaded') await recordingPage.waitForLoadState('domcontentloaded')
await new Promise(r => setTimeout(r, 500)) await new Promise((r) => setTimeout(r, 500))
await recordingPage.goBack() await recordingPage.goBack()
await recordingPage.waitForLoadState('domcontentloaded') await recordingPage.waitForLoadState('domcontentloaded')
+118 -71
View File
@@ -6,7 +6,16 @@ import { getCDPSessionForPage } from './cdp-session.js'
import { Debugger } from './debugger.js' import { Debugger } from './debugger.js'
import { Editor } from './editor.js' import { Editor } from './editor.js'
import { PlaywrightExecutor } from './executor.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' import './test-declarations.js'
const TEST_PORT = 19993 const TEST_PORT = 19993
@@ -23,7 +32,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything() await globalThis.disconnectEverything()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
}, 600000) }, 600000)
afterAll(async () => { afterAll(async () => {
@@ -36,8 +45,6 @@ describe('CDP Session Tests', () => {
return testCtx.browserContext return testCtx.browserContext
} }
it('should use Debugger class to set breakpoints and inspect variables', async () => { it('should use Debugger class to set breakpoints and inspect variables', async () => {
const browserContext = getBrowserContext() const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)
@@ -50,7 +57,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page }) const cdpSession = await getCDPSessionForPage({ page })
@@ -72,12 +79,12 @@ describe('CDP Session Tests', () => {
const numberVar = 42; const numberVar = 42;
debugger; debugger;
return localVar + numberVar; return localVar + numberVar;
})()` })()`,
}) })
await Promise.race([ await Promise.race([
pausedPromise, 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) expect(dbg.isPaused()).toBe(true)
@@ -98,7 +105,7 @@ describe('CDP Session Tests', () => {
expect(evalResult.value).toBe('hello world') expect(evalResult.value).toBe('hello world')
await dbg.resume() await dbg.resume()
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
expect(dbg.isPaused()).toBe(false) expect(dbg.isPaused()).toBe(false)
await evalPromise await evalPromise
@@ -118,7 +125,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const executor = new PlaywrightExecutor({ const executor = new PlaywrightExecutor({
cdpConfig: { port: TEST_PORT }, cdpConfig: { port: TEST_PORT },
@@ -154,10 +161,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -194,7 +204,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page }) const cdpSession = await getCDPSessionForPage({ page })
@@ -231,7 +241,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page }) const cdpSession = await getCDPSessionForPage({ page })
@@ -253,7 +263,7 @@ describe('CDP Session Tests', () => {
} }
const result = inner(); const result = inner();
return result; return result;
})()` })()`,
}) })
await pausedPromise await pausedPromise
@@ -301,7 +311,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page }) const cdpSession = await getCDPSessionForPage({ page })
@@ -320,15 +330,15 @@ describe('CDP Session Tests', () => {
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
document.querySelectorAll('*') document.querySelectorAll('*')
} }
})()` })()`,
}) })
const stopResult = await cdpSession.send('Profiler.stop') const stopResult = await cdpSession.send('Profiler.stop')
const profile = stopResult.profile const profile = stopResult.profile
const functionNames = profile.nodes const functionNames = profile.nodes
.map(n => n.callFrame.functionName) .map((n) => n.callFrame.functionName)
.filter(name => name && name.length > 0) .filter((name) => name && name.length > 0)
.slice(0, 10) .slice(0, 10)
expect(profile.nodes.length).toBeGreaterThan(0) expect(profile.nodes.length).toBeGreaterThan(0)
@@ -355,32 +365,35 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
const initialTargets = await cdpSession.send('Target.getTargets') 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/') expect(initialPageTarget?.url).toBe('https://example.com/')
await cdpPage!.goto('https://example.org/', { waitUntil: 'domcontentloaded' }) 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 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) 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) 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) expect(exampleOrgTargets).toHaveLength(1)
await cdpSession.detach() await cdpSession.detach()
@@ -410,23 +423,26 @@ describe('CDP Session Tests', () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
const { targetInfos } = await cdpSession.send('Target.getTargets') 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) expect(aboutBlankTargets).toHaveLength(0)
const pageTargets = allPageTargets 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)) .sort((a, b) => a.url.localeCompare(b.url))
expect(pageTargets).toMatchInlineSnapshot(` expect(pageTargets).toMatchInlineSnapshot(`
@@ -459,13 +475,16 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -494,10 +513,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -546,10 +568,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -570,12 +595,12 @@ describe('CDP Session Tests', () => {
} catch (e) { } catch (e) {
// caught but should still pause with state 'all' // caught but should still pause with state 'all'
} }
})()` })()`,
}) })
await Promise.race([ await Promise.race([
pausedPromise, 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) expect(dbg.isPaused()).toBe(true)
@@ -623,7 +648,7 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
let cdpPage let cdpPage
@@ -692,10 +717,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const h1Bounds = await cdpPage!.locator('h1').boundingBox() const h1Bounds = await cdpPage!.locator('h1').boundingBox()
@@ -703,10 +731,10 @@ describe('CDP Session Tests', () => {
console.log('H1 bounding box:', h1Bounds) console.log('H1 bounding box:', h1Bounds)
await cdpPage!.evaluate(() => { await cdpPage!.evaluate(() => {
(window as any).clickedAt = null; ;(window as any).clickedAt = null
document.addEventListener('click', (e) => { 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() await cdpPage!.locator('h1').click()
@@ -733,10 +761,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) 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() const scripts = await editor.list()
expect(scripts.length).toBeGreaterThan(0) expect(scripts.length).toBeGreaterThan(0)
@@ -772,14 +803,14 @@ describe('CDP Session Tests', () => {
}) })
const consoleLogs: string[] = [] const consoleLogs: string[] = []
cdpPage!.on('console', msg => { cdpPage!.on('console', (msg) => {
consoleLogs.push(msg.text()) consoleLogs.push(msg.text())
}) })
await cdpPage!.evaluate(() => { 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('Hello, World')
expect(consoleLogs).toContain('EDITOR_TEST_MARKER') expect(consoleLogs).toContain('EDITOR_TEST_MARKER')
@@ -800,10 +831,13 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) 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:/ }) const stylesheets = await editor.list({ pattern: /inline-css:/ })
expect(stylesheets.length).toBeGreaterThan(0) expect(stylesheets.length).toBeGreaterThan(0)
@@ -893,11 +927,11 @@ describe('CDP Session Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const pages = browser.contexts()[0].pages() 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() expect(cdpPage).toBeDefined()
const btn = cdpPage!.locator('#react-btn') const btn = cdpPage!.locator('#react-btn')
@@ -975,7 +1009,7 @@ describe('Service Worker Target Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const context = browser.contexts()[0] const context = browser.contexts()[0]
@@ -989,7 +1023,7 @@ describe('Service Worker Target Tests', () => {
expect(url).not.toMatch(/service.?worker/i) 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() expect(targetPage).toBeDefined()
const title = await targetPage!.title() const title = await targetPage!.title()
@@ -1010,10 +1044,13 @@ describe('Service Worker Target Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT }) const wsUrl = getCdpUrl({ port: TEST_PORT })
@@ -1022,12 +1059,12 @@ describe('Service Worker Target Tests', () => {
await cdpSession.send('Network.disable') await cdpSession.send('Network.disable')
await cdpSession.send('Network.enable', { await cdpSession.send('Network.enable', {
maxTotalBufferSize: 10000000, maxTotalBufferSize: 10000000,
maxResourceBufferSize: 5000000 maxResourceBufferSize: 5000000,
}) })
const [response] = await Promise.all([ const [response] = await Promise.all([
cdpPage!.waitForResponse(resp => resp.url() === 'https://example.com/'), cdpPage!.waitForResponse((resp) => resp.url() === 'https://example.com/'),
cdpPage!.goto('https://example.com/') cdpPage!.goto('https://example.com/'),
]) ])
const body = await response.text() const body = await response.text()
@@ -1085,7 +1122,10 @@ describe('Service Worker Target Tests', () => {
timeoutMs: 5000, timeoutMs: 5000,
errorMessage: 'connectOverCDP timed out', 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) return p.url().startsWith(sseServer.baseUrl)
}) })
expect(cdpPage).toBeDefined() expect(cdpPage).toBeDefined()
@@ -1094,9 +1134,12 @@ describe('Service Worker Target Tests', () => {
return window.startSse() return window.startSse()
}) })
await withTimeout({ await withTimeout({
promise: cdpPage!.waitForFunction(() => { promise: cdpPage!.waitForFunction(
() => {
return window.__sseMessages.length > 0 return window.__sseMessages.length > 0
}, { timeout: 5000 }), },
{ timeout: 5000 },
),
timeoutMs: 7000, timeoutMs: 7000,
errorMessage: 'SSE message not received in time', errorMessage: 'SSE message not received in time',
}) })
@@ -1170,7 +1213,7 @@ describe('Auto-enable Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything() await globalThis.disconnectEverything()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
}, 600000) }, 600000)
afterAll(async () => { afterAll(async () => {
@@ -1192,7 +1235,7 @@ describe('Auto-enable Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything() await globalThis.disconnectEverything()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const tabCountBefore = await serviceWorker.evaluate(() => { const tabCountBefore = await serviceWorker.evaluate(() => {
const state = globalThis.getExtensionState() const state = globalThis.getExtensionState()
@@ -1229,7 +1272,9 @@ describe('Auto-enable Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.disconnectEverything() await globalThis.disconnectEverything()
}) })
await new Promise((r) => { setTimeout(r, 100) }) await new Promise((r) => {
setTimeout(r, 100)
})
const tabCountBefore = await serviceWorker.evaluate(() => { const tabCountBefore = await serviceWorker.evaluate(() => {
const state = globalThis.getExtensionState() 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({ const afterCloseResult = await client.callTool({
name: 'execute', name: 'execute',
+10 -38
View File
@@ -1,5 +1,3 @@
You can also find `getByRole` to get elements on the page. You can also find `getByRole` to get elements on the page.
```javascript ```javascript
@@ -14,9 +12,7 @@ await page.getByRole('link', { name: 'About' }).click()
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com') await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com')
// For a heading with { "role": "heading", "name": "Welcome to Example.com" } // For a heading with { "role": "heading", "name": "Welcome to Example.com" }
const headingText = await page const headingText = await page.getByRole('heading', { name: 'Welcome to Example.com' }).textContent()
.getByRole('heading', { name: 'Welcome to Example.com' })
.textContent()
console.log('Heading text:', headingText) console.log('Heading text:', headingText)
``` ```
@@ -230,10 +226,7 @@ await page.evaluate(() => {
const sum = await page.evaluate(([a, b]) => a + b, [5, 3]) const sum = await page.evaluate(([a, b]) => a + b, [5, 3])
// Work with elements // Work with elements
const elementText = await page.evaluate( const elementText = await page.evaluate((el) => el.textContent, await page.getByRole('heading'))
(el) => el.textContent,
await page.getByRole('heading'),
)
``` ```
### Execute JavaScript on Element ### 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') await page.getByLabel('Upload file').setInputFiles('/path/to/file.pdf')
// Upload multiple files // Upload multiple files
await page await page.getByLabel('Upload files').setInputFiles(['/path/to/file1.pdf', '/path/to/file2.pdf'])
.getByLabel('Upload files')
.setInputFiles(['/path/to/file1.pdf', '/path/to/file2.pdf'])
// Clear file input // Clear file input
await page.getByLabel('Upload file').setInputFiles([]) await page.getByLabel('Upload file').setInputFiles([])
@@ -280,8 +271,7 @@ await page.locator('input[type="file"]').setInputFiles('/path/to/file.pdf')
```javascript ```javascript
// Wait for a specific request to complete and get its response // Wait for a specific request to complete and get its response
const response = await page.waitForResponse( const response = await page.waitForResponse(
(response) => (response) => response.url().includes('/api/user') && response.status() === 200,
response.url().includes('/api/user') && response.status() === 200,
) )
// Get response data // Get response data
@@ -338,10 +328,7 @@ await page.waitForURL(/github\.com.*\/pull/)
await page.waitForURL(/\/new-org/) await page.waitForURL(/\/new-org/)
// Wait for text to appear // Wait for text to appear
await page.waitForFunction( await page.waitForFunction((text) => document.body.textContent.includes(text), 'Success!')
(text) => document.body.textContent.includes(text),
'Success!',
)
// Wait for navigation // Wait for navigation
await page.waitForURL('**/success') await page.waitForURL('**/success')
@@ -350,10 +337,7 @@ await page.waitForURL('**/success')
await waitForPageLoad({ page }) await waitForPageLoad({ page })
// Wait for specific condition // Wait for specific condition
await page.waitForFunction( await page.waitForFunction((text) => document.querySelector('.status')?.textContent === text, 'Ready')
(text) => document.querySelector('.status')?.textContent === text,
'Ready',
)
``` ```
### Wait for Text to Appear or Disappear ### 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') console.log('Processing finished and success message appeared')
// Example: Wait for error message to disappear before proceeding // Example: Wait for error message to disappear before proceeding
await page await page.getByText('Error: Please try again').first().waitFor({ state: 'hidden' })
.getByText('Error: Please try again')
.first()
.waitFor({ state: 'hidden' })
await page.getByRole('button', { name: 'Submit' }).click() await page.getByRole('button', { name: 'Submit' }).click()
// Example: Wait for confirmation text after form submission // Example: Wait for confirmation text after form submission
await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Save' }).click()
await page await page.getByText('Your changes have been saved').first().waitFor({ state: 'visible' })
.getByText('Your changes have been saved')
.first()
.waitFor({ state: 'visible' })
console.log('Save confirmed') console.log('Save confirmed')
// Example: Wait for dynamic content to load // Example: Wait for dynamic content to load
await page.getByRole('button', { name: 'Load More' }).click() await page.getByRole('button', { name: 'Load More' }).click()
await page await page.getByText('Loading more items...').first().waitFor({ state: 'visible' })
.getByText('Loading more items...') await page.getByText('Loading more items...').first().waitFor({ state: 'hidden' })
.first()
.waitFor({ state: 'visible' })
await page
.getByText('Loading more items...')
.first()
.waitFor({ state: 'hidden' })
console.log('Additional items loaded') 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) // Verify the real path is also within allowed directories (handles symlinks)
const realStr = real.toString() const realStr = real.toString()
if (!this.isPathAllowed(realStr)) { 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' error.code = 'EPERM'
throw error throw error
} }
@@ -168,7 +170,9 @@ export class ScopedFS {
const linkDir = path.dirname(resolvedLink) const linkDir = path.dirname(resolvedLink)
const resolvedTarget = path.resolve(linkDir, target.toString()) const resolvedTarget = path.resolve(linkDir, target.toString())
if (!this.isPathAllowed(resolvedTarget)) { 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' error.code = 'EPERM'
throw error throw error
} }
@@ -368,7 +372,9 @@ export class ScopedFS {
const real = await fs.promises.realpath(resolved, options) const real = await fs.promises.realpath(resolved, options)
const realStr = real.toString() const realStr = real.toString()
if (!self.isPathAllowed(realStr)) { 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' error.code = 'EPERM'
throw error throw error
} }
+27 -16
View File
@@ -9,12 +9,7 @@
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import type { Page } from '@xmorse/playwright-core' import type { Page } from '@xmorse/playwright-core'
import type { import type { StartRecordingResult, StopRecordingResult, IsRecordingResult, CancelRecordingResult } from './protocol.js'
StartRecordingResult,
StopRecordingResult,
IsRecordingResult,
CancelRecordingResult,
} from './protocol.js'
import { EXTENSION_IDS } from './utils.js' import { EXTENSION_IDS } from './utils.js'
/** /**
@@ -27,7 +22,8 @@ import { EXTENSION_IDS } from './utils.js'
*/ */
export function getChromeRestartCommand(): string { export function getChromeRestartCommand(): string {
const platform = os.platform() 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') { if (platform === 'darwin') {
return `osascript -e 'quit app "Google Chrome"' && sleep 1 && open -a "Google Chrome" --args ${flags}` 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. * Check if an error is related to missing activeTab permission for recording.
*/ */
function isActiveTabPermissionError(error: string): boolean { 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('activeTab') ||
error.includes('enable recording') error.includes('enable recording')
)
} }
export interface StartRecordingOptions { 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`, { const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (!result.success) {
const errorMsg = result.error || 'Unknown error' 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` + `For automated recording, Chrome must be restarted with special flags.\n` +
`WARNING: This will close all Chrome windows. Save your work first!\n\n` + `WARNING: This will close all Chrome windows. Save your work first!\n\n` +
` ${restartCmd}\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. * Stop recording and save to file.
* Returns the path to the saved video 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 { sessionId, relayPort = 19988 } = options
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, { 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 }), body: JSON.stringify({ sessionId }),
}) })
const result = await response.json() as StopRecordingResult const result = (await response.json()) as StopRecordingResult
if (!result.success) { if (!result.success) {
throw new Error(`Failed to stop recording: ${result.error}`) 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. * 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 { sessionId, relayPort = 19988 } = options
const url = sessionId const url = sessionId
? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}` ? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}`
: `http://127.0.0.1:${relayPort}/recording/status` : `http://127.0.0.1:${relayPort}/recording/status`
const response = await fetch(url) 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 } 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 }), body: JSON.stringify({ sessionId }),
}) })
const result = await response.json() as CancelRecordingResult const result = (await response.json()) as CancelRecordingResult
if (!result.success) { if (!result.success) {
throw new Error(`Failed to cancel recording: ${result.error}`) 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 ### Session management
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to: Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
- Keep state separate between different tasks or agents - Keep state separate between different tasks or agents
- Persist data (pages, variables) across multiple execute calls - Persist data (pages, variables) across multiple execute calls
- Avoid interference when multiple agents use playwriter simultaneously - 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 ```js
// 1. Open page and observe — always print URL first // 1. Open page and observe — always print URL first
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://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' }); 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) console.log('URL:', state.page.url())
await snapshot({ page: state.page }).then(console.log)
``` ```
```js ```js
// 2. Act: open command palette → observe result // 2. Act: open command palette → observe result
await state.page.keyboard.press('Meta+k'); await state.page.keyboard.press('Meta+k')
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /dialog|Search/ }).then(console.log) 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 // If dialog didn't appear, observe again before retrying
``` ```
```js ```js
// 3. Act: type search query → observe result // 3. Act: type search query → observe result
await state.page.keyboard.type('MCP'); await state.page.keyboard.type('MCP')
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /MCP/ }).then(console.log) console.log('URL:', state.page.url())
await snapshot({ page: state.page, search: /MCP/ }).then(console.log)
``` ```
```js ```js
// 4. Act: press Enter → observe plugin loaded // 4. Act: press Enter → observe plugin loaded
await state.page.keyboard.press('Enter'); await state.page.keyboard.press('Enter')
await state.page.waitForTimeout(1000); await state.page.waitForTimeout(1000)
console.log('URL:', state.page.url()); console.log('URL:', state.page.url())
const frame = state.page.frames().find(f => f.url().includes('plugins.framercdn.com')); const frame = state.page.frames().find((f) => f.url().includes('plugins.framercdn.com'))
await snapshot({ page: state.page, frame: frame || undefined }).then(console.log) await snapshot({ page: state.page, frame: frame || undefined }).then(console.log)
// If frame not found, wait and observe again — plugin may still be loading // 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: - **Network requests** — verify API calls were made after a form submit or button click:
```js ```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: - **URL changes** — confirm navigation happened:
```js ```js
@@ -266,28 +274,31 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
**1. Not verifying actions succeeded** **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: Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
```js ```js
await state.page.keyboard.type('my text'); await state.page.keyboard.type('my text')
await snapshot({ page: state.page, search: /my text/ }) await snapshot({ page: state.page, search: /my text/ })
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead // If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
``` ```
**2. Assuming paste/upload worked** **2. Assuming paste/upload worked**
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input: Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
```js ```js
// Reliable: use file input // Reliable: use file input
const fileInput = state.page.locator('input[type="file"]').first(); const fileInput = state.page.locator('input[type="file"]').first()
await fileInput.setInputFiles('/path/to/image.png'); await fileInput.setInputFiles('/path/to/image.png')
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example // 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** **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: Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
```js ```js
// BAD: using ref from minutes ago // 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 // GOOD: get fresh snapshot, then immediately use locators from it
await snapshot({ page: state.page, showDiffSinceLastCall: true }) 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** **4. Wrong assumptions about current page/element**
Before destructive actions (delete, submit), verify you're targeting the right thing: Before destructive actions (delete, submit), verify you're targeting the right thing:
```js ```js
// Before deleting, verify it's the right item // 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 // READ the screenshot to confirm, THEN proceed with delete
``` ```
**5. Text concatenation without line breaks** **5. Text concatenation without line breaks**
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`: `keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
```js ```js
// BAD: newlines in string don't create line breaks // 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 // GOOD: use Enter key for line breaks
await state.page.keyboard.type('Line 1'); await state.page.keyboard.type('Line 1')
await state.page.keyboard.press('Enter'); await state.page.keyboard.press('Enter')
await state.page.keyboard.type('Line 2'); await state.page.keyboard.type('Line 2')
``` ```
**6. Quote escaping in $'...' syntax** **6. Quote escaping in $'...' syntax**
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them: When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
```bash ```bash
# BAD: nested double quotes break $'...' # BAD: nested double quotes break $'...'
playwriter -s 1 -e $'await state.page.locator("[id=\"_r_a_\"]").click()' playwriter -s 1 -e $'await state.page.locator("[id=\"_r_a_\"]").click()'
@@ -332,61 +346,65 @@ EOF
**7. Using screenshots when snapshots suffice** **7. Using screenshots when snapshots suffice**
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues: Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
```js ```js
// BAD: screenshot to check if text appeared (wastes tokens on image analysis) // 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 // GOOD: snapshot is text — fast, cheap, searchable
await snapshot({ page: state.page, search: /expected text/i }) await snapshot({ page: state.page, search: /expected text/i })
// GOOD: evaluate DOM directly for content checks // 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** **8. Assuming page content loaded**
Even after `goto()`, dynamic content may not be ready: Even after `goto()`, dynamic content may not be ready:
```js ```js
await state.page.goto('https://example.com'); await state.page.goto('https://example.com')
// Content may still be loading via JavaScript! // 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 // 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** **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: 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 ```js
// BAD: webfetch/curl on Instagram returns empty HTML, grep finds nothing, huge context wasted // 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 // BAD: Playwright CLI screenshot needs browser install, produces blank/modal-blocked images
// GOOD: use playwriter — real browser, full JS rendering, interactive // GOOD: use playwriter — real browser, full JS rendering, interactive
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://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' }); await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page: state.page, timeout: 8000 }); await waitForPageLoad({ page: state.page, timeout: 8000 })
await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log) await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log)
// Now you can see modals, dismiss them, navigate carousels, extract content // Now you can see modals, dismiss them, navigate carousels, extract content
``` ```
**10. Login buttons that open popups** **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: 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 ```js
// BAD: popup window is not controllable by playwriter // 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 // 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.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
await state.page.waitForTimeout(2000); await state.page.waitForTimeout(2000)
// Verify new tab opened - last page should be the login page // Verify new tab opened - last page should be the login page
const pages = context.pages(); const pages = context.pages()
const loginPage = pages[pages.length - 1]; const loginPage = pages[pages.length - 1]
if (loginPage.url() === state.page.url()) { 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 // Complete login flow in loginPage, cookies are shared with original page
await loginPage.locator('[data-email]').first().click(); await loginPage.locator('[data-email]').first().click()
await loginPage.waitForURL('**/callback**'); await loginPage.waitForURL('**/callback**')
// Original page should now be authenticated // Original page should now be authenticated
``` ```
@@ -396,10 +414,12 @@ After any action (click, submit, navigate), verify what happened. Always print U
```js ```js
// Always print URL first, then snapshot // 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 // 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. 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: **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 ```js
const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false }); const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false })
const relevant = snap.split('\n').filter(l => const relevant = snap
l.includes('dialog') || l.includes('error') || l.includes('button') .split('\n')
).join('\n'); .filter((l) => l.includes('dialog') || l.includes('error') || l.includes('button'))
console.log(relevant); .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. 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. Both `snapshot` and `screenshotWithAccessibilityLabels` use the same ref system, so you can combine them effectively.
**Use `snapshot` when:** **Use `snapshot` when:**
- Page has simple, semantic structure (articles, forms, lists) - Page has simple, semantic structure (articles, forms, lists)
- You need to search for specific text or patterns - You need to search for specific text or patterns
- Token usage matters (text is smaller than images) - Token usage matters (text is smaller than images)
- You need to process the output programmatically - You need to process the output programmatically
**Use `screenshotWithAccessibilityLabels` when:** **Use `screenshotWithAccessibilityLabels` when:**
- Page has complex visual layout (grids, galleries, dashboards, maps) - Page has complex visual layout (grids, galleries, dashboards, maps)
- Spatial position matters (e.g., "first image", "top-left button") - Spatial position matters (e.g., "first image", "top-left button")
- DOM order doesn't match visual order - 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. // Reuse an empty about:blank tab if available, otherwise create a new one.
// IMPORTANT: always navigate immediately in the same call to avoid another // IMPORTANT: always navigate immediately in the same call to avoid another
// agent grabbing the same about:blank tab between execute calls. // agent grabbing the same about:blank tab between execute calls.
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 state.page for ALL subsequent operations // 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 ```js
if (!state.page || state.page.isClosed()) { 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:** **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: 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 ```js
const pages = context.pages().filter(x => x.url().includes('myapp.com')); 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 === 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`); if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
state.targetPage = pages[0]; state.targetPage = pages[0]
``` ```
**List all available pages:** **List all available pages:**
```js ```js
context.pages().map(p => p.url()) context.pages().map((p) => p.url())
``` ```
## navigation ## navigation
@@ -559,8 +582,8 @@ context.pages().map(p => p.url())
**Use `domcontentloaded`** for `page.goto()`: **Use `domcontentloaded`** for `page.goto()`:
```js ```js
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' }); await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page: state.page, timeout: 5000 }); await waitForPageLoad({ page: state.page, timeout: 5000 })
``` ```
## common patterns ## common patterns
@@ -573,9 +596,9 @@ await waitForPageLoad({ page: state.page, timeout: 5000 });
// GOOD: fetch inside state.page.evaluate uses browser's full session // GOOD: fetch inside state.page.evaluate uses browser's full session
const data = await state.page.evaluate(async (url) => { const data = await state.page.evaluate(async (url) => {
const resp = await fetch(url); const resp = await fetch(url)
return await resp.text(); return await resp.text()
}, 'https://example.com/protected/resource'); }, 'https://example.com/protected/resource')
``` ```
**Downloading large data** - console output truncates large strings. Trigger a browser download instead: **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 ```js
// Fetch protected data and trigger download to user's Downloads folder // Fetch protected data and trigger download to user's Downloads folder
await state.page.evaluate(async (url) => { await state.page.evaluate(async (url) => {
const resp = await fetch(url); const resp = await fetch(url)
const data = await resp.text(); const data = await resp.text()
const blob = new Blob([data], { type: 'application/octet-stream' }); const blob = new Blob([data], { type: 'application/octet-stream' })
const a = document.createElement('a'); const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob)
a.download = 'data.json'; a.download = 'data.json'
a.click(); a.click()
}, 'https://example.com/protected/large-file'); }, 'https://example.com/protected/large-file')
// File saves to ~/Downloads - read it from there // 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: **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 - `navigator.clipboard.writeText()` - requires permission
- Multiple concurrent downloads - browser may block - Multiple concurrent downloads - browser may block
- `window.showSaveFilePicker()` - requires user gesture - `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): **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 ```js
await state.page.locator('a[target=_blank]').click({ modifiers: ['Meta'] }); await state.page.locator('a[target=_blank]').click({ modifiers: ['Meta'] })
await state.page.waitForTimeout(1000); await state.page.waitForTimeout(1000)
const pages = context.pages(); const pages = context.pages()
const newTab = pages[pages.length - 1]; const newTab = pages[pages.length - 1]
console.log('New tab URL:', newTab.url()); console.log('New tab URL:', newTab.url())
``` ```
**Downloads** - capture and save: **Downloads** - capture and save:
```js ```js
const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')]); const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')])
await download.saveAs(`/tmp/${download.suggestedFilename()}`); await download.saveAs(`/tmp/${download.suggestedFilename()}`)
``` ```
**iFrames** - two approaches depending on what you need: **iFrames** - two approaches depending on what you need:
```js ```js
// frameLocator: for chaining locator operations (click, fill, etc.) // frameLocator: for chaining locator operations (click, fill, etc.)
const frame = state.page.frameLocator('#my-iframe'); const frame = state.page.frameLocator('#my-iframe')
await frame.locator('button').click(); await frame.locator('button').click()
// contentFrame: returns a Frame object, needed for snapshot({ frame }) // 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 }) await snapshot({ frame: frame2 })
``` ```
**Dialogs** - handle alerts/confirms/prompts: **Dialogs** - handle alerts/confirms/prompts:
```js ```js
state.page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); }); state.page.on('dialog', async (dialog) => {
await state.page.click('button.trigger-alert'); 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: **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 ```js
// After navigating, check for common obstacles // After navigating, check for common obstacles
await waitForPageLoad({ page: state.page, timeout: 5000 }); 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 }); const snap = await snapshot({
console.log(snap); 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: // 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("Accept")').click();
// await state.page.locator('button:has-text("Decline optional")').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 ```js
// Extract all image URLs from rendered DOM // Extract all image URLs from rendered DOM
const images = await state.page.evaluate(() => const images = await state.page.evaluate(() =>
Array.from(document.querySelectorAll('img[src]')).map(img => ({ Array.from(document.querySelectorAll('img[src]')).map((img) => ({
src: img.src, alt: img.alt, width: img.naturalWidth src: img.src,
})) alt: img.alt,
); width: img.naturalWidth,
console.log(JSON.stringify(images, null, 2)); })),
)
console.log(JSON.stringify(images, null, 2))
// Download a specific image to disk // Download a specific image to disk
const fs = require('node:fs'); const fs = require('node:fs')
const resp = await fetch(images[0].src); const resp = await fetch(images[0].src)
const buf = Buffer.from(await resp.arrayBuffer()); const buf = Buffer.from(await resp.arrayBuffer())
fs.writeFileSync('./downloaded-image.jpg', buf); fs.writeFileSync('./downloaded-image.jpg', buf)
console.log('Saved', buf.length, 'bytes'); 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. 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:** **Parameters:**
- `locator` - Playwright Locator or Page to get HTML from - `locator` - Playwright Locator or Page to get HTML from
- `search` - string/regex to filter results (returns first 10 matching lines with 5 lines context) - `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. - `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:** **HTML processing:**
The function cleans HTML for compact, readable output: The function cleans HTML for compact, readable output:
- **Removes tags**: script, style, link, meta, noscript, svg, head - **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>`) - **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 - **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 - **Truncates long values**: Attribute values >200 chars and text content >500 chars are truncated
**Attributes kept (summary):** **Attributes kept (summary):**
- Common semantic and ARIA attributes (e.g., `href`, `name`, `type`, `aria-*`) - Common semantic and ARIA attributes (e.g., `href`, `name`, `type`, `aria-*`)
- All `data-*` test attributes - All `data-*` test attributes
- Frequently used test IDs and special attributes (e.g., `testid`, `qa`, `e2e`, `vimium-label`) - 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:** **Output format:**
``` ```
# Article Title # Article Title
@@ -736,11 +772,13 @@ The main article content as plain text, with paragraphs preserved...
``` ```
**Parameters:** **Parameters:**
- `page` - Playwright Page to extract content from - `page` - Playwright Page to extract content from
- `search` - string/regex to filter content (returns first 10 matching lines with 5 lines context) - `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. - `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full content.
**Use cases:** **Use cases:**
- Extract article text for LLM processing without HTML noise - Extract article text for LLM processing without HTML noise
- Get readable content from news sites, blogs, documentation - Get readable content from news sites, blogs, documentation
- Compare content changes after interactions - Compare content changes after interactions
@@ -755,46 +793,53 @@ await waitForPageLoad({ page: state.page, timeout?, pollInterval?, minWait? })
**getCDPSession** - send raw CDP commands: **getCDPSession** - send raw CDP commands:
```js ```js
const cdp = await getCDPSession({ page: state.page }); const cdp = await getCDPSession({ page: state.page })
const metrics = await cdp.send('Page.getLayoutMetrics'); const metrics = await cdp.send('Page.getLayoutMetrics')
``` ```
**getLocatorStringForElement** - get stable Playwright selector from an element: **getLocatorStringForElement** - get stable Playwright selector from an element:
```js ```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' })" // => "getByRole('button', { name: 'Save' })"
``` ```
**getReactSource** - get React component source location (dev mode only): **getReactSource** - get React component source location (dev mode only):
```js ```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 } // => { 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. **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 ```js
const styles = await getStylesForLocator({ locator: state.page.locator('.btn'), cdp: await getCDPSession({ page: state.page }) }); const styles = await getStylesForLocator({
console.log(formatStylesAsText(styles)); 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. **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 ```js
const cdp = await getCDPSession({ page: state.page }); const dbg = createDebugger({ cdp }); await dbg.enable(); const cdp = await getCDPSession({ page: state.page })
const scripts = await dbg.listScripts({ search: 'app' }); const dbg = createDebugger({ cdp })
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 }); 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() // 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. **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 ```js
const cdp = await getCDPSession({ page: state.page }); const editor = createEditor({ cdp }); await editor.enable(); const cdp = await getCDPSession({ page: state.page })
const matches = await editor.grep({ regex: /console\.log/ }); const editor = createEditor({ cdp })
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' }); 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. **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. 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 ```js
await screenshotWithAccessibilityLabels({ page: state.page }); await screenshotWithAccessibilityLabels({ page: state.page })
// Image and accessibility snapshot are automatically included in response // Image and accessibility snapshot are automatically included in response
// Use refs from snapshot to interact with elements // 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 // Can take multiple screenshots in one execution
await screenshotWithAccessibilityLabels({ page: state.page }); await screenshotWithAccessibilityLabels({ page: state.page })
await state.page.click('button'); await state.page.click('button')
await screenshotWithAccessibilityLabels({ page: state.page }); await screenshotWithAccessibilityLabels({ page: state.page })
// Both images are included in the response // Both images are included in the response
``` ```
@@ -827,26 +872,27 @@ await startRecording({
outputPath: './recording.mp4', outputPath: './recording.mp4',
frameRate: 30, // default: 30 frameRate: 30, // default: 30
audio: false, // default: false (tab audio) audio: false, // default: false (tab audio)
videoBitsPerSecond: 2500000 // 2.5 Mbps videoBitsPerSecond: 2500000, // 2.5 Mbps
}); })
// Navigate around - recording continues! // Navigate around - recording continues!
await state.page.click('a'); await state.page.click('a')
await state.page.waitForLoadState('domcontentloaded'); await state.page.waitForLoadState('domcontentloaded')
await state.page.goBack(); await state.page.goBack()
// Stop and get result // Stop and get result
const { path, duration, size } = await stopRecording({ page: state.page }); const { path, duration, size } = await stopRecording({ page: state.page })
console.log(`Saved ${size} bytes, duration: ${duration}ms`); console.log(`Saved ${size} bytes, duration: ${duration}ms`)
``` ```
Additional recording utilities: Additional recording utilities:
```js ```js
// Check if recording is active // 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 // 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. **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: 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 ```js
const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1); const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
await el.click(); await el.click()
``` ```
## taking screenshots ## taking screenshots
@@ -865,7 +911,7 @@ await el.click();
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays: Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
```js ```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. 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): 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 ```js
const title = await state.page.evaluate(() => document.title); const title = await state.page.evaluate(() => document.title)
console.log('Title:', title); console.log('Title:', title)
const info = await state.page.evaluate(() => ({ const info = await state.page.evaluate(() => ({
url: location.href, url: location.href,
buttons: document.querySelectorAll('button').length, buttons: document.querySelectorAll('button').length,
})); }))
console.log(info); console.log(info)
``` ```
## loading files ## loading files
@@ -890,7 +936,9 @@ console.log(info);
Fill inputs with file content: Fill inputs with file content:
```js ```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 ## 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: For scraping or reverse-engineering APIs, intercept network requests instead of scrolling DOM. Store in `state` to analyze across calls:
```js ```js
state.requests = []; state.responses = []; state.requests = []
state.page.on('request', req => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }); }); state.responses = []
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.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: Then trigger actions (scroll, click, navigate) and analyze captured data:
```js ```js
console.log('Captured', state.responses.length, 'API calls'); console.log('Captured', state.responses.length, 'API calls')
state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80))); state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
``` ```
Inspect a specific response to understand schema: Inspect a specific response to understand schema:
```js ```js
const resp = state.responses.find(r => r.url.includes('users')); const resp = state.responses.find((r) => r.url.includes('users'))
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000)); console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
``` ```
Replay API directly (useful for pagination): Replay API directly (useful for pagination):
```js ```js
const { url, headers } = state.requests.find(r => r.url.includes('feed')); 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 }); const data = await state.page.evaluate(
console.log(data); 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');` 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: **1. Console logs** — use `getLatestLogs` to check for errors:
```js ```js
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 }); 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 appLogs = await getLatestLogs({ page: state.page, search: /myComponent|state/i })
``` ```
**2. DOM inspection via evaluate** — check content directly without screenshots: **2. DOM inspection via evaluate** — check content directly without screenshots:
```js ```js
const info = await state.page.evaluate(() => { const info = await state.page.evaluate(() => {
const msgs = document.querySelectorAll('.message'); const msgs = document.querySelectorAll('.message')
return Array.from(msgs).map(m => ({ return Array.from(msgs).map((m) => ({
text: m.textContent?.slice(0, 200), text: m.textContent?.slice(0, 200),
visible: m.offsetHeight > 0, 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:** **3. Combine snapshot + logs for full picture:**
```js ```js
await state.page.keyboard.press('Enter'); await state.page.keyboard.press('Enter')
await state.page.waitForTimeout(2000); await state.page.waitForTimeout(2000)
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ }); const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 }); const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
console.log('UI:', snap); console.log('UI:', snap)
console.log('Logs:', logs); console.log('Logs:', logs)
``` ```
## capabilities ## capabilities
Examples of what playwriter can do: Examples of what playwriter can do:
- Monitor console logs while user reproduces a bug - Monitor console logs while user reproduces a bug
- Intercept network requests to reverse-engineer APIs and build SDKs - Intercept network requests to reverse-engineer APIs and build SDKs
- Scrape data by replaying paginated API calls instead of scrolling DOM - 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 - Handle popups, downloads, iframes, and dialog boxes
- Record videos of browser sessions that survive page navigation - Record videos of browser sessions that survive page navigation
## computer use ## 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. 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('button[name="Submit"]').click()
await state.page.locator('text=Login').click({ button: 'right' }) await state.page.locator('text=Login').click({ button: 'right' })
await state.page.locator('text=Login').dblclick() 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) // By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets)
await state.page.mouse.click(450, 320) // left click await state.page.mouse.click(450, 320) // left click
@@ -1023,7 +1089,9 @@ await state.page.mouse.move(450, 320)
await state.page.mouse.wheel(0, 500) await state.page.mouse.wheel(0, 500)
// Scroll inside a container // 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 ### drag
@@ -1071,12 +1139,12 @@ Playwriter supports [Ghost Browser](https://ghostbrowser.com/) for multi-identit
```js ```js
// List identities and open tabs in different ones // List identities and open tabs in different ones
const identities = await chrome.projects.getIdentitiesList(); const identities = await chrome.projects.getIdentitiesList()
await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id }); await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id })
// Assign proxies per tab or identity // Assign proxies per tab or identity
const proxies = await chrome.ghostProxies.getList(); const proxies = await chrome.ghostProxies.getList()
await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id); await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id)
``` ```
For complete API reference with all methods, types, and examples, read: 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 globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
const capturedCommands: CDPCommand[] = [] const capturedCommands: CDPCommand[] = []
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => { const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
@@ -63,7 +63,10 @@ describe('Snapshot & Screenshot Tests', () => {
testCtx!.relayServer.on('cdp:command', commandHandler) testCtx!.relayServer.on('cdp:command', commandHandler)
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) 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() expect(cdpPage).toBeDefined()
@@ -94,10 +97,12 @@ describe('Snapshot & Screenshot Tests', () => {
testCtx!.relayServer.off('cdp:command', commandHandler) testCtx!.relayServer.off('cdp:command', commandHandler)
expect(capturedCommands.length).toBe(2) expect(capturedCommands.length).toBe(2)
expect(capturedCommands.map(c => ({ expect(
capturedCommands.map((c) => ({
method: c.method, method: c.method,
params: c.params params: c.params,
}))).toMatchInlineSnapshot(` })),
).toMatchInlineSnapshot(`
[ [
{ {
"method": "Page.captureScreenshot", "method": "Page.captureScreenshot",
@@ -152,10 +157,13 @@ describe('Snapshot & Screenshot Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
// Get actual browser viewport via JS // Get actual browser viewport via JS
@@ -220,7 +228,7 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
const capturedCommands: CDPCommand[] = [] const capturedCommands: CDPCommand[] = []
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => { const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
@@ -288,7 +296,7 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
const result = await client.callTool({ const result = await client.callTool({
name: 'execute', name: 'execute',
@@ -352,7 +360,7 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
const stylesResult = await client.callTool({ const stylesResult = await client.callTool({
name: 'execute', name: 'execute',
@@ -516,10 +524,13 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
@@ -531,7 +542,8 @@ describe('Snapshot & Screenshot Tests', () => {
cssVisualViewport: layoutMetrics.cssVisualViewport, cssVisualViewport: layoutMetrics.cssVisualViewport,
layoutViewport: layoutMetrics.layoutViewport, layoutViewport: layoutMetrics.layoutViewport,
visualViewport: layoutMetrics.visualViewport, visualViewport: layoutMetrics.visualViewport,
devicePixelRatio: layoutMetrics.cssVisualViewport.clientWidth > 0 devicePixelRatio:
layoutMetrics.cssVisualViewport.clientWidth > 0
? layoutMetrics.visualViewport.clientWidth / layoutMetrics.cssVisualViewport.clientWidth ? layoutMetrics.visualViewport.clientWidth / layoutMetrics.cssVisualViewport.clientWidth
: 1, : 1,
} }
@@ -595,10 +607,13 @@ describe('Snapshot & Screenshot Tests', () => {
await globalThis.toggleExtensionForActiveTab() 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 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() expect(cdpPage).toBeDefined()
// Use the new getCDPSessionForPage which reuses Playwright's internal WS // Use the new getCDPSessionForPage which reuses Playwright's internal WS
@@ -640,7 +655,7 @@ describe('Snapshot & Screenshot Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() 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 })) const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
let cdpPage let cdpPage
@@ -769,12 +784,16 @@ describe('Snapshot & Screenshot Tests', () => {
} }
await page.close() 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) return loadPageWithRetries(testPage)
})) }),
)
for (const { page } of pages) { for (const { page } of pages) {
await page.bringToFront() await page.bringToFront()
@@ -785,7 +804,7 @@ describe('Snapshot & Screenshot Tests', () => {
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) 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 let timeoutId: NodeJS.Timeout | null = null
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
@@ -806,7 +825,10 @@ describe('Snapshot & Screenshot Tests', () => {
for (const { name, url, page } of pages) { for (const { name, url, page } of pages) {
console.log(`[labels] start ${name}`) 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) { if (!cdpPage) {
throw new Error(`Could not find CDP page for ${name}`) throw new Error(`Could not find CDP page for ${name}`)
@@ -818,7 +840,7 @@ describe('Snapshot & Screenshot Tests', () => {
async () => { async () => {
return await showAriaRefLabels({ page: cdpPage }) return await showAriaRefLabels({ page: cdpPage })
}, },
60000 60000,
) )
console.log(`${name}: ${labelCount} labels shown`) console.log(`${name}: ${labelCount} labels shown`)
expect(labelCount).toBeGreaterThan(0) expect(labelCount).toBeGreaterThan(0)
@@ -829,7 +851,7 @@ describe('Snapshot & Screenshot Tests', () => {
async () => { async () => {
return await cdpPage.screenshot({ type: 'png', fullPage: false }) return await cdpPage.screenshot({ type: 'png', fullPage: false })
}, },
30000 30000,
) )
const screenshotPath = path.join(assetsDir, `aria-labels-${name}.png`) const screenshotPath = path.join(assetsDir, `aria-labels-${name}.png`)
fs.writeFileSync(screenshotPath, screenshot) fs.writeFileSync(screenshotPath, screenshot)
@@ -839,11 +861,9 @@ describe('Snapshot & Screenshot Tests', () => {
const labelElements = await withTimeout( const labelElements = await withTimeout(
`countLabels(${name})`, `countLabels(${name})`,
async () => { async () => {
return await cdpPage.evaluate(() => return await cdpPage.evaluate(() => document.querySelectorAll('.__pw_label__').length)
document.querySelectorAll('.__pw_label__').length
)
}, },
10000 10000,
) )
expect(labelElements).toBe(labelCount) expect(labelElements).toBe(labelCount)
@@ -853,17 +873,15 @@ describe('Snapshot & Screenshot Tests', () => {
async () => { async () => {
await hideAriaRefLabels({ page: cdpPage }) await hideAriaRefLabels({ page: cdpPage })
}, },
10000 10000,
) )
const labelsAfterHide = await withTimeout( const labelsAfterHide = await withTimeout(
`verifyHide(${name})`, `verifyHide(${name})`,
async () => { async () => {
return await cdpPage.evaluate(() => return await cdpPage.evaluate(() => document.getElementById('__playwriter_labels__'))
document.getElementById('__playwriter_labels__')
)
}, },
10000 10000,
) )
expect(labelsAfterHide).toBeNull() expect(labelsAfterHide).toBeNull()
@@ -942,7 +960,7 @@ describe('Snapshot & Screenshot Tests', () => {
await serviceWorker.evaluate(async () => { await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab() await globalThis.toggleExtensionForActiveTab()
}) })
await new Promise(r => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
const result = await client.callTool({ const result = await client.callTool({
name: 'execute', name: 'execute',
@@ -965,7 +983,7 @@ describe('Snapshot & Screenshot Tests', () => {
const content = result.content as any[] const content = result.content as any[]
expect(content.length).toBe(2) 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).toBeDefined()
expect(textContent.text).toContain('Screenshot saved to:') expect(textContent.text).toContain('Screenshot saved to:')
expect(textContent.text).toContain('.jpg') expect(textContent.text).toContain('.jpg')
@@ -973,7 +991,7 @@ describe('Snapshot & Screenshot Tests', () => {
expect(textContent.text).toContain('Accessibility snapshot:') expect(textContent.text).toContain('Accessibility snapshot:')
expect(textContent.text).toContain('Submit Form') 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).toBeDefined()
expect(imageContent.mimeType).toBe('image/jpeg') expect(imageContent.mimeType).toBe('image/jpeg')
expect(imageContent.data).toBeDefined() expect(imageContent.data).toBeDefined()
+13 -10
View File
@@ -7,21 +7,24 @@ process.title = 'playwriter-ws-server'
const logger = createFileLogger() const logger = createFileLogger()
process.on('uncaughtException', async (err) => { process.on('uncaughtException', async (err) => {
await logger.error('Uncaught Exception:', err); await logger.error('Uncaught Exception:', err)
process.exit(1); process.exit(1)
}); })
process.on('unhandledRejection', async (reason) => { process.on('unhandledRejection', async (reason) => {
await logger.error('Unhandled Rejection:', reason); await logger.error('Unhandled Rejection:', reason)
process.exit(1); process.exit(1)
}); })
process.on('exit', async (code) => { 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({
export async function startServer({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) { port = 19988,
host = '127.0.0.1',
token,
}: { port?: number; host?: string; token?: string } = {}) {
const server = await startPlayWriterCDPRelayServer({ port, host, token, logger }) const server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
console.log('CDP Relay Server running. Press Ctrl+C to stop.') 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)) 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 let source: StyleSource | null = null
if (styleSheetId && sourceRange) { if (styleSheetId && sourceRange) {
const styleSheet = (matchedStyles as any).cssStyleSheetHeaders?.find( 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 = { source = {
url: (rule as any).styleSheetId ? await getStylesheetUrl(cdp, styleSheetId) : 'user-agent', url: (rule as any).styleSheetId ? await getStylesheetUrl(cdp, styleSheetId) : 'user-agent',
@@ -224,9 +225,7 @@ export async function getStylesForLocator({
} }
} }
const filteredRules = includeUserAgentStyles const filteredRules = includeUserAgentStyles ? rules : rules.filter((r) => r.origin !== 'user-agent')
? rules
: rules.filter((r) => r.origin !== 'user-agent')
return { return {
element: elementDescription, element: elementDescription,
+5 -5
View File
@@ -1,13 +1,13 @@
import type { ExtensionState } from 'mcp-extension/src/types.js' import type { ExtensionState } from 'mcp-extension/src/types.js'
declare global { declare global {
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>; var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>
var getExtensionState: () => ExtensionState; var getExtensionState: () => ExtensionState
var disconnectEverything: () => Promise<void>; var disconnectEverything: () => Promise<void>
// Browser globals used in evaluate() calls // Browser globals used in evaluate() calls
var window: any; var window: any
var document: any; var document: any
} }
export {} export {}
+25 -12
View File
@@ -21,10 +21,15 @@ async function buildExtension({ port, distDir }: { port: number; distDir: string
}) })
.then(async () => { .then(async () => {
// Build into a per-port dist to avoid parallel test runs overwriting each other. // 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 await buildPromise
} }
@@ -103,7 +108,10 @@ export async function setupTestContext({
return { browserContext, userDataDir, relayServer } 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) { if (ctx?.browserContext) {
await ctx.browserContext.close() await ctx.browserContext.close()
} }
@@ -188,7 +196,7 @@ export async function createSseServer(): Promise<SseServer> {
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform', 'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive', Connection: 'keep-alive',
}) })
res.write('retry: 1000\n\n') res.write('retry: 1000\n\n')
res.write('data: hello\n\n') res.write('data: hello\n\n')
@@ -265,11 +273,19 @@ export async function createSseServer(): Promise<SseServer> {
resolve() 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) => { return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
reject(new Error(errorMessage)) 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 */ /** Tagged template for inline JS code strings used in MCP execute calls */
export function js(strings: TemplateStringsArray, ...values: unknown[]): string { export function js(strings: TemplateStringsArray, ...values: unknown[]): string {
return strings.reduce( return strings.reduce((result, str, i) => result + str + (values[i] || ''), '')
(result, str, i) => result + str + (values[i] || ''),
'',
)
} }
export function tryJsonParse(str: string) { export function tryJsonParse(str: string) {
@@ -318,11 +331,11 @@ export function tryJsonParse(str: string) {
*/ */
export async function safeCloseCDPBrowser( export async function safeCloseCDPBrowser(
browser: Awaited<ReturnType<typeof import('@xmorse/playwright-core').chromium.connectOverCDP>>, browser: Awaited<ReturnType<typeof import('@xmorse/playwright-core').chromium.connectOverCDP>>,
drainDelayMs = 50 drainDelayMs = 50,
): Promise<void> { ): Promise<void> {
// Wait for any queued message handlers to run // Wait for any queued message handlers to run
// This gives Playwright's messageWrap time to process pending CDP responses // 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() 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) // 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') 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_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') 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 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 checkPageReady = async (): Promise<{ ready: boolean; readyState: string; pendingRequests: string[] }> => {
const result = await page.evaluate( const result = await page.evaluate(
({ filteredDomains, filteredExtensions, stuckThreshold, slowResourceThreshold }): { ({
filteredDomains,
filteredExtensions,
stuckThreshold,
slowResourceThreshold,
}): {
ready: boolean ready: boolean
readyState: string readyState: string
pendingRequests: string[] pendingRequests: string[]
+11 -10
View File
@@ -1,4 +1,3 @@
import { describe, it, expect, afterEach } from 'vitest' import { describe, it, expect, afterEach } from 'vitest'
import { startPlayWriterCDPRelayServer } from '../src/cdp-relay.js' import { startPlayWriterCDPRelayServer } from '../src/cdp-relay.js'
import { WebSocket } from 'ws' import { WebSocket } from 'ws'
@@ -33,7 +32,7 @@ describe('Security Tests', () => {
server = await startPlayWriterCDPRelayServer({ server = await startPlayWriterCDPRelayServer({
port: TEST_PORT, port: TEST_PORT,
token, token,
logger logger,
}) })
// Helper to try connecting // Helper to try connecting
@@ -72,7 +71,7 @@ describe('Security Tests', () => {
const logger = createFileLogger() const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({ server = await startPlayWriterCDPRelayServer({
port: TEST_PORT, port: TEST_PORT,
logger logger,
}) })
const tryConnectExtension = (origin?: string) => { const tryConnectExtension = (origin?: string) => {
@@ -117,7 +116,11 @@ describe('Security Tests', () => {
// Content-Type: text/plain bypasses CORS entirely). // Content-Type: text/plain bypasses CORS entirely).
// ========================================================================= // =========================================================================
const httpRequest = ({ path, method = 'POST', headers = {} }: { const httpRequest = ({
path,
method = 'POST',
headers = {},
}: {
path: string path: string
method?: string method?: string
headers?: Record<string, string> headers?: Record<string, string>
@@ -229,7 +232,7 @@ describe('Security Tests', () => {
const wrongToken = await httpRequest({ const wrongToken = await httpRequest({
path: '/cli/sessions', path: '/cli/sessions',
method: 'GET', method: 'GET',
headers: { 'Authorization': 'Bearer wrong-token' }, headers: { Authorization: 'Bearer wrong-token' },
}) })
expect(wrongToken.status).toBe(401) expect(wrongToken.status).toBe(401)
@@ -237,14 +240,12 @@ describe('Security Tests', () => {
const bearerOk = await httpRequest({ const bearerOk = await httpRequest({
path: '/cli/sessions', path: '/cli/sessions',
method: 'GET', method: 'GET',
headers: { 'Authorization': `Bearer ${secretToken}` }, headers: { Authorization: `Bearer ${secretToken}` },
}) })
expect(bearerOk.status).toBe(200) expect(bearerOk.status).toBe(200)
// Correct token via query param → pass middleware // Correct token via query param → pass middleware
const queryOk = await fetch( const queryOk = await fetch(`http://127.0.0.1:${TEST_PORT}/cli/sessions?token=${secretToken}`)
`http://127.0.0.1:${TEST_PORT}/cli/sessions?token=${secretToken}`,
)
expect(queryOk.status).toBe(200) expect(queryOk.status).toBe(200)
// Token also enforced on /recording/* // Token also enforced on /recording/*
@@ -258,7 +259,7 @@ describe('Security Tests', () => {
const recordingWithToken = await httpRequest({ const recordingWithToken = await httpRequest({
path: '/recording/status', path: '/recording/status',
method: 'GET', method: 'GET',
headers: { 'Authorization': `Bearer ${secretToken}` }, headers: { Authorization: `Bearer ${secretToken}` },
}) })
expect(recordingWithToken.status).toBe(200) expect(recordingWithToken.status).toBe(200)
}) })
+1
View File
@@ -12,6 +12,7 @@ playwriter skill
``` ```
This outputs the complete documentation including: This outputs the complete documentation including:
- Session management and timeout configuration - Session management and timeout configuration
- Selector strategies (and which ones to AVOID) - Selector strategies (and which ones to AVOID)
- Rules to prevent timeouts and failures - 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 ### Key Functions
#### `getAriaSnapshot()` #### `getAriaSnapshot()`
**Location:** Line 749-1033 in `aria-snapshot.ts` **Location:** Line 749-1033 in `aria-snapshot.ts`
Main entry point that returns `AriaSnapshotResult` containing: Main entry point that returns `AriaSnapshotResult` containing:
- `snapshot`: String representation of the accessibility tree - `snapshot`: String representation of the accessibility tree
- `tree`: Structured tree with nodes - `tree`: Structured tree with nodes
- `refs`: Array of references to interactive elements - `refs`: Array of references to interactive elements
@@ -36,6 +38,7 @@ Main entry point that returns `AriaSnapshotResult` containing:
- `getRefsForLocators()`: Get refs for Playwright locators - `getRefsForLocators()`: Get refs for Playwright locators
**Signature:** **Signature:**
```typescript ```typescript
export async function getAriaSnapshot({ export async function getAriaSnapshot({
page, page,
@@ -43,7 +46,7 @@ export async function getAriaSnapshot({
refFilter, refFilter,
wsUrl, wsUrl,
interactiveOnly = false, interactiveOnly = false,
cdp cdp,
}: { }: {
page: Page page: Page
locator?: Locator locator?: Locator
@@ -60,14 +63,16 @@ export async function getAriaSnapshot({
**Command:** `DOM.getFlattenedDocument` **Command:** `DOM.getFlattenedDocument`
**Location:** Line 772 **Location:** Line 772
```typescript ```typescript
const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { const { nodes: domNodes } = (await session.send('DOM.getFlattenedDocument', {
depth: -1, depth: -1,
pierce: true pierce: true,
}) as Protocol.DOM.GetFlattenedDocumentResponse })) as Protocol.DOM.GetFlattenedDocumentResponse
``` ```
**Parameters:** **Parameters:**
- `depth: -1` - Get entire subtree - `depth: -1` - Get entire subtree
- `pierce: true` - **Traverses iframes and shadow roots** - `pierce: true` - **Traverses iframes and shadow roots**
@@ -77,12 +82,14 @@ const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', {
**Command:** `Accessibility.getFullAXTree` **Command:** `Accessibility.getFullAXTree`
**Location:** Line 791 **Location:** Line 791
```typescript ```typescript
const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree') const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
as Protocol.Accessibility.GetFullAXTreeResponse as Protocol.Accessibility.GetFullAXTreeResponse
``` ```
**Parameters:** None specified (uses defaults) **Parameters:** None specified (uses defaults)
- Default: Returns AX tree for root frame - Default: Returns AX tree for root frame
- **Has optional `frameId` parameter** (not currently used) - **Has optional `frameId` parameter** (not currently used)
@@ -93,10 +100,12 @@ const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
### Current Implementation ### Current Implementation
**DOM Level:** ✅ **FULL SUPPORT** **DOM Level:** ✅ **FULL SUPPORT**
- `DOM.getFlattenedDocument` with `pierce: true` traverses all iframes and shadow roots - `DOM.getFlattenedDocument` with `pierce: true` traverses all iframes and shadow roots
- All DOM nodes from all frames are included in the flattened document - All DOM nodes from all frames are included in the flattened document
**Accessibility Level:** ⚠️ **LIMITED** **Accessibility Level:** ⚠️ **LIMITED**
- `Accessibility.getFullAXTree` is called **without `frameId` parameter** - `Accessibility.getFullAXTree` is called **without `frameId` parameter**
- According to CDP spec, when `frameId` is omitted, **only the root frame is used** - According to CDP spec, when `frameId` is omitted, **only the root frame is used**
- Cross-origin iframes may have additional restrictions - Cross-origin iframes may have additional restrictions
@@ -104,6 +113,7 @@ const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
### CDP Spec Details ### CDP Spec Details
From `Accessibility.pdl`: From `Accessibility.pdl`:
``` ```
experimental command getFullAXTree experimental command getFullAXTree
parameters parameters
@@ -126,11 +136,13 @@ experimental command getFullAXTree
### Evidence from Code ### Evidence from Code
**Scope handling (Line 760-789):** **Scope handling (Line 760-789):**
- Uses `data-pw-scope` attribute to scope snapshots to a locator - Uses `data-pw-scope` attribute to scope snapshots to a locator
- Builds `allowedBackendIds` set from DOM tree traversal - Builds `allowedBackendIds` set from DOM tree traversal
- Filters AX nodes based on `backendDOMNodeId` membership in this set - Filters AX nodes based on `backendDOMNodeId` membership in this set
**Node mapping:** **Node mapping:**
- Each AX node has `backendDOMNodeId` property linking to DOM node - Each AX node has `backendDOMNodeId` property linking to DOM node
- DOM nodes fetched with `pierce: true` include iframe contents - DOM nodes fetched with `pierce: true` include iframe contents
- But AX tree without `frameId` may not cover all frames - But AX tree without `frameId` may not cover all frames
@@ -138,6 +150,7 @@ experimental command getFullAXTree
## Key Data Structures ## Key Data Structures
### AriaSnapshotNode ### AriaSnapshotNode
```typescript ```typescript
type AriaSnapshotNode = { type AriaSnapshotNode = {
role: string role: string
@@ -151,6 +164,7 @@ type AriaSnapshotNode = {
``` ```
### AriaRef ### AriaRef
```typescript ```typescript
interface AriaRef { interface AriaRef {
role: string role: string
@@ -184,12 +198,28 @@ Generates Playwright-compatible locators:
**Location:** Lines 123-143 **Location:** Lines 123-143
Only these roles get refs in interactive mode: Only these roles get refs in interactive mode:
```typescript ```typescript
const INTERACTIVE_ROLES = new Set([ const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'combobox', 'searchbox', 'button',
'checkbox', 'radio', 'slider', 'spinbutton', 'switch', 'link',
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'textbox',
'option', 'tab', 'treeitem', 'img', 'video', 'audio', '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` **File:** `playwriter/src/aria-snapshot.test.ts`
Tests against real websites: Tests against real websites:
- Hacker News - Hacker News
- GitHub - GitHub
**Coverage:** **Coverage:**
- Snapshot generation - Snapshot generation
- Interactive-only mode - Interactive-only mode
- Locator format validation - Locator format validation
@@ -277,12 +309,14 @@ Tests against real websites:
To support iframes properly: To support iframes properly:
1. **Enumerate frames:** 1. **Enumerate frames:**
```typescript ```typescript
const { frameTree } = await session.send('Page.getFrameTree') const { frameTree } = await session.send('Page.getFrameTree')
// Recursively collect all frame IDs // Recursively collect all frame IDs
``` ```
2. **Get AX tree per frame:** 2. **Get AX tree per frame:**
```typescript ```typescript
for (const frameId of frameIds) { for (const frameId of frameIds) {
const { nodes } = await session.send('Accessibility.getFullAXTree', { frameId }) const { nodes } = await session.send('Accessibility.getFullAXTree', { frameId })
@@ -303,12 +337,14 @@ To support iframes properly:
## Summary ## Summary
**File Paths:** **File Paths:**
- Main implementation: `playwriter/src/aria-snapshot.ts` - Main implementation: `playwriter/src/aria-snapshot.ts`
- Executor integration: `playwriter/src/executor.ts` (lines 533-619) - Executor integration: `playwriter/src/executor.ts` (lines 533-619)
- CDP session: `playwriter/src/cdp-session.ts` - CDP session: `playwriter/src/cdp-session.ts`
- Tests: `playwriter/src/aria-snapshot.test.ts` - Tests: `playwriter/src/aria-snapshot.test.ts`
**CDP Commands:** **CDP Commands:**
- `DOM.enable` - Enable DOM domain - `DOM.enable` - Enable DOM domain
- `DOM.getFlattenedDocument({ depth: -1, pierce: true })` - Get all DOM nodes including iframes - `DOM.getFlattenedDocument({ depth: -1, pierce: true })` - Get all DOM nodes including iframes
- `Accessibility.enable` - Enable accessibility domain - `Accessibility.enable` - Enable accessibility domain
@@ -316,6 +352,7 @@ To support iframes properly:
- `DOM.getBoxModel({ backendNodeId })` - Get element positions for labels - `DOM.getBoxModel({ backendNodeId })` - Get element positions for labels
**Frame Handling:** **Frame Handling:**
- ✅ DOM tree includes iframe content (`pierce: true`) - ✅ DOM tree includes iframe content (`pierce: true`)
- ⚠️ Accessibility tree likely **only root frame** (no `frameId` parameter) - ⚠️ Accessibility tree likely **only root frame** (no `frameId` parameter)
- ❌ No frame enumeration or per-frame AX tree fetching - ❌ No frame enumeration or per-frame AX tree fetching
@@ -323,6 +360,7 @@ To support iframes properly:
**Key Insight:** **Key Insight:**
The current implementation may miss interactive elements inside iframes because: The current implementation may miss interactive elements inside iframes because:
1. `Accessibility.getFullAXTree()` without `frameId` only returns root frame 1. `Accessibility.getFullAXTree()` without `frameId` only returns root frame
2. No iteration over child frames to collect their AX trees 2. No iteration over child frames to collect their AX trees
3. Cross-origin iframes would be blocked anyway for security 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 ## Overview
This document contains findings from exploring the Playwright source code to understand: This document contains findings from exploring the Playwright source code to understand:
1. How Playwright implements accessibility snapshots (ariaSnapshot) 1. How Playwright implements accessibility snapshots (ariaSnapshot)
2. Whether Playwright supports getting accessibility tree for iframes/child frames 2. Whether Playwright supports getting accessibility tree for iframes/child frames
3. How Playwright handles frame locators for accessibility 3. How Playwright handles frame locators for accessibility
@@ -69,7 +70,7 @@ From `packages/injected/src/ariaSnapshot.ts:217-232`:
```typescript ```typescript
function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | null { 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') { if (element.nodeName === 'IFRAME') {
const ariaNode: aria.AriaNode = { const ariaNode: aria.AriaNode = {
role: 'iframe', role: 'iframe',
@@ -78,17 +79,18 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode |
props: {}, props: {},
box: computeBox(element), box: computeBox(element),
receivesPointerEvents: true, receivesPointerEvents: true,
active active,
}; }
setAriaNodeElement(ariaNode, element); setAriaNodeElement(ariaNode, element)
computeAriaRef(ariaNode, options); computeAriaRef(ariaNode, options)
return ariaNode; return ariaNode
} }
// ... // ...
} }
``` ```
**Key observations:** **Key observations:**
- IFrame elements are detected and added to the tree with `role: 'iframe'` - IFrame elements are detected and added to the tree with `role: 'iframe'`
- The `children` array is ALWAYS empty for iframes - The `children` array is ALWAYS empty for iframes
- IFrame refs are tracked separately in `snapshot.iframeRefs` array - IFrame refs are tracked separately in `snapshot.iframeRefs` array
@@ -99,11 +101,11 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode |
```typescript ```typescript
export type AriaSnapshot = { export type AriaSnapshot = {
root: aria.AriaNode; root: aria.AriaNode
elements: Map<string, Element>; // ref -> Element mapping elements: Map<string, Element> // ref -> Element mapping
refs: Map<Element, string>; // Element -> ref mapping refs: Map<Element, string> // Element -> ref mapping
iframeRefs: string[]; // List of iframe ref IDs iframeRefs: string[] // List of iframe ref IDs
}; }
``` ```
The `iframeRefs` array contains references to iframe elements but NOT their content. 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 ```typescript
// From protocol.d.ts // From protocol.d.ts
export type getFullAXTreeParameters = { export type getFullAXTreeParameters = {
depth?: number; depth?: number
frameId?: Page.FrameId; // ⚠️ Supports frame-specific queries! frameId?: Page.FrameId // ⚠️ Supports frame-specific queries!
} }
export type getPartialAXTreeParameters = { export type getPartialAXTreeParameters = {
nodeId?: DOM.NodeId; nodeId?: DOM.NodeId
backendNodeId?: DOM.BackendNodeId; backendNodeId?: DOM.BackendNodeId
objectId?: Runtime.RemoteObjectId; objectId?: Runtime.RemoteObjectId
fetchRelatives?: boolean; fetchRelatives?: boolean
} }
``` ```
**However, Playwright does NOT use these CDP commands anywhere in the codebase.** **However, Playwright does NOT use these CDP commands anywhere in the codebase.**
Search results show: Search results show:
- CDP types defined in `protocol.d.ts` - CDP types defined in `protocol.d.ts`
- No actual usage in Chromium implementation files - No actual usage in Chromium implementation files
- Firefox uses a custom `Accessibility.getFullAXTree` via Juggler protocol - 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 ### 5. Why Playwright Uses DOM Traversal Instead of CDP
**Advantages of DOM traversal approach:** **Advantages of DOM traversal approach:**
1. **Cross-browser compatibility** - Works in Firefox, WebKit, Chromium 1. **Cross-browser compatibility** - Works in Firefox, WebKit, Chromium
2. **Full control** - Can customize what gets included/excluded 2. **Full control** - Can customize what gets included/excluded
3. **Performance** - No serialization overhead for large trees 3. **Performance** - No serialization overhead for large trees
4. **Flexibility** - Can implement custom filtering (visibility, aria roles, etc.) 4. **Flexibility** - Can implement custom filtering (visibility, aria roles, etc.)
**Disadvantages:** **Disadvantages:**
1. **Cannot access iframe content** - Browser security prevents cross-origin access 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 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 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 3. **By design**: The `generateAriaTree` function explicitly skips iframe children
**To get iframe content accessibility tree, you would need to:** **To get iframe content accessibility tree, you would need to:**
1. Switch to the iframe's frame context 1. Switch to the iframe's frame context
2. Call `ariaSnapshot()` again on that frame 2. Call `ariaSnapshot()` again on that frame
3. Manually combine the results 3. Manually combine the results
Example: Example:
```typescript ```typescript
// Get main frame snapshot // Get main frame snapshot
const mainSnapshot = await page.locator('body').ariaSnapshot(); const mainSnapshot = await page.locator('body').ariaSnapshot()
// Get iframe content // Get iframe content
const frameElement = await page.frameLocator('iframe'); const frameElement = await page.frameLocator('iframe')
const frame = await frameElement.owner(); const frame = await frameElement.owner()
const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot(); const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot()
// Results are separate - no automatic merging // 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 3. Merge the results into a single tree
**CDP Command:** **CDP Command:**
```typescript ```typescript
await session.send('Accessibility.getFullAXTree', { await session.send('Accessibility.getFullAXTree', {
frameId: 'frame-id-here', frameId: 'frame-id-here',
depth: -1 // unlimited depth depth: -1, // unlimited depth
}); })
``` ```
This would return the full accessibility tree including iframe content, but: This would return the full accessibility tree including iframe content, but:
- Only works in Chromium (not Firefox/WebKit) - Only works in Chromium (not Firefox/WebKit)
- Returns CDP's AXNode format, not Playwright's AriaNode format - Returns CDP's AXNode format, not Playwright's AriaNode format
- Would need conversion logic - Would need conversion logic
@@ -205,34 +214,34 @@ Get accessibility tree for each frame separately:
```typescript ```typescript
// Pseudo-code for MCP implementation // Pseudo-code for MCP implementation
async function getAccessibilitySnapshot({ sessionId, includeFrames = false }) { async function getAccessibilitySnapshot({ sessionId, includeFrames = false }) {
const page = getPage(sessionId); const page = getPage(sessionId)
// Get main frame snapshot // Get main frame snapshot
const mainSnapshot = await page.evaluate(() => { const mainSnapshot = await page.evaluate(() => {
return injected.ariaSnapshot(document.body, { mode: 'ai' }); return injected.ariaSnapshot(document.body, { mode: 'ai' })
}); })
if (!includeFrames) { if (!includeFrames) {
return mainSnapshot; return mainSnapshot
} }
// Get all iframe snapshots // Get all iframe snapshots
const frames = page.frames(); const frames = page.frames()
const frameSnapshots = await Promise.all( const frameSnapshots = await Promise.all(
frames.slice(1).map(async (frame) => { frames.slice(1).map(async (frame) => {
return { return {
frameId: frame.name() || frame.url(), frameId: frame.name() || frame.url(),
snapshot: await frame.evaluate(() => { snapshot: await frame.evaluate(() => {
return injected.ariaSnapshot(document.body, { mode: 'ai' }); return injected.ariaSnapshot(document.body, { mode: 'ai' })
}) }),
}; }
}) }),
); )
return { return {
main: mainSnapshot, main: mainSnapshot,
frames: frameSnapshots frames: frameSnapshots,
}; }
} }
``` ```
@@ -242,24 +251,24 @@ Use CDP `Accessibility.getFullAXTree` for each frame:
```typescript ```typescript
async function getFullAccessibilityTree({ sessionId }) { async function getFullAccessibilityTree({ sessionId }) {
const page = getPage(sessionId); const page = getPage(sessionId)
const frames = page.frames(); const frames = page.frames()
const trees = await Promise.all( const trees = await Promise.all(
frames.map(async (frame) => { 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', { const { nodes } = await session.send('Accessibility.getFullAXTree', {
frameId: frame._id frameId: frame._id,
}); })
return { return {
frameId: frame.url(), frameId: frame.url(),
nodes nodes,
}; }
}) }),
); )
// Convert CDP AXNode[] to Playwright AriaNode format // Convert CDP AXNode[] to Playwright AriaNode format
return convertCDPtoAria(trees); return convertCDPtoAria(trees)
} }
``` ```
@@ -274,48 +283,48 @@ async function getFullAccessibilityTree({ sessionId }) {
```typescript ```typescript
async function getRecursiveSnapshot({ sessionId, maxDepth = 3 }) { async function getRecursiveSnapshot({ sessionId, maxDepth = 3 }) {
const page = getPage(sessionId); const page = getPage(sessionId)
async function getFrameSnapshot(frame, depth = 0) { async function getFrameSnapshot(frame, depth = 0) {
if (depth >= maxDepth) return null; if (depth >= maxDepth) return null
// Get snapshot for this frame // Get snapshot for this frame
const result = await frame.evaluate(() => { const result = await frame.evaluate(() => {
return injected.incrementalAriaSnapshot(document.body, { return injected.incrementalAriaSnapshot(document.body, {
mode: 'ai', mode: 'ai',
refPrefix: `f${depth}_` refPrefix: `f${depth}_`,
}); })
}); })
// Find iframe elements // Find iframe elements
const iframeElements = await frame.$$('iframe'); const iframeElements = await frame.$$('iframe')
// Get snapshots for child frames // Get snapshots for child frames
const childSnapshots = await Promise.all( const childSnapshots = await Promise.all(
iframeElements.map(async (iframeEl) => { iframeElements.map(async (iframeEl) => {
const childFrame = await iframeEl.contentFrame(); const childFrame = await iframeEl.contentFrame()
if (!childFrame) return null; if (!childFrame) return null
return { return {
iframeSrc: await iframeEl.getAttribute('src'), iframeSrc: await iframeEl.getAttribute('src'),
content: await getFrameSnapshot(childFrame, depth + 1) content: await getFrameSnapshot(childFrame, depth + 1),
}; }
}) }),
); )
return { return {
snapshot: result.full, snapshot: result.full,
iframes: childSnapshots.filter(Boolean) iframes: childSnapshots.filter(Boolean),
}; }
} }
return await getFrameSnapshot(page.mainFrame()); return await getFrameSnapshot(page.mainFrame())
} }
``` ```
## Summary ## Summary
| Feature | Playwright Support | Notes | | Feature | Playwright Support | Notes |
|---------|-------------------|-------| | ----------------------------------------- | ------------------ | ----------------------------------------- |
| Accessibility snapshot for current frame | ✅ Yes | Via injected script DOM traversal | | Accessibility snapshot for current frame | ✅ Yes | Via injected script DOM traversal |
| Accessibility snapshot for iframe content | ❌ No | Iframes detected but content not included | | Accessibility snapshot for iframe content | ❌ No | Iframes detected but content not included |
| CDP Accessibility commands | ❌ Not used | Available but Playwright doesn't use them | | 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 | | 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: **Bottom line:** Playwright's `ariaSnapshot()` works on a single frame at a time. To get iframe content, you must:
1. Get the iframe element 1. Get the iframe element
2. Access its `contentFrame()` 2. Access its `contentFrame()`
3. Call `ariaSnapshot()` on that frame 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 ```css
/* WRONG - silently produces no output */ /* WRONG - silently produces no output */
@variant dark { @variant dark {
.my-class { color: white; } .my-class {
color: white;
}
} }
/* CORRECT - nest inside the selector */ /* CORRECT - nest inside the selector */
@@ -37,9 +39,9 @@ Dark mode uses `prefers-color-scheme` media query, configured in `globals.css`:
```css ```css
/* globals.css — this is the Tailwind entry point */ /* globals.css — this is the Tailwind entry point */
@import "tailwindcss"; @import 'tailwindcss';
@import "./editorial.css"; /* editorial page styles (class names, layout) */ @import './editorial.css'; /* editorial page styles (class names, layout) */
@import "./editorial-prism.css"; /* prism syntax highlighting */ @import './editorial-prism.css'; /* prism syntax highlighting */
@custom-variant dark (@media (prefers-color-scheme: dark)); @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. 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 ```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. Use `sips -g pixelWidth -g pixelHeight <file>` to get dimensions on macOS.
+1 -3
View File
@@ -3,9 +3,7 @@
{ {
"name": "playwriter", "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", "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": [ "files": ["SKILL.md"]
"SKILL.md"
]
} }
] ]
} }
@@ -12,6 +12,7 @@ playwriter skill
``` ```
This outputs the complete documentation including: This outputs the complete documentation including:
- Session management and timeout configuration - Session management and timeout configuration
- Selector strategies (and which ones to AVOID) - Selector strategies (and which ones to AVOID)
- Rules to prevent timeouts and failures - 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 ### Session management
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to: Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
- Keep state separate between different tasks or agents - Keep state separate between different tasks or agents
- Persist data (pages, variables) across multiple execute calls - Persist data (pages, variables) across multiple execute calls
- Avoid interference when multiple agents use playwriter simultaneously - 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 ```js
// 1. Open page and observe — always print URL first // 1. Open page and observe — always print URL first
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://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' }); 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) console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage }).then(console.log)
``` ```
```js ```js
// 2. Act: open command palette → observe result // 2. Act: open command palette → observe result
await state.myPage.keyboard.press('Meta+k'); await state.myPage.keyboard.press('Meta+k')
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log) 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 // If dialog didn't appear, observe again before retrying
``` ```
```js ```js
// 3. Act: type search query → observe result // 3. Act: type search query → observe result
await state.myPage.keyboard.type('MCP'); await state.myPage.keyboard.type('MCP')
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log) console.log('URL:', state.myPage.url())
await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
``` ```
```js ```js
// 4. Act: press Enter → observe plugin loaded // 4. Act: press Enter → observe plugin loaded
await state.myPage.keyboard.press('Enter'); await state.myPage.keyboard.press('Enter')
await state.myPage.waitForTimeout(1000); await state.myPage.waitForTimeout(1000)
console.log('URL:', state.myPage.url()); console.log('URL:', state.myPage.url())
const frame = state.myPage.frames().find(f => f.url().includes('plugins.framercdn.com')); const frame = state.myPage.frames().find((f) => f.url().includes('plugins.framercdn.com'))
await snapshot({ page: state.myPage, frame: frame || undefined }).then(console.log) await snapshot({ page: state.myPage, frame: frame || undefined }).then(console.log)
// If frame not found, wait and observe again — plugin may still be loading // 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: - **Network requests** — verify API calls were made after a form submit or button click:
```js ```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: - **URL changes** — confirm navigation happened:
```js ```js
@@ -263,28 +271,31 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
**1. Not verifying actions succeeded** **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: Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
```js ```js
await page.keyboard.type('my text'); await page.keyboard.type('my text')
await snapshot({ page, search: /my text/ }) await snapshot({ page, search: /my text/ })
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead // If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
``` ```
**2. Assuming paste/upload worked** **2. Assuming paste/upload worked**
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input: Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
```js ```js
// Reliable: use file input // Reliable: use file input
const fileInput = page.locator('input[type="file"]').first(); const fileInput = page.locator('input[type="file"]').first()
await fileInput.setInputFiles('/path/to/image.png'); await fileInput.setInputFiles('/path/to/image.png')
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example // 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** **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: Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
```js ```js
// BAD: using ref from minutes ago // 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 // GOOD: get fresh snapshot, then immediately use locators from it
await snapshot({ page, showDiffSinceLastCall: true }) await snapshot({ page, showDiffSinceLastCall: true })
@@ -293,26 +304,29 @@ await snapshot({ page, showDiffSinceLastCall: true })
**4. Wrong assumptions about current page/element** **4. Wrong assumptions about current page/element**
Before destructive actions (delete, submit), verify you're targeting the right thing: Before destructive actions (delete, submit), verify you're targeting the right thing:
```js ```js
// Before deleting, verify it's the right item // Before deleting, verify it's the right item
await screenshotWithAccessibilityLabels({ page }); await screenshotWithAccessibilityLabels({ page })
// READ the screenshot to confirm, THEN proceed with delete // READ the screenshot to confirm, THEN proceed with delete
``` ```
**5. Text concatenation without line breaks** **5. Text concatenation without line breaks**
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`: `keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
```js ```js
// BAD: newlines in string don't create line breaks // 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 // GOOD: use Enter key for line breaks
await page.keyboard.type('Line 1'); await page.keyboard.type('Line 1')
await page.keyboard.press('Enter'); await page.keyboard.press('Enter')
await page.keyboard.type('Line 2'); await page.keyboard.type('Line 2')
``` ```
**6. Quote escaping in $'...' syntax** **6. Quote escaping in $'...' syntax**
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them: When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
```bash ```bash
# BAD: nested double quotes break $'...' # BAD: nested double quotes break $'...'
playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()' playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()'
@@ -329,47 +343,50 @@ EOF
**7. Using screenshots when snapshots suffice** **7. Using screenshots when snapshots suffice**
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues: Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
```js ```js
// BAD: screenshot to check if text appeared (wastes tokens on image analysis) // 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 // GOOD: snapshot is text — fast, cheap, searchable
await snapshot({ page, search: /expected text/i }) await snapshot({ page, search: /expected text/i })
// GOOD: evaluate DOM directly for content checks // 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** **8. Assuming page content loaded**
Even after `goto()`, dynamic content may not be ready: Even after `goto()`, dynamic content may not be ready:
```js ```js
await page.goto('https://example.com'); await page.goto('https://example.com')
// Content may still be loading via JavaScript! // Content may still be loading via JavaScript!
await page.waitForSelector('article', { timeout: 10000 }); await page.waitForSelector('article', { timeout: 10000 })
// Or use waitForPageLoad utility // Or use waitForPageLoad utility
await waitForPageLoad({ page, timeout: 5000 }); await waitForPageLoad({ page, timeout: 5000 })
``` ```
**9. Login buttons that open popups** **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: 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 ```js
// BAD: popup window is not controllable by playwriter // 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 // 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.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
await page.waitForTimeout(2000); await page.waitForTimeout(2000)
// Verify new tab opened - last page should be the login page // Verify new tab opened - last page should be the login page
const pages = context.pages(); const pages = context.pages()
const loginPage = pages[pages.length - 1]; const loginPage = pages[pages.length - 1]
if (loginPage.url() === page.url()) { 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 // Complete login flow in loginPage, cookies are shared with original page
await loginPage.locator('[data-email]').first().click(); await loginPage.locator('[data-email]').first().click()
await loginPage.waitForURL('**/callback**'); await loginPage.waitForURL('**/callback**')
// Original page should now be authenticated // Original page should now be authenticated
``` ```
@@ -379,10 +396,12 @@ After any action (click, submit, navigate), verify what happened. Always print U
```js ```js
// Always print URL first, then snapshot // 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 // 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. 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: **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 ```js
const snap = await snapshot({ page, showDiffSinceLastCall: false }); const snap = await snapshot({ page, showDiffSinceLastCall: false })
const relevant = snap.split('\n').filter(l => const relevant = snap
l.includes('dialog') || l.includes('error') || l.includes('button') .split('\n')
).join('\n'); .filter((l) => l.includes('dialog') || l.includes('error') || l.includes('button'))
console.log(relevant); .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. 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. Both `snapshot` and `screenshotWithAccessibilityLabels` use the same ref system, so you can combine them effectively.
**Use `snapshot` when:** **Use `snapshot` when:**
- Page has simple, semantic structure (articles, forms, lists) - Page has simple, semantic structure (articles, forms, lists)
- You need to search for specific text or patterns - You need to search for specific text or patterns
- Token usage matters (text is smaller than images) - Token usage matters (text is smaller than images)
- You need to process the output programmatically - You need to process the output programmatically
**Use `screenshotWithAccessibilityLabels` when:** **Use `screenshotWithAccessibilityLabels` when:**
- Page has complex visual layout (grids, galleries, dashboards, maps) - Page has complex visual layout (grids, galleries, dashboards, maps)
- Spatial position matters (e.g., "first image", "top-left button") - Spatial position matters (e.g., "first image", "top-left button")
- DOM order doesn't match visual order - 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. // Reuse an empty about:blank tab if available, otherwise create a new one.
// IMPORTANT: always navigate immediately in the same call to avoid another // IMPORTANT: always navigate immediately in the same call to avoid another
// agent grabbing the same about:blank tab between execute calls. // agent grabbing the same about:blank tab between execute calls.
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 state.myPage for ALL subsequent operations // 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 ```js
if (!state.myPage || state.myPage.isClosed()) { 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:** **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: 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 ```js
const pages = context.pages().filter(x => x.url().includes('myapp.com')); 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 === 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`); if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
state.targetPage = pages[0]; state.targetPage = pages[0]
``` ```
**List all available pages:** **List all available pages:**
```js ```js
context.pages().map(p => p.url()) context.pages().map((p) => p.url())
``` ```
## navigation ## navigation
@@ -542,8 +564,8 @@ context.pages().map(p => p.url())
**Use `domcontentloaded`** for `page.goto()`: **Use `domcontentloaded`** for `page.goto()`:
```js ```js
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' }); await page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page, timeout: 5000 }); await waitForPageLoad({ page, timeout: 5000 })
``` ```
## common patterns ## common patterns
@@ -556,9 +578,9 @@ await waitForPageLoad({ page, timeout: 5000 });
// GOOD: fetch inside page.evaluate uses browser's full session // GOOD: fetch inside page.evaluate uses browser's full session
const data = await page.evaluate(async (url) => { const data = await page.evaluate(async (url) => {
const resp = await fetch(url); const resp = await fetch(url)
return await resp.text(); return await resp.text()
}, 'https://example.com/protected/resource'); }, 'https://example.com/protected/resource')
``` ```
**Downloading large data** - console output truncates large strings. Trigger a browser download instead: **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 ```js
// Fetch protected data and trigger download to user's Downloads folder // Fetch protected data and trigger download to user's Downloads folder
await page.evaluate(async (url) => { await page.evaluate(async (url) => {
const resp = await fetch(url); const resp = await fetch(url)
const data = await resp.text(); const data = await resp.text()
const blob = new Blob([data], { type: 'application/octet-stream' }); const blob = new Blob([data], { type: 'application/octet-stream' })
const a = document.createElement('a'); const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob)
a.download = 'data.json'; a.download = 'data.json'
a.click(); a.click()
}, 'https://example.com/protected/large-file'); }, 'https://example.com/protected/large-file')
// File saves to ~/Downloads - read it from there // 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: **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 - `navigator.clipboard.writeText()` - requires permission
- Multiple concurrent downloads - browser may block - Multiple concurrent downloads - browser may block
- `window.showSaveFilePicker()` - requires user gesture - `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): **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 ```js
await page.locator('a[target=_blank]').click({ modifiers: ['Meta'] }); await page.locator('a[target=_blank]').click({ modifiers: ['Meta'] })
await page.waitForTimeout(1000); await page.waitForTimeout(1000)
const pages = context.pages(); const pages = context.pages()
const newTab = pages[pages.length - 1]; const newTab = pages[pages.length - 1]
console.log('New tab URL:', newTab.url()); console.log('New tab URL:', newTab.url())
``` ```
**Downloads** - capture and save: **Downloads** - capture and save:
```js ```js
const [download] = await Promise.all([page.waitForEvent('download'), page.click('button.download')]); const [download] = await Promise.all([page.waitForEvent('download'), page.click('button.download')])
await download.saveAs(`./${download.suggestedFilename()}`); await download.saveAs(`./${download.suggestedFilename()}`)
``` ```
**iFrames** - two approaches depending on what you need: **iFrames** - two approaches depending on what you need:
```js ```js
// frameLocator: for chaining locator operations (click, fill, etc.) // frameLocator: for chaining locator operations (click, fill, etc.)
const frame = page.frameLocator('#my-iframe'); const frame = page.frameLocator('#my-iframe')
await frame.locator('button').click(); await frame.locator('button').click()
// contentFrame: returns a Frame object, needed for snapshot({ frame }) // 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 }) await snapshot({ frame: frame2 })
``` ```
**Dialogs** - handle alerts/confirms/prompts: **Dialogs** - handle alerts/confirms/prompts:
```js ```js
page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); }); page.on('dialog', async (dialog) => {
await page.click('button.trigger-alert'); console.log(dialog.message())
await dialog.accept()
})
await page.click('button.trigger-alert')
``` ```
## utility functions ## utility functions
@@ -645,6 +671,7 @@ const fullHtml = await getCleanHTML({ locator: page, showDiffSinceLastCall: fals
``` ```
**Parameters:** **Parameters:**
- `locator` - Playwright Locator or Page to get HTML from - `locator` - Playwright Locator or Page to get HTML from
- `search` - string/regex to filter results (returns first 10 matching lines with 5 lines context) - `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. - `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:** **HTML processing:**
The function cleans HTML for compact, readable output: The function cleans HTML for compact, readable output:
- **Removes tags**: script, style, link, meta, noscript, svg, head - **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>`) - **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 - **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 - **Truncates long values**: Attribute values >200 chars and text content >500 chars are truncated
**Attributes kept (summary):** **Attributes kept (summary):**
- Common semantic and ARIA attributes (e.g., `href`, `name`, `type`, `aria-*`) - Common semantic and ARIA attributes (e.g., `href`, `name`, `type`, `aria-*`)
- All `data-*` test attributes - All `data-*` test attributes
- Frequently used test IDs and special attributes (e.g., `testid`, `qa`, `e2e`, `vimium-label`) - 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:** **Output format:**
``` ```
# Article Title # Article Title
@@ -683,11 +713,13 @@ The main article content as plain text, with paragraphs preserved...
``` ```
**Parameters:** **Parameters:**
- `page` - Playwright Page to extract content from - `page` - Playwright Page to extract content from
- `search` - string/regex to filter content (returns first 10 matching lines with 5 lines context) - `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. - `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full content.
**Use cases:** **Use cases:**
- Extract article text for LLM processing without HTML noise - Extract article text for LLM processing without HTML noise
- Get readable content from news sites, blogs, documentation - Get readable content from news sites, blogs, documentation
- Compare content changes after interactions - Compare content changes after interactions
@@ -702,46 +734,50 @@ await waitForPageLoad({ page, timeout?, pollInterval?, minWait? })
**getCDPSession** - send raw CDP commands: **getCDPSession** - send raw CDP commands:
```js ```js
const cdp = await getCDPSession({ page }); const cdp = await getCDPSession({ page })
const metrics = await cdp.send('Page.getLayoutMetrics'); const metrics = await cdp.send('Page.getLayoutMetrics')
``` ```
**getLocatorStringForElement** - get stable Playwright selector from an element: **getLocatorStringForElement** - get stable Playwright selector from an element:
```js ```js
const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]')); const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]'))
// => "getByRole('button', { name: 'Save' })" // => "getByRole('button', { name: 'Save' })"
``` ```
**getReactSource** - get React component source location (dev mode only): **getReactSource** - get React component source location (dev mode only):
```js ```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 } // => { 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. **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 ```js
const styles = await getStylesForLocator({ locator: page.locator('.btn'), cdp: await getCDPSession({ page }) }); const styles = await getStylesForLocator({ locator: page.locator('.btn'), cdp: await getCDPSession({ page }) })
console.log(formatStylesAsText(styles)); 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. **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 ```js
const cdp = await getCDPSession({ page }); const dbg = createDebugger({ cdp }); await dbg.enable(); const cdp = await getCDPSession({ page })
const scripts = await dbg.listScripts({ search: 'app' }); const dbg = createDebugger({ cdp })
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 }); 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() // 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. **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 ```js
const cdp = await getCDPSession({ page }); const editor = createEditor({ cdp }); await editor.enable(); const cdp = await getCDPSession({ page })
const matches = await editor.grep({ regex: /console\.log/ }); const editor = createEditor({ cdp })
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' }); 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. **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. 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 ```js
await screenshotWithAccessibilityLabels({ page }); await screenshotWithAccessibilityLabels({ page })
// Image and accessibility snapshot are automatically included in response // Image and accessibility snapshot are automatically included in response
// Use refs from snapshot to interact with elements // 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 // Can take multiple screenshots in one execution
await screenshotWithAccessibilityLabels({ page }); await screenshotWithAccessibilityLabels({ page })
await page.click('button'); await page.click('button')
await screenshotWithAccessibilityLabels({ page }); await screenshotWithAccessibilityLabels({ page })
// Both images are included in the response // Both images are included in the response
``` ```
@@ -774,26 +810,27 @@ await startRecording({
outputPath: './recording.mp4', outputPath: './recording.mp4',
frameRate: 30, // default: 30 frameRate: 30, // default: 30
audio: false, // default: false (tab audio) audio: false, // default: false (tab audio)
videoBitsPerSecond: 2500000 // 2.5 Mbps videoBitsPerSecond: 2500000, // 2.5 Mbps
}); })
// Navigate around - recording continues! // Navigate around - recording continues!
await page.click('a'); await page.click('a')
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded')
await page.goBack(); await page.goBack()
// Stop and get result // Stop and get result
const { path, duration, size } = await stopRecording({ page }); const { path, duration, size } = await stopRecording({ page })
console.log(`Saved ${size} bytes, duration: ${duration}ms`); console.log(`Saved ${size} bytes, duration: ${duration}ms`)
``` ```
Additional recording utilities: Additional recording utilities:
```js ```js
// Check if recording is active // Check if recording is active
const { isRecording, startedAt } = await isRecording({ page }); const { isRecording, startedAt } = await isRecording({ page })
// Cancel recording without saving // 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. **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: 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 ```js
const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1); const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
await el.click(); await el.click()
``` ```
## taking screenshots ## taking screenshots
@@ -812,7 +849,7 @@ await el.click();
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays: Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
```js ```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. 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): 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 ```js
const title = await page.evaluate(() => document.title); const title = await page.evaluate(() => document.title)
console.log('Title:', title); console.log('Title:', title)
const info = await page.evaluate(() => ({ const info = await page.evaluate(() => ({
url: location.href, url: location.href,
buttons: document.querySelectorAll('button').length, buttons: document.querySelectorAll('button').length,
})); }))
console.log(info); console.log(info)
``` ```
## loading files ## loading files
@@ -837,7 +874,9 @@ console.log(info);
Fill inputs with file content: Fill inputs with file content:
```js ```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 ## 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: For scraping or reverse-engineering APIs, intercept network requests instead of scrolling DOM. Store in `state` to analyze across calls:
```js ```js
state.requests = []; state.responses = []; state.requests = []
page.on('request', req => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }); }); 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 {} } }); 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: Then trigger actions (scroll, click, navigate) and analyze captured data:
```js ```js
console.log('Captured', state.responses.length, 'API calls'); console.log('Captured', state.responses.length, 'API calls')
state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80))); state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
``` ```
Inspect a specific response to understand schema: Inspect a specific response to understand schema:
```js ```js
const resp = state.responses.find(r => r.url.includes('users')); const resp = state.responses.find((r) => r.url.includes('users'))
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000)); console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
``` ```
Replay API directly (useful for pagination): Replay API directly (useful for pagination):
```js ```js
const { url, headers } = state.requests.find(r => r.url.includes('feed')); 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 }); const data = await page.evaluate(
console.log(data); 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');` 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: **1. Console logs** — use `getLatestLogs` to check for errors:
```js ```js
const errors = await getLatestLogs({ page, search: /error|fail/i, count: 20 }); const errors = await getLatestLogs({ page, search: /error|fail/i, count: 20 })
const appLogs = await getLatestLogs({ page, search: /myComponent|state/i }); const appLogs = await getLatestLogs({ page, search: /myComponent|state/i })
``` ```
**2. DOM inspection via evaluate** — check content directly without screenshots: **2. DOM inspection via evaluate** — check content directly without screenshots:
```js ```js
const info = await page.evaluate(() => { const info = await page.evaluate(() => {
const msgs = document.querySelectorAll('.message'); const msgs = document.querySelectorAll('.message')
return Array.from(msgs).map(m => ({ return Array.from(msgs).map((m) => ({
text: m.textContent?.slice(0, 200), text: m.textContent?.slice(0, 200),
visible: m.offsetHeight > 0, 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:** **3. Combine snapshot + logs for full picture:**
```js ```js
await page.keyboard.press('Enter'); await page.keyboard.press('Enter')
await page.waitForTimeout(2000); await page.waitForTimeout(2000)
const snap = await snapshot({ page, search: /dialog|error|message/ }); const snap = await snapshot({ page, search: /dialog|error|message/ })
const logs = await getLatestLogs({ page, search: /error/i, count: 10 }); const logs = await getLatestLogs({ page, search: /error/i, count: 10 })
console.log('UI:', snap); console.log('UI:', snap)
console.log('Logs:', logs); console.log('Logs:', logs)
``` ```
## capabilities ## capabilities
Examples of what playwriter can do: Examples of what playwriter can do:
- Monitor console logs while user reproduces a bug - Monitor console logs while user reproduces a bug
- Intercept network requests to reverse-engineer APIs and build SDKs - Intercept network requests to reverse-engineer APIs and build SDKs
- Scrape data by replaying paginated API calls instead of scrolling DOM - 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 - Handle popups, downloads, iframes, and dialog boxes
- Record videos of browser sessions that survive page navigation - Record videos of browser sessions that survive page navigation
## computer use ## 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. 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('button[name="Submit"]').click()
await page.locator('text=Login').click({ button: 'right' }) await page.locator('text=Login').click({ button: 'right' })
await page.locator('text=Login').dblclick() 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) // By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets)
await page.mouse.click(450, 320) // left click await page.mouse.click(450, 320) // left click
@@ -970,7 +1027,9 @@ await page.mouse.move(450, 320)
await page.mouse.wheel(0, 500) await page.mouse.wheel(0, 500)
// Scroll inside a container // 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 ### drag
@@ -1018,12 +1077,12 @@ Playwriter supports [Ghost Browser](https://ghostbrowser.com/) for multi-identit
```js ```js
// List identities and open tabs in different ones // List identities and open tabs in different ones
const identities = await chrome.projects.getIdentitiesList(); const identities = await chrome.projects.getIdentitiesList()
await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id }); await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id })
// Assign proxies per tab or identity // Assign proxies per tab or identity
const proxies = await chrome.ghostProxies.getList(); const proxies = await chrome.ghostProxies.getList()
await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id); await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id)
``` ```
For complete API reference with all methods, types, and examples, read: For complete API reference with all methods, types, and examples, read:
+53 -78
View File
@@ -2,31 +2,31 @@
## Types ## Types
```ts ````ts
import type { ICDPSession } from './cdp-session.js'; import type { ICDPSession } from './cdp-session.js'
export interface BreakpointInfo { export interface BreakpointInfo {
id: string; id: string
file: string; file: string
line: number; line: number
} }
export interface LocationInfo { export interface LocationInfo {
url: string; url: string
lineNumber: number; lineNumber: number
columnNumber: number; columnNumber: number
callstack: Array<{ callstack: Array<{
functionName: string; functionName: string
url: string; url: string
lineNumber: number; lineNumber: number
columnNumber: number; columnNumber: number
}>; }>
sourceContext: string; sourceContext: string
} }
export interface EvaluateResult { export interface EvaluateResult {
value: unknown; value: unknown
} }
export interface ScriptInfo { export interface ScriptInfo {
scriptId: string; scriptId: string
url: string; url: string
} }
/** /**
* A class for debugging JavaScript code via Chrome DevTools Protocol. * A class for debugging JavaScript code via Chrome DevTools Protocol.
@@ -45,14 +45,14 @@ export interface ScriptInfo {
* ``` * ```
*/ */
export declare class Debugger { export declare class Debugger {
private cdp; private cdp
private debuggerEnabled; private debuggerEnabled
private paused; private paused
private currentCallFrames; private currentCallFrames
private breakpoints; private breakpoints
private scripts; private scripts
private xhrBreakpoints; private xhrBreakpoints
private blackboxPatterns; private blackboxPatterns
/** /**
* Creates a new Debugger instance. * Creates a new Debugger instance.
* *
@@ -66,10 +66,8 @@ export declare class Debugger {
* const dbg = new Debugger({ cdp }) * const dbg = new Debugger({ cdp })
* ``` * ```
*/ */
constructor({ cdp }: { constructor({ cdp }: { cdp: ICDPSession })
cdp: ICDPSession; private setupEventListeners
});
private setupEventListeners;
/** /**
* Enables the debugger and runtime domains. Called automatically by other methods. * Enables the debugger and runtime domains. Called automatically by other methods.
* Also resumes execution if the target was started with --inspect-brk. * Also resumes execution if the target was started with --inspect-brk.
@@ -79,7 +77,7 @@ export declare class Debugger {
* await dbg.enable() * await dbg.enable()
* ``` * ```
*/ */
enable(): Promise<void>; enable(): Promise<void>
/** /**
* Sets a breakpoint at a specified URL and line number. * Sets a breakpoint at a specified URL and line number.
* Use the URL from listScripts() to find available scripts. * Use the URL from listScripts() to find available scripts.
@@ -104,11 +102,7 @@ export declare class Debugger {
* }) * })
* ``` * ```
*/ */
setBreakpoint({ file, line, condition }: { setBreakpoint({ file, line, condition }: { file: string; line: number; condition?: string }): Promise<string>
file: string;
line: number;
condition?: string;
}): Promise<string>;
/** /**
* Removes a breakpoint by its ID. * Removes a breakpoint by its ID.
* *
@@ -120,9 +114,7 @@ export declare class Debugger {
* await dbg.deleteBreakpoint({ breakpointId: 'bp-123' }) * await dbg.deleteBreakpoint({ breakpointId: 'bp-123' })
* ``` * ```
*/ */
deleteBreakpoint({ breakpointId }: { deleteBreakpoint({ breakpointId }: { breakpointId: string }): Promise<void>
breakpointId: string;
}): Promise<void>;
/** /**
* Returns a list of all active breakpoints set by this debugger instance. * 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 }] * // [{ id: 'bp-123', file: 'https://example.com/index.js', line: 42 }]
* ``` * ```
*/ */
listBreakpoints(): BreakpointInfo[]; listBreakpoints(): BreakpointInfo[]
/** /**
* Inspects local variables in the current call frame. * Inspects local variables in the current call frame.
* Must be paused at a breakpoint. String values over 1000 chars are truncated. * 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 } * // { myVar: 'hello', count: 42 }
* ``` * ```
*/ */
inspectLocalVariables(): Promise<Record<string, unknown>>; inspectLocalVariables(): Promise<Record<string, unknown>>
/** /**
* Returns global lexical scope variable names. * Returns global lexical scope variable names.
* *
@@ -161,7 +153,7 @@ export declare class Debugger {
* // ['myGlobal', 'CONFIG'] * // ['myGlobal', 'CONFIG']
* ``` * ```
*/ */
inspectGlobalVariables(): Promise<string[]>; inspectGlobalVariables(): Promise<string[]>
/** /**
* Evaluates a JavaScript expression and returns the result. * Evaluates a JavaScript expression and returns the result.
* When paused at a breakpoint, evaluates in the current stack frame scope, * 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' }) * const full = await dbg.evaluate({ expression: 'largeStringVar' })
* ``` * ```
*/ */
evaluate({ expression }: { evaluate({ expression }: { expression: string }): Promise<EvaluateResult>
expression: string;
}): Promise<EvaluateResult>;
/** /**
* Gets the current execution location when paused at a breakpoint. * Gets the current execution location when paused at a breakpoint.
* Includes the call stack and surrounding source code for context. * Includes the call stack and surrounding source code for context.
@@ -204,7 +194,7 @@ export declare class Debugger {
* // 43: }' * // 43: }'
* ``` * ```
*/ */
getLocation(): Promise<LocationInfo>; getLocation(): Promise<LocationInfo>
/** /**
* Steps over to the next line of code, not entering function calls. * 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() * const newLocation = await dbg.getLocation()
* ``` * ```
*/ */
stepOver(): Promise<void>; stepOver(): Promise<void>
/** /**
* Steps into a function call on the current line. * Steps into a function call on the current line.
* *
@@ -229,7 +219,7 @@ export declare class Debugger {
* // now inside the called function * // now inside the called function
* ``` * ```
*/ */
stepInto(): Promise<void>; stepInto(): Promise<void>
/** /**
* Steps out of the current function, returning to the caller. * Steps out of the current function, returning to the caller.
* *
@@ -242,7 +232,7 @@ export declare class Debugger {
* // back in the calling function * // back in the calling function
* ``` * ```
*/ */
stepOut(): Promise<void>; stepOut(): Promise<void>
/** /**
* Resumes code execution until the next breakpoint or completion. * Resumes code execution until the next breakpoint or completion.
* *
@@ -254,7 +244,7 @@ export declare class Debugger {
* // execution continues * // execution continues
* ``` * ```
*/ */
resume(): Promise<void>; resume(): Promise<void>
/** /**
* Returns whether the debugger is currently paused at a breakpoint. * 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. * Configures the debugger to pause on exceptions.
* *
@@ -286,9 +276,7 @@ export declare class Debugger {
* await dbg.setPauseOnExceptions({ state: 'none' }) * await dbg.setPauseOnExceptions({ state: 'none' })
* ``` * ```
*/ */
setPauseOnExceptions({ state }: { setPauseOnExceptions({ state }: { state: 'none' | 'uncaught' | 'all' }): Promise<void>
state: 'none' | 'uncaught' | 'all';
}): Promise<void>;
/** /**
* Lists available scripts where breakpoints can be set. * Lists available scripts where breakpoints can be set.
* Automatically enables the debugger if not already enabled. * Automatically enables the debugger if not already enabled.
@@ -308,16 +296,10 @@ export declare class Debugger {
* // [{ scriptId: '5', url: 'https://example.com/handlers.js' }] * // [{ scriptId: '5', url: 'https://example.com/handlers.js' }]
* ``` * ```
*/ */
listScripts({ search }?: { listScripts({ search }?: { search?: string }): Promise<ScriptInfo[]>
search?: string; setXHRBreakpoint({ url }: { url: string }): Promise<void>
}): Promise<ScriptInfo[]>; removeXHRBreakpoint({ url }: { url: string }): Promise<void>
setXHRBreakpoint({ url }: { listXHRBreakpoints(): string[]
url: string;
}): Promise<void>;
removeXHRBreakpoint({ url }: {
url: string;
}): Promise<void>;
listXHRBreakpoints(): string[];
/** /**
* Sets regex patterns for scripts to blackbox (skip when stepping). * Sets regex patterns for scripts to blackbox (skip when stepping).
* Blackboxed scripts are hidden from the call stack and stepped over automatically. * Blackboxed scripts are hidden from the call stack and stepped over automatically.
@@ -348,9 +330,7 @@ export declare class Debugger {
* await dbg.setBlackboxPatterns({ patterns: [] }) * await dbg.setBlackboxPatterns({ patterns: [] })
* ``` * ```
*/ */
setBlackboxPatterns({ patterns }: { setBlackboxPatterns({ patterns }: { patterns: string[] }): Promise<void>
patterns: string[];
}): Promise<void>;
/** /**
* Adds a single regex pattern to the blackbox list. * Adds a single regex pattern to the blackbox list.
* *
@@ -363,27 +343,23 @@ export declare class Debugger {
* await dbg.addBlackboxPattern({ pattern: 'node_modules/axios' }) * await dbg.addBlackboxPattern({ pattern: 'node_modules/axios' })
* ``` * ```
*/ */
addBlackboxPattern({ pattern }: { addBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
pattern: string;
}): Promise<void>;
/** /**
* Removes a pattern from the blackbox list. * Removes a pattern from the blackbox list.
* *
* @param options - Options * @param options - Options
* @param options.pattern - The exact pattern string to remove * @param options.pattern - The exact pattern string to remove
*/ */
removeBlackboxPattern({ pattern }: { removeBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
pattern: string;
}): Promise<void>;
/** /**
* Returns the current list of blackbox patterns. * Returns the current list of blackbox patterns.
*/ */
listBlackboxPatterns(): string[]; listBlackboxPatterns(): string[]
private truncateValue; private truncateValue
private formatPropertyValue; private formatPropertyValue
private processRemoteObject; private processRemoteObject
} }
``` ````
## Examples ## Examples
@@ -454,5 +430,4 @@ async function cleanupBreakpoints() {
} }
export { listScriptsAndSetBreakpoint, inspectWhenPaused, stepThroughCode, 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 ## Types
```ts ````ts
import type { ICDPSession } from './cdp-session.js'; import type { ICDPSession } from './cdp-session.js'
export interface ReadResult { export interface ReadResult {
content: string; content: string
totalLines: number; totalLines: number
startLine: number; startLine: number
endLine: number; endLine: number
} }
export interface SearchMatch { export interface SearchMatch {
url: string; url: string
lineNumber: number; lineNumber: number
lineContent: string; lineContent: string
} }
export interface EditResult { export interface EditResult {
success: boolean; success: boolean
stackChanged?: boolean; stackChanged?: boolean
} }
/** /**
* A class for viewing and editing web page scripts via Chrome DevTools Protocol. * A class for viewing and editing web page scripts via Chrome DevTools Protocol.
@@ -49,22 +49,20 @@ export interface EditResult {
* ``` * ```
*/ */
export declare class Editor { export declare class Editor {
private cdp; private cdp
private enabled; private enabled
private scripts; private scripts
private stylesheets; private stylesheets
private sourceCache; private sourceCache
constructor({ cdp }: { constructor({ cdp }: { cdp: ICDPSession })
cdp: ICDPSession; private setupEventListeners
});
private setupEventListeners;
/** /**
* Enables the editor. Must be called before other methods. * Enables the editor. Must be called before other methods.
* Scripts are collected from Debugger.scriptParsed events. * Scripts are collected from Debugger.scriptParsed events.
* Reload the page after enabling to capture all scripts. * Reload the page after enabling to capture all scripts.
*/ */
enable(): Promise<void>; enable(): Promise<void>
private getIdByUrl; private getIdByUrl
/** /**
* Lists available script and stylesheet URLs. Use pattern to filter by regex. * Lists available script and stylesheet URLs. Use pattern to filter by regex.
* Automatically enables the editor if not already enabled. * Automatically enables the editor if not already enabled.
@@ -88,9 +86,7 @@ export declare class Editor {
* const appScripts = await editor.list({ pattern: /app/ }) * const appScripts = await editor.list({ pattern: /app/ })
* ``` * ```
*/ */
list({ pattern }?: { list({ pattern }?: { pattern?: RegExp }): Promise<string[]>
pattern?: RegExp;
}): Promise<string[]>;
/** /**
* Reads a script or stylesheet's source code by URL. * Reads a script or stylesheet's source code by URL.
* Returns line-numbered content like Claude Code's Read tool. * Returns line-numbered content like Claude Code's Read tool.
@@ -120,12 +116,8 @@ export declare class Editor {
* }) * })
* ``` * ```
*/ */
read({ url, offset, limit }: { read({ url, offset, limit }: { url: string; offset?: number; limit?: number }): Promise<ReadResult>
url: string; private getSource
offset?: number;
limit?: number;
}): Promise<ReadResult>;
private getSource;
/** /**
* Edits a script or stylesheet by replacing oldString with newString. * Edits a script or stylesheet by replacing oldString with newString.
* Like Claude Code's Edit tool - performs exact string replacement. * Like Claude Code's Edit tool - performs exact string replacement.
@@ -154,13 +146,18 @@ export declare class Editor {
* }) * })
* ``` * ```
*/ */
edit({ url, oldString, newString, dryRun, }: { edit({
url: string; url,
oldString: string; oldString,
newString: string; newString,
dryRun?: boolean; dryRun,
}): Promise<EditResult>; }: {
private setSource; url: string
oldString: string
newString: string
dryRun?: boolean
}): Promise<EditResult>
private setSource
/** /**
* Searches for a regex across all scripts and stylesheets. * Searches for a regex across all scripts and stylesheets.
* Like Claude Code's Grep tool - returns matching lines with context. * Like Claude Code's Grep tool - returns matching lines with context.
@@ -188,10 +185,7 @@ export declare class Editor {
* }) * })
* ``` * ```
*/ */
grep({ regex, pattern }: { grep({ regex, pattern }: { regex: RegExp; pattern?: RegExp }): Promise<SearchMatch[]>
regex: RegExp;
pattern?: RegExp;
}): Promise<SearchMatch[]>;
/** /**
* Writes entire content to a script or stylesheet, replacing all existing code. * Writes entire content to a script or stylesheet, replacing all existing code.
* Use with caution - prefer edit() for targeted changes. * Use with caution - prefer edit() for targeted changes.
@@ -201,13 +195,9 @@ export declare class Editor {
* @param options.content - New content * @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS) * @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/ */
write({ url, content, dryRun }: { write({ url, content, dryRun }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult>
url: string;
content: string;
dryRun?: boolean;
}): Promise<EditResult>;
} }
``` ````
## Examples ## Examples
@@ -359,6 +349,15 @@ async function searchStyles() {
console.log(matches) 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 ## Types
```ts ```ts
import type { ICDPSession } from './cdp-session.js'; import type { ICDPSession } from './cdp-session.js'
import type { Locator } from '@xmorse/playwright-core'; import type { Locator } from '@xmorse/playwright-core'
export interface StyleSource { export interface StyleSource {
url: string; url: string
line: number; line: number
column: number; column: number
} }
export type StyleDeclarations = Record<string, string>; export type StyleDeclarations = Record<string, string>
export interface StyleRule { export interface StyleRule {
selector: string; selector: string
source: StyleSource | null; source: StyleSource | null
origin: 'regular' | 'user-agent' | 'injected' | 'inspector'; origin: 'regular' | 'user-agent' | 'injected' | 'inspector'
declarations: StyleDeclarations; declarations: StyleDeclarations
inheritedFrom: string | null; inheritedFrom: string | null
} }
export interface StylesResult { export interface StylesResult {
element: string; element: string
inlineStyle: StyleDeclarations | null; inlineStyle: StyleDeclarations | null
rules: StyleRule[]; rules: StyleRule[]
} }
export declare function getStylesForLocator({ locator, cdp: cdpSession, includeUserAgentStyles, }: { export declare function getStylesForLocator({
locator: Locator; locator,
cdp: ICDPSession; cdp: cdpSession,
includeUserAgentStyles?: boolean; includeUserAgentStyles,
}): Promise<StylesResult>; }: {
export declare function formatStylesAsText(styles: StylesResult): string; locator: Locator
cdp: ICDPSession
includeUserAgentStyles?: boolean
}): Promise<StylesResult>
export declare function formatStylesAsText(styles: StylesResult): string
``` ```
## Examples ## Examples
@@ -112,6 +116,12 @@ async function compareStyles() {
console.log(formatStylesAsText(secondary)) 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 type { Config } from '@react-router/dev/config'
import { vercelPreset } from '@vercel/react-router/vite'; import { vercelPreset } from '@vercel/react-router/vite'
export default { export default {
appDirectory: "src", appDirectory: 'src',
ssr: true, ssr: true,
presets: [vercelPreset()], presets: [vercelPreset()],
prerender: ["/"], prerender: ['/'],
} satisfies Config; } satisfies Config
+27 -32
View File
@@ -17,59 +17,54 @@
* Usage: tsx website/scripts/generate-placeholders.ts * Usage: tsx website/scripts/generate-placeholders.ts
*/ */
import sharp from "sharp"; import sharp from 'sharp'
import path from "node:path"; import path from 'node:path'
import fs from "node:fs"; import fs from 'node:fs'
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public"); const PUBLIC_DIR = path.resolve(import.meta.dirname, '../public')
const OUTPUT_DIR = path.resolve(import.meta.dirname, "../src/assets/placeholders"); const OUTPUT_DIR = path.resolve(import.meta.dirname, '../src/assets/placeholders')
const PLACEHOLDER_PREFIX = "placeholder-"; const PLACEHOLDER_PREFIX = 'placeholder-'
const PLACEHOLDER_WIDTH = 64; const PLACEHOLDER_WIDTH = 64
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]); const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
async function main() { 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) => { const images = entries.filter((name) => {
if (name.startsWith(PLACEHOLDER_PREFIX)) { if (name.startsWith(PLACEHOLDER_PREFIX)) {
return false; return false
} }
const ext = path.extname(name).toLowerCase(); const ext = path.extname(name).toLowerCase()
return IMAGE_EXTENSIONS.has(ext); return IMAGE_EXTENSIONS.has(ext)
}); })
if (images.length === 0) { if (images.length === 0) {
console.error("No images found in public/"); console.error('No images found in public/')
return; return
} }
for (const name of images) { for (const name of images) {
const inputPath = path.join(PUBLIC_DIR, name); const inputPath = path.join(PUBLIC_DIR, name)
const ext = path.extname(name); const ext = path.extname(name)
const base = path.basename(name, ext); const base = path.basename(name, ext)
const outputPath = path.join(OUTPUT_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`); const outputPath = path.join(OUTPUT_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`)
// Skip if placeholder already exists and is newer than source // Skip if placeholder already exists and is newer than source
if (fs.existsSync(outputPath)) { if (fs.existsSync(outputPath)) {
const srcMtime = fs.statSync(inputPath).mtimeMs; const srcMtime = fs.statSync(inputPath).mtimeMs
const outMtime = fs.statSync(outputPath).mtimeMs; const outMtime = fs.statSync(outputPath).mtimeMs
if (outMtime > srcMtime) { if (outMtime > srcMtime) {
continue; continue
} }
} }
await sharp(inputPath) await sharp(inputPath).resize(PLACEHOLDER_WIDTH).png({ compressionLevel: 9 }).toFile(outputPath)
.resize(PLACEHOLDER_WIDTH)
.png({ compressionLevel: 9 })
.toFile(outputPath);
const stats = fs.statSync(outputPath); const stats = fs.statSync(outputPath)
console.error( console.error(`Generated ${PLACEHOLDER_PREFIX}${base}${ext} (${PLACEHOLDER_WIDTH}px wide, ${stats.size} bytes)`)
`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 { startTransition } from 'react'
import { hydrateRoot } from "react-dom/client"; import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from "react-router/dom"; import { HydratedRouter } from 'react-router/dom'
startTransition(() => { startTransition(() => {
hydrateRoot(document, <HydratedRouter />); hydrateRoot(document, <HydratedRouter />)
}); })
+56 -74
View File
@@ -1,17 +1,12 @@
import { PassThrough } from "node:stream"; import { PassThrough } from 'node:stream'
import { createReadableStreamFromReadable } from "@react-router/node"; import { createReadableStreamFromReadable } from '@react-router/node'
import { isbot } from "isbot"; import { isbot } from 'isbot'
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from 'react-dom/server'
import type { import type { ActionFunctionArgs, AppLoadContext, EntryContext, LoaderFunctionArgs } from 'react-router'
ActionFunctionArgs, import { isRouteErrorResponse, ServerRouter } from 'react-router'
AppLoadContext, import { notifyError } from './lib/errors'
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( export default function handleRequest(
request: Request, request: Request,
@@ -21,32 +16,22 @@ export default function handleRequest(
// This is ignored so we can keep it in the template for visibility. Feel // 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! // free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext loadContext: AppLoadContext,
) { ) {
return isbot(request.headers.get("user-agent") || "") return isbot(request.headers.get('user-agent') || '')
? handleBotRequest( ? handleBotRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
request, : handleBrowserRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
responseStatusCode,
responseHeaders,
reactRouterContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
reactRouterContext
);
} }
function handleBotRequest( function handleBotRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
reactRouterContext: EntryContext reactRouterContext: EntryContext,
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<ServerRouter <ServerRouter
context={reactRouterContext} context={reactRouterContext}
@@ -55,51 +40,51 @@ function handleBotRequest(
/>, />,
{ {
onAllReady() { onAllReady() {
shellRendered = true; shellRendered = true
const body = new PassThrough(); const body = new PassThrough()
const stream = createReadableStreamFromReadable(body); const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html"); responseHeaders.set('Content-Type', 'text/html')
clearTimeout(timeoutId); clearTimeout(timeoutId)
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode, status: responseStatusCode,
}) }),
); )
pipe(body); pipe(body)
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
clearTimeout(timeoutId); clearTimeout(timeoutId)
reject(error); reject(error)
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500; responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll // errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest. // reject and get logged in handleDocumentRequest.
if (shellRendered) { if (shellRendered) {
console.error(error); console.error(error)
} }
}, },
} },
); )
timeoutId = setTimeout(abort, streamTimeout); timeoutId = setTimeout(abort, streamTimeout)
}); })
} }
function handleBrowserRequest( function handleBrowserRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
reactRouterContext: EntryContext reactRouterContext: EntryContext,
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<ServerRouter <ServerRouter
context={reactRouterContext} context={reactRouterContext}
@@ -108,56 +93,53 @@ function handleBrowserRequest(
/>, />,
{ {
onShellReady() { onShellReady() {
shellRendered = true; shellRendered = true
const body = new PassThrough(); const body = new PassThrough()
const stream = createReadableStreamFromReadable(body); const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html"); responseHeaders.set('Content-Type', 'text/html')
clearTimeout(timeoutId); clearTimeout(timeoutId)
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode, status: responseStatusCode,
}) }),
); )
pipe(body); pipe(body)
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
clearTimeout(timeoutId); clearTimeout(timeoutId)
reject(error); reject(error)
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500; responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll // errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest. // reject and get logged in handleDocumentRequest.
if (shellRendered) { if (shellRendered) {
console.error(error); console.error(error)
} }
}, },
} },
); )
timeoutId = setTimeout(abort, streamTimeout); timeoutId = setTimeout(abort, streamTimeout)
}); })
} }
export function handleError( export function handleError(error: any, { request, params, context }: LoaderFunctionArgs | ActionFunctionArgs) {
error: any,
{ request, params, context }: LoaderFunctionArgs | ActionFunctionArgs
) {
// https://github.com/remix-run/remix/discussions/8933 // https://github.com/remix-run/remix/discussions/8933
if (request.signal.aborted || isRouteErrorResponse(error)) { if (request.signal.aborted || isRouteErrorResponse(error)) {
return; return
} }
if (error?.["status"] === 404) { if (error?.['status'] === 404) {
return; return
} }
if (error instanceof Error) { if (error instanceof Error) {
notifyError(error, "unhandled remix error"); notifyError(error, 'unhandled remix error')
} else { } 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({ init({
dsn: "https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168", dsn: 'https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168',
integrations: [], integrations: [],
tracesSampleRate: 0.01, tracesSampleRate: 0.01,
profilesSampleRate: 0.01, profilesSampleRate: 0.01,
beforeSend(event) { beforeSend(event) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === 'development') {
return null; return null
} }
if (process.env.BYTECODE_RUN) { if (process.env.BYTECODE_RUN) {
return null; return null
} }
if (event?.["name"] === "AbortError") { if (event?.['name'] === 'AbortError') {
return null; return null
} }
return event; return event
}, },
}); })
export async function notifyError(error: any, msg?: string) { export async function notifyError(error: any, msg?: string) {
console.error(msg, error); console.error(msg, error)
captureException(error, { extra: { msg } }); captureException(error, { extra: { msg } })
await flush(1000); await flush(1000)
} }
export class AppError extends Error { export class AppError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message)
this.name = "AppError"; this.name = 'AppError'
} }
} }
+5 -5
View File
@@ -1,10 +1,10 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from 'clsx'
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge'
export const sleep = (ms: number): Promise<void> => { 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[]) { 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 'website/src/styles/globals.css'
import type { LinksFunction } from "react-router"; import type { LinksFunction } from 'react-router'
import { Route } from "./+types/root"; import { Route } from './+types/root'
import { import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: "icon", type: "image/png", href: "/favicon-32.png", sizes: "32x32" }, { 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-16.png', sizes: '16x16' },
]; ]
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang='en'>
<head> <head>
<meta charSet="utf-8" /> <meta charSet='utf-8' />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel='preconnect' href='https://fonts.googleapis.com' />
<link <link rel='preconnect' href='https://fonts.gstatic.com' crossOrigin='' />
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
{/* Inter from rsms (same source as next/font) for weight fidelity */} {/* Inter from rsms (same source as next/font) for weight fidelity */}
<link href='https://rsms.me/inter/inter.css' rel='stylesheet' />
<link <link
href="https://rsms.me/inter/inter.css" 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" 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"
/> />
<Meta /> <Meta />
<Links /> <Links />
@@ -45,40 +31,39 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Scripts /> <Scripts />
</body> </body>
</html> </html>
); )
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center"> <div className='flex flex-col items-center justify-center min-h-screen p-8 text-center'>
<h1 className="text-4xl font-bold mb-4"> <h1 className='text-4xl font-bold mb-4'>
{error.status} {error.statusText} {error.status} {error.statusText}
</h1> </h1>
<p className="text-lg text-gray-600">{error.data}</p> <p className='text-lg text-gray-600'>{error.data}</p>
</div> </div>
); )
} else if (error instanceof Error) { } else if (error instanceof Error) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center max-w-4xl mx-auto"> <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> <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-lg text-gray-600 mb-6'>{error.message}</p>
<p className="text-sm text-gray-500 mb-2">The stack trace is:</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"> <pre className='bg-gray-100 p-4 rounded-lg text-xs text-left overflow-auto max-w-full border'>
{error.stack} {error.stack}
</pre> </pre>
</div> </div>
); )
} else { } else {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className='flex items-center justify-center min-h-screen'>
<h1 className="text-4xl font-bold text-center">Unknown Error</h1> <h1 className='text-4xl font-bold text-center'>Unknown Error</h1>
</div> </div>
); )
} }
} }
export default function App() { 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 type { RouteConfig } from '@react-router/dev/routes'
import { flatRoutes } from "@react-router/fs-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. * Styles from globals.css (editorial tokens) and editorial-prism.css.
*/ */
import type { MetaFunction } from "react-router"; import type { MetaFunction } from 'react-router'
import dedent from "string-dedent"; import dedent from 'string-dedent'
import { import {
EditorialPage, EditorialPage,
P, P,
@@ -19,150 +19,134 @@ import {
OL, OL,
Li, Li,
PixelatedImage, PixelatedImage,
} from "website/src/components/markdown"; } from 'website/src/components/markdown'
import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png"; import placeholderScreenshot from '../assets/placeholders/placeholder-screenshot@2x.png'
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
const title = "Playwriter - Control your Chrome with Playwright API"; const title = 'Playwriter - Control your Chrome with Playwright API'
const description = const description =
"Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat."; '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"; const image = 'https://playwriter.dev/og-image.png'
return [ return [
{ title }, { title },
{ name: "description", content: description }, { name: 'description', content: description },
{ property: "og:title", content: title }, { property: 'og:title', content: title },
{ property: "og:description", content: description }, { property: 'og:description', content: description },
{ property: "og:image", content: image }, { property: 'og:image', content: image },
{ property: "og:image:width", content: "1200" }, { property: 'og:image:width', content: '1200' },
{ property: "og:image:height", content: "630" }, { property: 'og:image:height', content: '630' },
{ property: "og:type", content: "website" }, { property: 'og:type', content: 'website' },
{ property: "og:url", content: "https://playwriter.dev" }, { property: 'og:url', content: 'https://playwriter.dev' },
{ name: "twitter:card", content: "summary_large_image" }, { name: 'twitter:card', content: 'summary_large_image' },
{ name: "twitter:title", content: title }, { name: 'twitter:title', content: title },
{ name: "twitter:description", content: description }, { name: 'twitter:description', content: description },
{ name: "twitter:image", content: image }, { name: 'twitter:image', content: image },
]; ]
}; }
const tocItems = [ const tocItems = [
{ label: "Getting started", href: "#getting-started" }, { label: 'Getting started', href: '#getting-started' },
{ label: "How it works", href: "#how-it-works" }, { label: 'How it works', href: '#how-it-works' },
{ label: "Collaboration", href: "#collaboration" }, { label: 'Collaboration', href: '#collaboration' },
{ label: "Snapshots", href: "#snapshots" }, { label: 'Snapshots', href: '#snapshots' },
{ label: "Visual labels", href: "#visual-labels" }, { label: 'Visual labels', href: '#visual-labels' },
{ label: "Sessions", href: "#sessions" }, { label: 'Sessions', href: '#sessions' },
{ label: "Debugger & editor", href: "#debugger-and-editor" }, { label: 'Debugger & editor', href: '#debugger-and-editor' },
{ label: "Network interception", href: "#network-interception" }, { label: 'Network interception', href: '#network-interception' },
{ label: "Screen recording", href: "#screen-recording" }, { label: 'Screen recording', href: '#screen-recording' },
{ label: "Comparison", href: "#comparison" }, { label: 'Comparison', href: '#comparison' },
{ label: "Remote access", href: "#remote-access" }, { label: 'Remote access', href: '#remote-access' },
{ label: "Security", href: "#security" }, { label: 'Security', href: '#security' },
]; ]
export default function IndexPage() { export default function IndexPage() {
return ( return (
<EditorialPage toc={tocItems} logo="playwriter"> <EditorialPage toc={tocItems} logo='playwriter'>
<P> <P>
You want your agent to control the browser. <strong>Your actual You want your agent to control the browser. <strong>Your actual Chrome</strong> {' \u2014 '} with logins,
Chrome</strong> {" \u2014 "} with logins, extensions, and cookies already extensions, and cookies already there. Not a headless instance that gets blocked by every captcha and bot
there. Not a headless instance that gets blocked by every captcha detector. <A href='https://github.com/remorses/playwriter'>Star on GitHub</A>.
and bot detector.{" "}
<A href="https://github.com/remorses/playwriter">Star on GitHub</A>.
</P> </P>
<div className="bleed" style={{ display: "flex", justifyContent: "center" }}> <div className='bleed' style={{ display: 'flex', justifyContent: 'center' }}>
<PixelatedImage <PixelatedImage
src="/screenshot@2x.png" src='/screenshot@2x.png'
placeholder={placeholderScreenshot} placeholder={placeholderScreenshot}
alt="Playwriter controlling Chrome with accessibility labels overlay" alt='Playwriter controlling Chrome with accessibility labels overlay'
width={1280} width={1280}
height={800} height={800}
style={{ display: "block", maxWidth: "100%", height: "auto" }} style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
/> />
</div> </div>
<Caption> <Caption>Your existing Chrome session. Extensions, logins, cookies {' \u2014 '} all there.</Caption>
Your existing Chrome session. Extensions, logins, cookies {" \u2014 "} all there.
</Caption>
<P> <P>
Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents Other browser MCPs either <strong>spawn a fresh Chrome</strong> or give agents a fixed set of tools. New Chrome
a fixed set of tools. New Chrome means no logins, no extensions, means no logins, no extensions, instant bot detection, and double the memory. Fixed tools mean the agent can
instant bot detection, and double the memory. Fixed tools mean the {"'"}t profile performance, can{"'"}t set breakpoints, can{"'"}t intercept network requests {' \u2014 '} it can
agent can{"'"}t profile performance, can{"'"}t set breakpoints, only do what someone decided to expose.
can{"'"}t intercept network requests {" \u2014 "} it can only do what someone
decided to expose.
</P> </P>
<P> <P>
Playwriter gives agents the <strong>full Playwright API</strong> through Playwriter gives agents the <strong>full Playwright API</strong> through a single <Code>execute</Code> tool. One
a single <Code>execute</Code> tool. One tool, any Playwright code, tool, any Playwright code, no wrappers. Low context usage because there{"'"}s no schema bloat from dozens of
no wrappers. Low context usage because there{"'"}s no schema bloat tool definitions. And it runs in your existing browser, so <strong>nothing extra gets spawned</strong>.
from dozens of tool definitions. And it runs in your existing browser,
so <strong>nothing extra gets spawned</strong>.
</P> </P>
<Section id="getting-started" title="Getting started"> <Section id='getting-started' title='Getting started'>
<P> <P>
<strong>Four steps</strong> and your agent is browsing. <strong>Four steps</strong> and your agent is browsing.
</P> </P>
<OL> <OL>
<Li> <Li>
Install the{" "} Install the{' '}
<A href="https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe">Chrome extension</A> <A href='https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe'>
Chrome extension
</A>
</Li> </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> <Li>Install the CLI:</Li>
</OL> </OL>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
npm i -g playwriter npm i -g playwriter
`}</CodeBlock> `}</CodeBlock>
<P> <P>
Then install the <strong>skill</strong> {" \u2014 "} it teaches your agent how to use Then install the <strong>skill</strong> {' \u2014 '} it teaches your agent how to use Playwriter: which
Playwriter: which selectors to use, how to avoid timeouts, how to selectors to use, how to avoid timeouts, how to read snapshots, and all available utilities.
read snapshots, and all available utilities.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
npx -y skills add remorses/playwriter npx -y skills add remorses/playwriter
`}</CodeBlock> `}</CodeBlock>
<P> <P>
The extension connects your browser to a <strong>local WebSocket relay</strong> on{" "} The extension connects your browser to a <strong>local WebSocket relay</strong> on{' '}
<Code>localhost:19988</Code>. The CLI sends Playwright <Code>localhost:19988</Code>. The CLI sends Playwright code through the relay. No remote servers, no accounts,
code through the relay. No remote servers, no accounts, nothing nothing leaves your machine.
leaves your machine.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
playwriter session new # new sandbox, outputs id (e.g. 1) playwriter session new # new sandbox, outputs id (e.g. 1)
playwriter -s 1 -e "await page.goto('https://example.com')" playwriter -s 1 -e "await page.goto('https://example.com')"
playwriter -s 1 -e "console.log(await snapshot({ page }))" playwriter -s 1 -e "console.log(await snapshot({ page }))"
playwriter -s 1 -e "await page.locator('aria-ref=e5').click()" playwriter -s 1 -e "await page.locator('aria-ref=e5').click()"
`}</CodeBlock> `}</CodeBlock>
<Caption> <Caption>Extension icon green = connected. Gray = not attached to this tab.</Caption>
Extension icon green = connected. Gray = not attached to this tab.
</Caption>
</Section> </Section>
<Section id="how-it-works" title="How it works"> <Section id='how-it-works' title='How it works'>
<P> <P>
Click the extension icon on a tab {" \u2014 "} it attaches via{" "} Click the extension icon on a tab {' \u2014 '} it attaches via <Code>chrome.debugger</Code> and opens a
<Code>chrome.debugger</Code> and opens a WebSocket to a WebSocket to a local relay. Your agent (CLI, MCP, or a Playwright script) connects to the same relay.{' '}
local relay. Your agent (CLI, MCP, or a Playwright script) connects <strong>CDP commands flow through</strong>; the extension forwards them to Chrome and sends responses back. No
to the same relay. <strong>CDP commands flow through</strong>; the extension Chrome restart, no flags, no special setup.
forwards them to Chrome and sends responses back. No Chrome restart,
no flags, no special setup.
</P> </P>
<CodeBlock lang="bash" lineHeight="1.3">{dedent` <CodeBlock lang='bash' lineHeight='1.3'>{dedent`
BROWSER LOCALHOST CLIENT BROWSER LOCALHOST CLIENT
@@ -180,42 +164,35 @@ export default function IndexPage() {
`}</CodeBlock> `}</CodeBlock>
<P> <P>
The relay <strong>multiplexes sessions</strong>, so multiple agents The relay <strong>multiplexes sessions</strong>, so multiple agents or CLI instances can work with the same
or CLI instances can work with the same browser at the same time. browser at the same time.
</P> </P>
</Section> </Section>
<Section id="collaboration" title="Collaboration"> <Section id='collaboration' title='Collaboration'>
<P> <P>
Because the agent works in <strong>your browser</strong>, you can Because the agent works in <strong>your browser</strong>, you can collaborate. You see everything it does in
collaborate. You see everything it does in real time. When it hits real time. When it hits a captcha, <strong>you solve it</strong>. When a consent wall appears, you click
a captcha, <strong>you solve it</strong>. When a consent wall through it. When the agent gets stuck, you disable the extension on that tab, fix things manually, re-enable
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. it, and the agent picks up where it left off.
</P> </P>
<P> <P>
You{"'"}re not watching a remote screen or reading logs after the You{"'"}re not watching a remote screen or reading logs after the fact. You{"'"}re{' '}
fact. You{"'"}re <strong>sharing a browser</strong> {" \u2014 "} the <strong>sharing a browser</strong> {' \u2014 '} the agent does the repetitive work, you step in when it needs
agent does the repetitive work, you step in when it needs a human. a human.
</P> </P>
</Section> </Section>
<Section id="snapshots" title="Accessibility snapshots"> <Section id='snapshots' title='Accessibility snapshots'>
<P> <P>
Your agent needs to <strong>see the page</strong> before it can act. Your agent needs to <strong>see the page</strong> before it can act. Accessibility snapshots return every
Accessibility snapshots return every interactive element as text, interactive element as text, with Playwright locators attached.{' '}
with Playwright locators attached. <strong>5{"\u2013"}20KB instead of <strong>5{'\u2013'}20KB instead of 100KB+</strong> for a screenshot {' \u2014 '} cheaper, faster, and the
100KB+</strong> for a screenshot {" \u2014 "} cheaper, faster, and the
agent can parse them without vision. agent can parse them without vision.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "await snapshot({ page })" playwriter -s 1 -e "await snapshot({ page })"
# Output: # Output:
@@ -227,13 +204,12 @@ export default function IndexPage() {
`}</CodeBlock> `}</CodeBlock>
<P> <P>
Each line ends with a <strong>locator</strong> you can pass directly to{" "} Each line ends with a <strong>locator</strong> you can pass directly to <Code>page.locator()</Code>.
<Code>page.locator()</Code>. Subsequent calls return a Subsequent calls return a<strong> diff</strong>, so you only see what changed. Use <Code>search</Code> to
<strong> diff</strong>, so you only see what changed. Use{" "} filter large pages.
<Code>search</Code> to filter large pages.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
# Search for specific elements # Search for specific elements
playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })" playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })"
@@ -242,25 +218,19 @@ export default function IndexPage() {
`}</CodeBlock> `}</CodeBlock>
<P> <P>
Use snapshots as the <strong>primary way to read pages</strong>. Only Use snapshots as the <strong>primary way to read pages</strong>. Only reach for screenshots when spatial
reach for screenshots when spatial layout matters {" \u2014 "} grids, layout matters {' \u2014 '} grids, dashboards, maps.
dashboards, maps.
</P> </P>
</Section> </Section>
<Section id="visual-labels" title="Visual labels"> <Section id='visual-labels' title='Visual labels'>
<P> <P>
When the agent needs to understand <strong>where things are on When the agent needs to understand <strong>where things are on screen</strong>,{' '}
screen</strong>,{" "} <Code>screenshotWithAccessibilityLabels</Code> overlays <strong>Vimium-style labels</strong> on every
<Code>screenshotWithAccessibilityLabels</Code> overlays{" "} interactive element. The agent sees the screenshot, reads the labels, and clicks by reference.
<strong>Vimium-style labels</strong> on every interactive element.
The agent sees the screenshot, reads the labels, and clicks by
reference.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })" playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })"
# Returns screenshot + accessibility snapshot with aria-ref selectors # Returns screenshot + accessibility snapshot with aria-ref selectors
@@ -268,29 +238,22 @@ export default function IndexPage() {
`}</CodeBlock> `}</CodeBlock>
<P> <P>
Labels are <strong>color-coded by element type</strong>: yellow for links, orange for Labels are <strong>color-coded by element type</strong>: yellow for links, orange for buttons, coral for
buttons, coral for inputs, pink for checkboxes, peach for sliders, inputs, pink for checkboxes, peach for sliders, salmon for menus, amber for tabs. The ref system is shared
salmon for menus, amber for tabs. The ref system is shared with{" "} with <Code>snapshot()</Code>, so you can switch between text and visual modes freely.
<Code>snapshot()</Code>, so you can switch between text
and visual modes freely.
</P> </P>
<Caption> <Caption>Vimium-style labels. Screenshot + snapshot in one call.</Caption>
Vimium-style labels. Screenshot + snapshot in one call.
</Caption>
</Section> </Section>
<Section id="sessions" title="Sessions"> <Section id='sessions' title='Sessions'>
<P> <P>
Run <strong>multiple agents at once</strong> without them stepping on Run <strong>multiple agents at once</strong> without them stepping on each other. Each session is an isolated
each other. Each session is an isolated sandbox with its own{" "} sandbox with its own <Code>state</Code> object. Variables, pages, and listeners persist between calls. Browser
<Code>state</Code> object. Variables, pages, and listeners tabs are shared, but state is not.
persist between calls. Browser tabs are shared, but state is not.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
playwriter session new # => 1 playwriter session new # => 1
playwriter session new # => 2 playwriter session new # => 2
playwriter session list # shows sessions + state keys playwriter session list # shows sessions + state keys
@@ -303,30 +266,26 @@ export default function IndexPage() {
`}</CodeBlock> `}</CodeBlock>
<P> <P>
Create your own page to <strong>avoid interference</strong> from other agents. Reuse Create your own page to <strong>avoid interference</strong> from other agents. Reuse an existing{' '}
an existing <Code>about:blank</Code> tab or create a <Code>about:blank</Code> tab or create a fresh one, and store it in <Code>state</Code>.
fresh one, and store it in <Code>state</Code>.
</P> </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')" 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 # All subsequent calls use state.myPage
playwriter -s 1 -e "console.log(await state.myPage.title())" playwriter -s 1 -e "console.log(await state.myPage.title())"
`}</CodeBlock> `}</CodeBlock>
</Section> </Section>
<Section id="debugger-and-editor" title="Debugger & editor"> <Section id='debugger-and-editor' title='Debugger & editor'>
<P> <P>
Things no other browser MCP can do. <strong>Set breakpoints</strong>, Things no other browser MCP can do. <strong>Set breakpoints</strong>, step through code, inspect variables at
step through code, inspect variables at runtime. <strong>Live-edit runtime. <strong>Live-edit page scripts and CSS</strong> without reloading. Full Chrome DevTools Protocol
page scripts and CSS</strong> without reloading. Full Chrome DevTools access, not a watered-down subset.
Protocol access, not a watered-down subset.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
# Set breakpoints and debug # 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.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))" 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> `}</CodeBlock>
<P> <P>
Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for Edits are <strong>in-memory</strong> and persist until the page reloads. Useful for toggling debug flags,
toggling debug flags, patching broken code, or testing quick fixes patching broken code, or testing quick fixes without touching source files. The editor also supports{' '}
without touching source files. The editor also supports{" "}
<Code>grep</Code> across all loaded scripts. <Code>grep</Code> across all loaded scripts.
</P> </P>
<Caption> <Caption>Breakpoints, stepping, variable inspection {' \u2014 '} from the CLI.</Caption>
Breakpoints, stepping, variable inspection {" \u2014 "} from the CLI.
</Caption>
</Section> </Section>
<Section id="network-interception" title="Network interception"> <Section id='network-interception' title='Network interception'>
<P> <P>
Let the agent <strong>watch network traffic</strong> to Let the agent <strong>watch network traffic</strong> to reverse-engineer APIs, scrape data behind JavaScript
reverse-engineer APIs, scrape data behind JavaScript rendering, rendering, or debug failing requests. Captured data lives in <Code>state</Code> and persists across calls.
or debug failing requests. Captured data lives in{" "}
<Code>state</Code> and persists across calls.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
# Start intercepting # 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 {} } })" 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> `}</CodeBlock>
<P> <P>
<strong>Faster than scraping the DOM.</strong> The agent captures the <strong>Faster than scraping the DOM.</strong> The agent captures the real API calls, inspects their schemas,
real API calls, inspects their schemas, and replays them with and replays them with different parameters. Works for pagination, authenticated endpoints, and anything behind
different parameters. Works for pagination, authenticated endpoints, client-side rendering.
and anything behind client-side rendering.
</P> </P>
</Section> </Section>
<Section id="screen-recording" title="Screen recording"> <Section id='screen-recording' title='Screen recording'>
<P> <P>
Have the agent <strong>record what it{"'"}s doing</strong> as MP4 Have the agent <strong>record what it{"'"}s doing</strong> as MP4 video. The recording uses{' '}
video. The recording uses <Code>chrome.tabCapture</Code> and <Code>chrome.tabCapture</Code> and runs in the extension context, so it{' '}
runs in the extension context, so it <strong>survives page <strong>survives page navigation</strong>.
navigation</strong>.
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
# Start recording # Start recording
playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })" playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })"
@@ -403,75 +351,64 @@ export default function IndexPage() {
<P> <P>
Unlike <Code>getDisplayMedia</Code>, this approach Unlike <Code>getDisplayMedia</Code>, this approach
<strong> persists across navigations</strong> because the extension holds the{" "} <strong> persists across navigations</strong> because the extension holds the <Code>MediaRecorder</Code>, not
<Code>MediaRecorder</Code>, not the page. You can also the page. You can also check recording status with <Code>isRecording</Code> or cancel without saving with{' '}
check recording status with <Code>isRecording</Code> or <Code>cancelRecording</Code>.
cancel without saving with <Code>cancelRecording</Code>.
</P> </P>
<Caption> <Caption>Native tab capture. 30{'\u2013'}60fps. Survives navigation.</Caption>
Native tab capture. 30{"\u2013"}60fps. Survives navigation.
</Caption>
</Section> </Section>
<Section id="comparison" title="Comparison"> <Section id='comparison' title='Comparison'>
<P>Why use this over the alternatives.</P>
<P>
Why use this over the alternatives.
</P>
<ComparisonTable <ComparisonTable
title="vs Playwright MCP" title='vs Playwright MCP'
headers={["", "Playwright MCP", "Playwriter"]} headers={['', 'Playwright MCP', 'Playwriter']}
rows={[ rows={[
["Browser", "Spawns new Chrome", "Uses your Chrome"], ['Browser', 'Spawns new Chrome', 'Uses your Chrome'],
["Extensions", "None", "Your existing ones"], ['Extensions', 'None', 'Your existing ones'],
["Login state", "Fresh", "Already logged in"], ['Login state', 'Fresh', 'Already logged in'],
["Bot detection", "Always detected", "Can bypass"], ['Bot detection', 'Always detected', 'Can bypass'],
["Collaboration", "Separate window", "Same browser as user"], ['Collaboration', 'Separate window', 'Same browser as user'],
]} ]}
/> />
<ComparisonTable <ComparisonTable
title="vs BrowserMCP" title='vs BrowserMCP'
headers={["", "BrowserMCP", "Playwriter"]} headers={['', 'BrowserMCP', 'Playwriter']}
rows={[ rows={[
["Tools", "12+ dedicated tools", "1 execute tool"], ['Tools', '12+ dedicated tools', '1 execute tool'],
["API", "Limited actions", "Full Playwright"], ['API', 'Limited actions', 'Full Playwright'],
["Context usage", "High (tool schemas)", "Low"], ['Context usage', 'High (tool schemas)', 'Low'],
["LLM knowledge", "Must learn tools", "Already knows Playwright"], ['LLM knowledge', 'Must learn tools', 'Already knows Playwright'],
]} ]}
/> />
<ComparisonTable <ComparisonTable
title="vs Claude Browser Extension" title='vs Claude Browser Extension'
headers={["", "Claude Extension", "Playwriter"]} headers={['', 'Claude Extension', 'Playwriter']}
rows={[ rows={[
["Agent support", "Claude only", "Any MCP client"], ['Agent support', 'Claude only', 'Any MCP client'],
["Windows WSL", "No", "Yes"], ['Windows WSL', 'No', 'Yes'],
["Context method", "Screenshots (100KB+)", "A11y snapshots (5\u201320KB)"], ['Context method', 'Screenshots (100KB+)', 'A11y snapshots (5\u201320KB)'],
["Playwright API", "No", "Full"], ['Playwright API', 'No', 'Full'],
["Debugger", "No", "Yes"], ['Debugger', 'No', 'Yes'],
["Live code editing", "No", "Yes"], ['Live code editing', 'No', 'Yes'],
["Network interception", "Limited", "Full"], ['Network interception', 'Limited', 'Full'],
["Raw CDP access", "No", "Yes"], ['Raw CDP access', 'No', 'Yes'],
]} ]}
/> />
</Section> </Section>
<Section id="remote-access" title="Remote access"> <Section id='remote-access' title='Remote access'>
<P> <P>
Control Chrome on a <strong>remote machine</strong> {" \u2014 "} a headless Control Chrome on a <strong>remote machine</strong> {' \u2014 '} a headless Mac mini, a cloud VM, a
Mac mini, a cloud VM, a devcontainer. A{" "} devcontainer. A <A href='https://traforo.dev'>traforo</A> tunnel exposes the relay through Cloudflare.{' '}
<A href="https://traforo.dev">traforo</A>{" "} <strong>No VPN, no firewall rules, no port forwarding.</strong>
tunnel exposes the relay through Cloudflare. <strong>No VPN, no
firewall rules, no port forwarding.</strong>
</P> </P>
<CodeBlock lang="bash">{dedent` <CodeBlock lang='bash'>{dedent`
# On the host machine start relay with tunnel # On the host machine start relay with tunnel
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret> npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
@@ -482,46 +419,36 @@ export default function IndexPage() {
`}</CodeBlock> `}</CodeBlock>
<P> <P>
Also works on a <strong>LAN without tunnels</strong> {" \u2014 "} just set{" "} Also works on a <strong>LAN without tunnels</strong> {' \u2014 '} just set{' '}
<Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP <Code>PLAYWRITER_HOST=192.168.1.10</Code>. Works for MCP too {' \u2014 '} set <Code>PLAYWRITER_HOST</Code> and{' '}
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
<Code>PLAYWRITER_TOKEN</Code> in your MCP client env config. support, multi-machine automation, dev from a VM or devcontainer.
Use cases: headless Mac mini, remote user support,
multi-machine automation, dev from a VM or devcontainer.
</P> </P>
</Section> </Section>
<Section id="security" title="Security"> <Section id='security' title='Security'>
<P> <P>
Everything runs <strong>on your machine</strong>. The relay binds Everything runs <strong>on your machine</strong>. The relay binds to <Code>localhost:19988</Code> and only
to <Code>localhost:19988</Code> and only accepts connections accepts connections from the extension. No remote server, no account, no telemetry.
from the extension. No remote server, no account, no telemetry.
</P> </P>
<List> <List>
<Li> <Li>
<strong>Local only</strong> {" \u2014 "} WebSocket server binds to <strong>Local only</strong> {' \u2014 '} WebSocket server binds to localhost. Nothing leaves your machine.
localhost. Nothing leaves your machine.
</Li> </Li>
<Li> <Li>
<strong>Origin validation</strong> {" \u2014 "} only the Playwriter <strong>Origin validation</strong> {' \u2014 '} only the Playwriter extension origin is accepted. Browsers
extension origin is accepted. Browsers cannot spoof the Origin cannot spoof the Origin header, so malicious websites cannot connect.
header, so malicious websites cannot connect.
</Li> </Li>
<Li> <Li>
<strong>Explicit consent</strong> {" \u2014 "} only tabs where you <strong>Explicit consent</strong> {' \u2014 '} only tabs where you clicked the extension icon are
clicked the extension icon are controlled. No background access. controlled. No background access.
</Li> </Li>
<Li> <Li>
<strong>Visible automation</strong> {" \u2014 "} Chrome shows an <strong>Visible automation</strong> {' \u2014 '} Chrome shows an automation banner on controlled tabs.
automation banner on controlled tabs.
</Li> </Li>
</List> </List>
</Section> </Section>
</EditorialPage> </EditorialPage>
); )
} }
+7 -7
View File
@@ -1,24 +1,24 @@
import React from "react"; import React from 'react'
import { sleep } from "../lib/utils"; import { sleep } from '../lib/utils'
import { Route } from "./+types/defer-example"; import { Route } from './+types/defer-example'
async function getProjectLocation() { 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) { export async function loader({}: Route.LoaderArgs) {
return { return {
project: getProjectLocation(), project: getProjectLocation(),
}; }
} }
export default function ProjectRoute({ loaderData }: Route.ComponentProps) { export default function ProjectRoute({ loaderData }: Route.ComponentProps) {
const location = React.use(loaderData.project); const location = React.use(loaderData.project)
return ( return (
<main> <main>
<h1>Let's locate your project</h1> <h1>Let's locate your project</h1>
<p>Your project is at {location}.</p> <p>Your project is at {location}.</p>
</main> </main>
); )
} }
+4 -4
View File
@@ -1,9 +1,9 @@
import { redirect } from 'react-router'; import { redirect } from 'react-router'
export const loader = () => { export const loader = () => {
throw redirect('https://github.com/remorses/playwriter'); throw redirect('https://github.com/remorses/playwriter')
}; }
export default function Index() { export default function Index() {
return null; return null
} }
+5 -6
View File
@@ -12,13 +12,12 @@
*/ */
/* Base code style - override Prism defaults */ /* Base code style - override Prism defaults */
code[class*="language-"], code[class*='language-'],
pre[class*="language-"] { pre[class*='language-'] {
color: #1e293b; color: #1e293b;
background: none; background: none;
text-shadow: none; text-shadow: none;
font-family: var(--font-code, "SF Mono", "SFMono-Regular", "Consolas", font-family: var(--font-code, 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo, Courier, monospace);
"Liberation Mono", Menlo, Courier, monospace);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -163,8 +162,8 @@ pre[class*="language-"] {
} }
} }
code[class*="language-"], code[class*='language-'],
pre[class*="language-"] { pre[class*='language-'] {
@variant dark { @variant dark {
color: var(--prism-fg); color: var(--prism-fg);
} }
+8 -5
View File
@@ -25,11 +25,12 @@
======================================================================== */ ======================================================================== */
@font-face { @font-face {
font-family: "JetBrainsMono NF Mono"; font-family: 'JetBrainsMono NF Mono';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; 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 { .editorial-page {
font-optical-sizing: auto; font-optical-sizing: auto;
font-feature-settings: "liga" 1, "calt" 1; font-feature-settings:
'liga' 1,
'calt' 1;
} }
.editorial-page ::selection { .editorial-page ::selection {
@@ -119,7 +122,7 @@
} }
.inline-code::before { .inline-code::before {
content: ""; content: '';
position: absolute; position: absolute;
inset: -1.26px 0; inset: -1.26px 0;
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.04);
@@ -144,7 +147,7 @@
======================================================================== */ ======================================================================== */
.editorial-page::before { .editorial-page::before {
content: ""; content: '';
pointer-events: none; pointer-events: none;
z-index: 9; z-index: 9;
position: fixed; position: fixed;
+22 -27
View File
@@ -1,6 +1,6 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "./editorial.css"; @import './editorial.css';
@import "./editorial-prism.css"; @import './editorial-prism.css';
@custom-variant dark (@media (prefers-color-scheme: dark)); @custom-variant dark (@media (prefers-color-scheme: dark));
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@@ -42,11 +42,13 @@
--sidebar-ring: oklch(0.871 0.006 286.286); --sidebar-ring: oklch(0.871 0.006 286.286);
/* ===== Editorial design tokens ===== */ /* ===== Editorial design tokens ===== */
--font-primary: "Inter var", "Inter", system-ui, -apple-system, --font-primary:
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 'Inter var', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
--font-secondary: "Newsreader", Georgia, "Times New Roman", serif; sans-serif;
--font-code: "JetBrainsMono NF Mono", "JetBrains Mono", "SF Mono", --font-secondary: 'Newsreader', Georgia, 'Times New Roman', serif;
"SFMono-Regular", "Consolas", "Liberation Mono", Menlo, Courier, monospace; --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. /* Bleed: code blocks & images extend beyond prose column.
44px = 8px div padding-left + 36px line-number width */ 44px = 8px div padding-left + 36px line-number width */
@@ -84,9 +86,8 @@
/* Button / back button */ /* Button / back button */
--btn-bg: #fff; --btn-bg: #fff;
--btn-shadow: rgba(0, 0, 0, 0.08) 0px 2px 8px, --btn-shadow:
rgba(0, 0, 0, 0.04) 0px 4px 16px, 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;
rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
/* Link accent color (renamed from --accent to avoid shadcn conflict) */ /* Link accent color (renamed from --accent to avoid shadcn conflict) */
--link-accent: #0969da; --link-accent: #0969da;
@@ -98,13 +99,10 @@
/* Overlay / glass */ /* Overlay / glass */
--overlay-filter: blur(1rem); --overlay-filter: blur(1rem);
--overlay-bg: hsla(0, 0%, 100%, 0.8); --overlay-bg: hsla(0, 0%, 100%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), --overlay-shadow:
0 1.625rem 3.375rem rgba(0, 0, 0, 0.04), 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 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.625rem 1rem rgba(0, 0, 0, 0.024), 0 0.125rem 0.25rem rgba(0, 0, 0, 0.016), 0 0 0.125rem rgba(0, 0, 0, 0.01);
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) */ /* Header fade gradient stops (white) */
--fade-0: rgb(255, 255, 255); --fade-0: rgb(255, 255, 255);
@@ -172,8 +170,8 @@
--selection-bg: rgba(255, 255, 255, 0.1); --selection-bg: rgba(255, 255, 255, 0.1);
--btn-bg: rgb(30, 30, 30); --btn-bg: rgb(30, 30, 30);
--btn-shadow: rgba(255, 255, 255, 0.05) 0px 2px 8px, --btn-shadow:
rgba(255, 255, 255, 0.02) 0px 4px 16px, 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; rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
--link-accent: #58a6ff; --link-accent: #58a6ff;
@@ -181,13 +179,10 @@
--brand-secondary: #f5a623; --brand-secondary: #f5a623;
--overlay-bg: hsla(0, 0%, 7%, 0.8); --overlay-bg: hsla(0, 0%, 7%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), --overlay-shadow:
0 1.625rem 3.375rem rgba(0, 0, 0, 0.3), 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 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.625rem 1rem rgba(0, 0, 0, 0.15), 0 0.125rem 0.25rem rgba(0, 0, 0, 0.08), 0 0 0.125rem rgba(0, 0, 0, 0.05);
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) */ /* Header fade gradient stops (dark) */
--fade-0: rgb(17, 17, 17); --fade-0: rgb(17, 17, 17);
+15 -15
View File
@@ -1,26 +1,26 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { reactRouter } from "@react-router/dev/vite"; import { reactRouter } from '@react-router/dev/vite'
import react from "@vitejs/plugin-react"; import react from '@vitejs/plugin-react'
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from '@tailwindcss/vite'
import EnvironmentPlugin from "vite-plugin-environment"; import EnvironmentPlugin from 'vite-plugin-environment'
import { defineConfig } from "vite"; import { defineConfig } from 'vite'
import { import {
viteExternalsPlugin, viteExternalsPlugin,
enablePreserveModulesPlugin, enablePreserveModulesPlugin,
} from "@xmorse/deployment-utils/dist/vite-externals-plugin.js"; } from '@xmorse/deployment-utils/dist/vite-externals-plugin.js'
import { reactRouterServerPlugin } from "@xmorse/deployment-utils/dist/react-router.js"; import { reactRouterServerPlugin } from '@xmorse/deployment-utils/dist/react-router.js'
import tsconfigPaths from "vite-tsconfig-paths"; 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({ export default defineConfig({
clearScreen: false, clearScreen: false,
define: { define: {
"process.env.NODE_ENV": NODE_ENV, 'process.env.NODE_ENV': NODE_ENV,
}, },
test: { test: {
pool: "threads", pool: 'threads',
exclude: ["**/dist/**", "**/esm/**", "**/node_modules/**", "**/e2e/**"], exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'],
poolOptions: { poolOptions: {
threads: { threads: {
isolate: false, isolate: false,
@@ -28,8 +28,8 @@ export default defineConfig({
}, },
}, },
plugins: [ plugins: [
EnvironmentPlugin("all", { prefix: "PUBLIC" }), EnvironmentPlugin('all', { prefix: 'PUBLIC' }),
EnvironmentPlugin("all", { prefix: "NEXT_PUBLIC" }), EnvironmentPlugin('all', { prefix: 'NEXT_PUBLIC' }),
process.env.VITEST ? react() : reactRouter(), process.env.VITEST ? react() : reactRouter(),
tsconfigPaths(), tsconfigPaths(),
// viteExternalsPlugin({ // viteExternalsPlugin({
@@ -44,4 +44,4 @@ export default defineConfig({
// transformMixedEsModules: true, // transformMixedEsModules: true,
// }, // },
// }, // },
}); })