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"
} }
+45 -41
View File
@@ -13,7 +13,7 @@ breaking changes to the WS protocol MUST never be made. publishing the extension
## architecture ## architecture
- user installs the extension in chrome. we assume there is only one chrome window for now, the first opened. - user installs the extension in chrome. we assume there is only one chrome window for now, the first opened.
- extension connects to a websocket server on port 19988. if this server is not yet open, it retries connecting in a loop - extension connects to a websocket server on port 19988. if this server is not yet open, it retries connecting in a loop
- the MCP spawns the ws server if not already listening on 19988, in background. the mcp then connects to this same server with a playwright client - the MCP spawns the ws server if not already listening on 19988, in background. the mcp then connects to this same server with a playwright client
- the server exposes /cdp/client-id which is used by playwright clients to communicate with the extension - the server exposes /cdp/client-id which is used by playwright clients to communicate with the extension
@@ -54,10 +54,10 @@ to test CLI changes without publishing:
```bash ```bash
# mac/linux: kill any existing relay on 19988 # mac/linux: kill any existing relay on 19988
lsof -ti :19988 | xargs kill lsof -ti :19988 | xargs kill
# windows (powershell): kill any existing relay on 19988 # windows (powershell): kill any existing relay on 19988
Get-NetTCPConnection -LocalPort 19988 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force } Get-NetTCPConnection -LocalPort 19988 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
tsx playwriter/src/cli.ts -s 1 -e "await page.goto('https://example.com')" tsx playwriter/src/cli.ts -s 1 -e "await page.goto('https://example.com')"
tsx playwriter/src/cli.ts -s 1 -e "console.log(await accessibilitySnapshot({ page }))" tsx playwriter/src/cli.ts -s 1 -e "console.log(await accessibilitySnapshot({ page }))"
tsx playwriter/src/cli.ts session new tsx playwriter/src/cli.ts session new
@@ -96,10 +96,10 @@ tests use these utilities from `test-utils.ts`:
```ts ```ts
// setup browser with extension loaded + relay server // setup browser with extension loaded + relay server
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
@@ -209,7 +207,7 @@ cd playwright && git branch
git checkout playwriter git checkout playwriter
``` ```
make sure to always bump the package json and update the make sure to always bump the package json and update the
### bootstrapping the repo ### bootstrapping the repo
@@ -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
@@ -257,11 +258,11 @@ 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 |
### key source files ### key source files
@@ -278,7 +279,7 @@ ignore ./claude-extension. this is the source code of the Claude Chrome extensio
you can find the logfile for playwriter executing `playwriter logfile`. read that then to understand issues happening and debug them you can find the logfile for playwriter executing `playwriter logfile`. read that then to understand issues happening and debug them
`playwriter logfile` also logs a jsonl file with all CDP commands and events being sent between extension, cli, mcp and relay. the cdp log is a jsonl file (one json object per line). you can use jq to process and read it efficiently. for example, list direction + method: `playwriter logfile` also logs a jsonl file with all CDP commands and events being sent between extension, cli, mcp and relay. the cdp log is a jsonl file (one json object per line). you can use jq to process and read it efficiently. for example, list direction + method:
```bash ```bash
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
@@ -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.
@@ -572,7 +574,7 @@ to understand how the code you are writing works, you should add inline snapshot
- for very long snapshots you should use `toMatchFileSnapshot(filename)` instead of `toMatchInlineSnapshot()`. put the snapshot files in a snapshots/ directory and use the appropriate extension for the file based on the content - for very long snapshots you should use `toMatchFileSnapshot(filename)` instead of `toMatchInlineSnapshot()`. put the snapshot files in a snapshots/ directory and use the appropriate extension for the file based on the content
never test client react components. only React and browser independent code. never test client react components. only React and browser independent code.
most tests should be simple calls to functions with some expect calls, no mocks. test files should be called the same as the file where the tested function is being exported from. most tests should be simple calls to functions with some expect calls, no mocks. test files should be called the same as the file where the tested function is being exported from.
@@ -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
@@ -722,4 +726,4 @@ npx opensrc crates:<package> # Rust crate (e.g., npx opensrc crates:serde)
npx opensrc <owner>/<repo> # GitHub repo (e.g., npx opensrc vercel/ai) npx opensrc <owner>/<repo> # GitHub repo (e.g., npx opensrc vercel/ai)
``` ```
<!-- opensrc:end --> <!-- opensrc:end -->
+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
+20 -19
View File
@@ -11,7 +11,7 @@ breaking changes to the WS protocol MUST never be made. publishing the extension
## architecture ## architecture
- user installs the extension in chrome. we assume there is only one chrome window for now, the first opened. - user installs the extension in chrome. we assume there is only one chrome window for now, the first opened.
- extension connects to a websocket server on port 19988. if this server is not yet open, it retries connecting in a loop - extension connects to a websocket server on port 19988. if this server is not yet open, it retries connecting in a loop
- the MCP spawns the ws server if not already listening on 19988, in background. the mcp then connects to this same server with a playwright client - the MCP spawns the ws server if not already listening on 19988, in background. the mcp then connects to this same server with a playwright client
- the server exposes /cdp/client-id which is used by playwright clients to communicate with the extension - the server exposes /cdp/client-id which is used by playwright clients to communicate with the extension
@@ -52,10 +52,10 @@ to test CLI changes without publishing:
```bash ```bash
# mac/linux: kill any existing relay on 19988 # mac/linux: kill any existing relay on 19988
lsof -ti :19988 | xargs kill lsof -ti :19988 | xargs kill
# windows (powershell): kill any existing relay on 19988 # windows (powershell): kill any existing relay on 19988
Get-NetTCPConnection -LocalPort 19988 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force } Get-NetTCPConnection -LocalPort 19988 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
tsx playwriter/src/cli.ts -s 1 -e "await page.goto('https://example.com')" tsx playwriter/src/cli.ts -s 1 -e "await page.goto('https://example.com')"
tsx playwriter/src/cli.ts -s 1 -e "console.log(await snapshot({ page }))" tsx playwriter/src/cli.ts -s 1 -e "console.log(await snapshot({ page }))"
tsx playwriter/src/cli.ts session new tsx playwriter/src/cli.ts session new
@@ -94,10 +94,10 @@ tests use these utilities from `test-utils.ts`:
```ts ```ts
// setup browser with extension loaded + relay server // setup browser with extension loaded + relay server
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
@@ -207,7 +205,7 @@ cd playwright && git branch
git checkout playwriter git checkout playwriter
``` ```
make sure to always bump the package json and update the make sure to always bump the package json and update the
### bootstrapping the repo ### bootstrapping the repo
@@ -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
@@ -255,11 +256,11 @@ 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 |
### key source files ### key source files
@@ -276,7 +277,7 @@ ignore ./claude-extension. this is the source code of the Claude Chrome extensio
you can find the logfile for playwriter executing `playwriter logfile`. read that then to understand issues happening and debug them you can find the logfile for playwriter executing `playwriter logfile`. read that then to understand issues happening and debug them
`playwriter logfile` also logs a jsonl file with all CDP commands and events being sent between extension, cli, mcp and relay. the cdp log is a jsonl file (one json object per line). you can use jq to process and read it efficiently. for example, list direction + method: `playwriter logfile` also logs a jsonl file with all CDP commands and events being sent between extension, cli, mcp and relay. the cdp log is a jsonl file (one json object per line). you can use jq to process and read it efficiently. for example, list direction + method:
```bash ```bash
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
+35 -27
View File
@@ -13,13 +13,13 @@
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 |
| Bot detection | Always detected | Can bypass (disconnect extension) | | Bot detection | Always detected | Can bypass (disconnect extension) |
| Collaboration | Separate window | Same browser as user | | Collaboration | Separate window | Same browser as user |
## Installation ## Installation
@@ -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 })"
``` ```
@@ -133,33 +139,33 @@ 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 |
| LLM knowledge | Must learn tools | Already knows Playwright | | LLM knowledge | Must learn tools | Already knows Playwright |
### 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 |
### 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) |
| Playwright API | No | Full | | Playwright API | No | Full |
| Debugger/breakpoints | No | Yes | | Debugger/breakpoints | 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 |
## Architecture ## Architecture
@@ -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/ }));"
``` ```
+6 -8
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
@@ -176,11 +174,11 @@ 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) |
### Recommendations ### Recommendations
+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",
+33 -33
View File
@@ -1,35 +1,35 @@
{ {
"name": "mcp-extension", "name": "mcp-extension",
"version": "0.0.71", "version": "0.0.71",
"description": "Playwright MCP Browser Extension", "description": "Playwright MCP Browser Extension",
"private": true, "private": true,
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/remorses/playwriter.git" "url": "git+https://github.com/remorses/playwriter.git"
}, },
"homepage": "https://github.com/remorses/playwriter", "homepage": "https://github.com/remorses/playwriter",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"author": { "author": {
"name": "Microsoft Corporation" "name": "Microsoft Corporation"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"build": "tsc --project . && vite build --config vite.config.mts && tsx scripts/download-prism.ts", "build": "tsc --project . && vite build --config vite.config.mts && tsx scripts/download-prism.ts",
"reload": "bun run build && osascript -e 'tell application \"Google Chrome\" to open location \"chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid\"'", "reload": "bun run build && osascript -e 'tell application \"Google Chrome\" to open location \"chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid\"'",
"watch": "vite build --watch --config vite.config.mts", "watch": "vite build --watch --config vite.config.mts",
"clean": "rm -rf dist" "clean": "rm -rf dist"
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^5.4.21", "vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1" "vite-plugin-static-copy": "^3.1.1"
}, },
"dependencies": { "dependencies": {
"playwriter": "workspace:*", "playwriter": "workspace:*",
"zustand": "^5.0.8" "zustand": "^5.0.8"
} }
} }
+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
+17 -13
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
if (res.statusCode !== 200) { .get(url, (res) => {
reject(new Error(`Failed to download ${url}: ${res.statusCode}`)) if (res.statusCode !== 200) {
return reject(new Error(`Failed to download ${url}: ${res.statusCode}`))
} return
const chunks: Buffer[] = [] }
res.on('data', (chunk: Buffer) => { chunks.push(chunk) }) const chunks: Buffer[] = []
res.on('end', () => { res.on('data', (chunk: Buffer) => {
fs.writeFileSync(dest, Buffer.concat(chunks)) chunks.push(chunk)
resolve() })
res.on('end', () => {
fs.writeFileSync(dest, Buffer.concat(chunks))
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}`)
} }
+137 -90
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))
} }
@@ -118,19 +117,21 @@ function flushRecordingChunkBuffer(ws: WebSocket): void {
if (recordingChunkBuffer.length === 0) { if (recordingChunkBuffer.length === 0) {
return return
} }
logger.debug(`Flushing ${recordingChunkBuffer.length} buffered recording chunks`) logger.debug(`Flushing ${recordingChunkBuffer.length} buffered recording chunks`)
while (recordingChunkBuffer.length > 0) { while (recordingChunkBuffer.length > 0) {
const chunk = recordingChunkBuffer.shift()! const chunk = recordingChunkBuffer.shift()!
const { tabId, data, final } = chunk const { tabId, data, final } = chunk
// Send metadata message first // Send metadata message first
ws.send(JSON.stringify({ ws.send(
method: 'recordingData', JSON.stringify({
params: { tabId, final }, method: 'recordingData',
})) params: { tabId, final },
}),
)
// Then send binary data if not final // Then send binary data if not final
if (data && !final) { if (data && !final) {
const buffer = new Uint8Array(data) const buffer = new Uint8Array(data)
@@ -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 {
@@ -232,10 +233,10 @@ class ConnectionManager {
settled = true settled = true
logger.debug('WebSocket connected') logger.debug('WebSocket connected')
clearTimeout(timeout) clearTimeout(timeout)
// Flush any buffered recording chunks now that WebSocket is ready // Flush any buffered recording chunks now that WebSocket is ready
flushRecordingChunkBuffer(socket) flushRecordingChunkBuffer(socket)
resolve() resolve()
} }
@@ -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...')
} }
@@ -500,26 +512,26 @@ class ConnectionManager {
continue continue
} }
// Ensure tabs are in 'connecting' state when WS is not connected // Ensure tabs are in 'connecting' state when WS is not connected
// This handles edge cases where handleClose wasn't called or state got out of sync // This handles edge cases where handleClose wasn't called or state got out of sync
const currentTabs = store.getState().tabs const currentTabs = store.getState().tabs
const hasConnectedTabs = Array.from(currentTabs.values()).some((t) => t.state === 'connected') const hasConnectedTabs = Array.from(currentTabs.values()).some((t) => t.state === 'connected')
if (hasConnectedTabs) { if (hasConnectedTabs) {
store.setState((state) => { store.setState((state) => {
const newTabs = new Map(state.tabs) const newTabs = new Map(state.tabs)
for (const [tabId, tab] of newTabs) { for (const [tabId, tab] of newTabs) {
if (tab.state === 'connected') { if (tab.state === 'connected') {
newTabs.set(tabId, { ...tab, state: 'connecting' }) newTabs.set(tabId, { ...tab, state: 'connecting' })
}
} }
} return { tabs: newTabs }
return { tabs: newTabs } })
}) }
}
// Try to connect silently in background - don't show 'connecting' badge // Try to connect silently in background - don't show 'connecting' badge
// Individual tab states will show 'connecting' when user explicitly clicks // Individual tab states will show 'connecting' when user explicitly clicks
try { try {
await this.ensureConnection() await this.ensureConnection()
store.setState({ connectionState: 'connected' }) store.setState({ connectionState: 'connected' })
// Re-attach any tabs that were in 'connecting' state (from a previous disconnect) // Re-attach any tabs that were in 'connecting' state (from a previous disconnect)
@@ -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(
try { connectedTabIds.map(async (tabId) => {
await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params) try {
} catch (error) { await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params)
logger.debug('Failed to set auto-attach for tab:', tabId, error) } catch (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,14 +1470,17 @@ 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
chrome.contextMenus.create({ .remove('playwriter-pin-element')
id: 'playwriter-pin-element', .catch(() => {})
title: 'Copy Playwriter Element Reference', .finally(() => {
contexts: ['all'], chrome.contextMenus.create({
visible: false, id: 'playwriter-pin-element',
title: 'Copy Playwriter Element Reference',
contexts: ['all'],
visible: false,
})
}) })
})
function updateContextMenuVisibility(): void { function updateContextMenuVisibility(): void {
const { currentTabId, tabs } = store.getState() const { currentTabId, tabs } = store.getState()
@@ -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,31 +1573,33 @@ 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
// Query for playwriter group by title - no stale cached ID .then(async () => {
const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' }) // Query for playwriter group by title - no stale cached ID
const groupId = existingGroups[0]?.id const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' })
if (groupId === undefined) { const groupId = existingGroups[0]?.id
return if (groupId === undefined) {
}
const { tabs } = store.getState()
if (changeInfo.groupId === groupId) {
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
logger.debug('Tab manually added to playwriter group:', tabId)
await connectTab(tabId)
}
} else if (tabs.has(tabId)) {
const tabInfo = tabs.get(tabId)
if (tabInfo?.state === 'connecting') {
logger.debug('Tab removed from group while connecting, ignoring:', tabId)
return return
} }
logger.debug('Tab manually removed from playwriter group:', tabId) const { tabs } = store.getState()
await disconnectTab(tabId) if (changeInfo.groupId === groupId) {
} if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
}).catch((e) => { logger.debug('Tab manually added to playwriter group:', tabId)
logger.debug('onTabUpdated handler error:', e) await connectTab(tabId)
}) }
} else if (tabs.has(tabId)) {
const tabInfo = tabs.get(tabId)
if (tabInfo?.state === 'connecting') {
logger.debug('Tab removed from group while connecting, ignoring:', tabId)
return
}
logger.debug('Tab manually removed from playwriter group:', tabId)
await disconnectTab(tabId)
}
})
.catch((e) => {
logger.debug('onTabUpdated handler error:', e)
})
} }
}) })
@@ -1634,14 +1681,14 @@ void updateIcons()
chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => { chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
if (message.action === 'recordingChunk') { if (message.action === 'recordingChunk') {
const { tabId, data, final } = message const { tabId, data, final } = message
if (connectionManager.ws?.readyState === WebSocket.OPEN) { if (connectionManager.ws?.readyState === WebSocket.OPEN) {
// Send metadata message first // Send metadata message first
sendMessage({ sendMessage({
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) {
const buffer = new Uint8Array(data) const buffer = new Uint8Array(data)
@@ -1653,13 +1700,13 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
logger.debug(`Buffering recording chunk for tab ${tabId} (WebSocket not ready)`) logger.debug(`Buffering recording chunk for tab ${tabId} (WebSocket not ready)`)
recordingChunkBuffer.push({ tabId, data, final }) recordingChunkBuffer.push({ tabId, data, final })
} }
return false // Sync response, no need to keep channel open return false // Sync response, no need to keep channel open
} }
if (message.action === 'recordingCancelled') { if (message.action === 'recordingCancelled') {
const { tabId } = message const { tabId } = message
getActiveRecordings().delete(tabId) getActiveRecordings().delete(tabId)
store.setState((state) => { store.setState((state) => {
const newTabs = new Map(state.tabs) const newTabs = new Map(state.tabs)
@@ -1669,16 +1716,16 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
} }
return { tabs: newTabs } return { tabs: newTabs }
}) })
if (connectionManager.ws?.readyState === WebSocket.OPEN) { if (connectionManager.ws?.readyState === WebSocket.OPEN) {
sendMessage({ sendMessage({
method: 'recordingCancelled', method: 'recordingCancelled',
params: { tabId }, params: { tabId },
}) })
} }
return false return false
} }
return false return false
}) })
+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
>
} }
// ============================================================================ // ============================================================================
+31 -27
View File
@@ -55,24 +55,28 @@ export type OffscreenMessage =
| OffscreenCancelRecordingMessage | OffscreenCancelRecordingMessage
// Offscreen document response types // Offscreen document response types
export type OffscreenStartRecordingResult = { export type OffscreenStartRecordingResult =
success: true | {
tabId: number success: true
startedAt: number tabId: number
mimeType: string startedAt: number
} | { mimeType: string
success: false }
error: string | {
} success: false
error: string
}
export type OffscreenStopRecordingResult = { export type OffscreenStopRecordingResult =
success: true | {
tabId: number success: true
duration: number tabId: number
} | { duration: number
success: false }
error: string | {
} success: false
error: string
}
export interface OffscreenIsRecordingResult { export interface OffscreenIsRecordingResult {
isRecording: boolean isRecording: boolean
@@ -80,13 +84,15 @@ export interface OffscreenIsRecordingResult {
startedAt?: number startedAt?: number
} }
export type OffscreenCancelRecordingResult = { export type OffscreenCancelRecordingResult =
success: true | {
tabId: number success: true
} | { tabId: number
success: false }
error: string | {
} success: false
error: string
}
// Messages sent FROM offscreen TO background // Messages sent FROM offscreen TO background
export interface OffscreenRecordingChunkMessage { export interface OffscreenRecordingChunkMessage {
@@ -101,6 +107,4 @@ export interface OffscreenRecordingCancelledMessage {
tabId: number tabId: number
} }
export type OffscreenOutgoingMessage = export type OffscreenOutgoingMessage = OffscreenRecordingChunkMessage | OffscreenRecordingCancelledMessage
| OffscreenRecordingChunkMessage
| OffscreenRecordingCancelledMessage
+7 -7
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<title>Playwriter Offscreen</title> <title>Playwriter Offscreen</title>
</head> </head>
<body> <body>
<script src="./offscreen.ts" type="module"></script> <script src="./offscreen.ts" type="module"></script>
</body> </body>
</html> </html>
+22 -12
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: { ? {
chromeMediaSource: 'tab', mandatory: {
chromeMediaSourceId: params.streamId, chromeMediaSource: 'tab',
} 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
@@ -160,13 +166,13 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reject(new Error('MediaRecorder failed to start within 5 seconds')) reject(new Error('MediaRecorder failed to start within 5 seconds'))
}, 5000) }, 5000)
recorder.onstart = () => { recorder.onstart = () => {
clearTimeout(timeout) clearTimeout(timeout)
console.log(`MediaRecorder started for tab ${tabId}`) console.log(`MediaRecorder started for tab ${tabId}`)
resolve() resolve()
} }
// Start with 1 second chunks // Start with 1 second chunks
recorder.start(1000) recorder.start(1000)
}) })
@@ -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,
+1 -1
View File
@@ -18,7 +18,7 @@ export interface ExtensionState {
errorText: string | undefined errorText: string | undefined
} }
/** /**
* Recording state - stored in service worker to track active recordings. * Recording state - stored in service worker to track active recordings.
* The actual MediaRecorder/MediaStream live in the offscreen document. * The actual MediaRecorder/MediaStream live in the offscreen document.
*/ */
+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,
}); })
+28 -27
View File
@@ -1,29 +1,30 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "pnpm --filter playwriter test", "test": "pnpm --filter playwriter test",
"watch": "pnpm -r watch", "watch": "pnpm -r watch",
"cli": "pnpm --filter playwriter cli", "format": "prettier --write .",
"build": "pnpm --filter playwriter --filter mcp-extension build", "cli": "pnpm --filter playwriter cli",
"reload": "pnpm --filter playwriter build && pnpm --filter mcp-extension reload", "build": "pnpm --filter playwriter --filter mcp-extension build",
"agents.md": "agentsdotmd ./PLAYWRITER_AGENTS.md core.md typescript.md pnpm.md vitest.md changelog.md docs-writing.md github.md playwright.md zod.md gitchamber.md", "reload": "pnpm --filter playwriter build && pnpm --filter mcp-extension reload",
"release": "pnpm --filter playwriter build && cd extension && PRODUCTION=true PLAYWRITER_EXTENSION_DIST=dist-release pnpm build && cd .. && rm -f extension.zip && cd extension && zip -r ../extension.zip dist-release && cd .. && realpath extension.zip | pbcopy && open 'https://chrome.google.com/webstore/devconsole/a379d569-9533-44e4-9749-0368f6dbf878/jfeammnjpkecdekppnclgkkffahnhfhe/edit/package'", "agents.md": "agentsdotmd ./PLAYWRITER_AGENTS.md core.md typescript.md pnpm.md vitest.md changelog.md docs-writing.md github.md playwright.md zod.md gitchamber.md",
"bootstrap": "git submodule update --init && pnpm install && node playwright/utils/generate_injected.js && node playwright/packages/playwright-core/build.mjs", "release": "pnpm --filter playwriter build && cd extension && PRODUCTION=true PLAYWRITER_EXTENSION_DIST=dist-release pnpm build && cd .. && rm -f extension.zip && cd extension && zip -r ../extension.zip dist-release && cd .. && realpath extension.zip | pbcopy && open 'https://chrome.google.com/webstore/devconsole/a379d569-9533-44e4-9749-0368f6dbf878/jfeammnjpkecdekppnclgkkffahnhfhe/edit/package'",
"playwright:build": "node playwright/packages/playwright-core/build.mjs" "bootstrap": "git submodule update --init && pnpm install && node playwright/utils/generate_injected.js && node playwright/packages/playwright-core/build.mjs",
}, "playwright:build": "node playwright/packages/playwright-core/build.mjs"
"devDependencies": { },
"@changesets/cli": "^2.29.7", "devDependencies": {
"prettier": "^3.6.2", "@changesets/cli": "^2.29.7",
"tsx": "^4.20.6", "prettier": "^3.6.2",
"typescript": "^5.9.3", "tsx": "^4.20.6",
"vite": "^7.2.2", "typescript": "^5.9.3",
"vitest": "^4.0.8" "vite": "^7.2.2",
}, "vitest": "^4.0.8"
"repository": "https://github.com/remorses/", },
"author": "remorses <beats.by.morse@gmail.com>", "repository": "https://github.com/remorses/",
"license": "", "author": "remorses <beats.by.morse@gmail.com>",
"dependencies": { "license": "",
"mcp-extension": "workspace:*" "dependencies": {
} "mcp-extension": "workspace:*"
}
} }
+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
``` ```
+4 -4
View File
@@ -1,18 +1,18 @@
/** /**
* Consolidated build script for all client-side JavaScript bundles. * Consolidated build script for all client-side JavaScript bundles.
* *
* These bundles are injected into pages via CDP Runtime.evaluate or page.evaluate(). * These bundles are injected into pages via CDP Runtime.evaluate or page.evaluate().
* All bundles are built as browser-targeted IIFEs that expose their APIs on globalThis. * All bundles are built as browser-targeted IIFEs that expose their APIs on globalThis.
* *
* Injection flow (see react-source.ts, page-markdown.ts for examples): * Injection flow (see react-source.ts, page-markdown.ts for examples):
* 1. Bundle is read from dist/*.js via fs.readFileSync (cached after first read) * 1. Bundle is read from dist/*.js via fs.readFileSync (cached after first read)
* 2. Check if already injected: `await page.evaluate(() => !!globalThis.__name)` * 2. Check if already injected: `await page.evaluate(() => !!globalThis.__name)`
* 3. If not present, inject: `await page.evaluate(code)` or `cdp.send('Runtime.evaluate', { expression: code })` * 3. If not present, inject: `await page.evaluate(code)` or `cdp.send('Runtime.evaluate', { expression: code })`
* 4. Use the exposed global in subsequent evaluate calls: `globalThis.__readability`, `globalThis.__bippy`, etc. * 4. Use the exposed global in subsequent evaluate calls: `globalThis.__readability`, `globalThis.__bippy`, etc.
* *
* Each bundle uses a separate Bun.build() call (not multiple entrypoints in one call) * Each bundle uses a separate Bun.build() call (not multiple entrypoints in one call)
* to ensure fully self-contained output with no shared chunks. * to ensure fully self-contained output with no shared chunks.
* *
* Two types of bundles: * Two types of bundles:
* 1. Source file bundles - directly bundle a TypeScript source file * 1. Source file bundles - directly bundle a TypeScript source file
* 2. Wrapper bundles - create entry code that imports from npm packages and exposes on globalThis * 2. Wrapper bundles - create entry code that imports from npm packages and exposes on globalThis
+42 -41
View File
@@ -1,14 +1,14 @@
/** /**
* Generates markdown resource files for the MCP at build time. * Generates markdown resource files for the MCP at build time.
* *
* These files are written to: * These files are written to:
* - playwriter/dist/ - for the MCP to read at runtime * - playwriter/dist/ - for the MCP to read at runtime
* - website/public/ - for hosting on playwriter.dev * - website/public/ - for hosting on playwriter.dev
* *
* Source of truth: * Source of truth:
* - playwriter/src/skill.md - manually edited, contains full docs including CLI usage * - playwriter/src/skill.md - manually edited, contains full docs including CLI usage
* - skills/playwriter/SKILL.md - stub with frontmatter for agent discovery * - skills/playwriter/SKILL.md - stub with frontmatter for agent discovery
* *
* Generated files: * Generated files:
* - playwriter/dist/prompt.md - MCP prompt (skill.md minus CLI sections) * - playwriter/dist/prompt.md - MCP prompt (skill.md minus CLI sections)
* - website/public/SKILL.md - full copy for playwriter.dev/SKILL.md * - website/public/SKILL.md - full copy for playwriter.dev/SKILL.md
@@ -40,26 +40,24 @@ function readFile(relativePath: string): string {
function writeToDestinations(filename: string, content: string) { function writeToDestinations(filename: string, content: string) {
ensureDir(distDir) ensureDir(distDir)
ensureDir(websitePublicDir) ensureDir(websitePublicDir)
const distPath = path.join(distDir, filename) const distPath = path.join(distDir, filename)
const websitePath = path.join(websitePublicDir, filename) const websitePath = path.join(websitePublicDir, filename)
fs.writeFileSync(distPath, content, 'utf-8') fs.writeFileSync(distPath, content, 'utf-8')
fs.writeFileSync(websitePath, content, 'utf-8') fs.writeFileSync(websitePath, content, 'utf-8')
console.log(`Generated ${filename}`) console.log(`Generated ${filename}`)
} }
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() {
const debuggerTypes = cleanTypes(readFile('dist/debugger.d.ts')) const debuggerTypes = cleanTypes(readFile('dist/debugger.d.ts'))
const debuggerExamples = readFile('src/debugger-examples.ts') const debuggerExamples = readFile('src/debugger-examples.ts')
const content = dedent` const content = dedent`
# Debugger API Reference # Debugger API Reference
@@ -75,14 +73,14 @@ function buildDebuggerApi() {
${debuggerExamples} ${debuggerExamples}
\`\`\` \`\`\`
` `
writeToDestinations('debugger-api.md', content) writeToDestinations('debugger-api.md', content)
} }
function buildEditorApi() { function buildEditorApi() {
const editorTypes = cleanTypes(readFile('dist/editor.d.ts')) const editorTypes = cleanTypes(readFile('dist/editor.d.ts'))
const editorExamples = readFile('src/editor-examples.ts') const editorExamples = readFile('src/editor-examples.ts')
const content = dedent` const content = dedent`
# Editor API Reference # Editor API Reference
@@ -100,14 +98,14 @@ function buildEditorApi() {
${editorExamples} ${editorExamples}
\`\`\` \`\`\`
` `
writeToDestinations('editor-api.md', content) writeToDestinations('editor-api.md', content)
} }
function buildStylesApi() { function buildStylesApi() {
const stylesTypes = cleanTypes(readFile('dist/styles.d.ts')) const stylesTypes = cleanTypes(readFile('dist/styles.d.ts'))
const stylesExamples = readFile('src/styles-examples.ts') const stylesExamples = readFile('src/styles-examples.ts')
const content = dedent` const content = dedent`
# Styles API Reference # Styles API Reference
@@ -125,24 +123,24 @@ function buildStylesApi() {
${stylesExamples} ${stylesExamples}
\`\`\` \`\`\`
` `
writeToDestinations('styles-api.md', content) writeToDestinations('styles-api.md', content)
} }
/** /**
* Removes CLI-related sections from skill.md to create prompt.md for the MCP. * Removes CLI-related sections from skill.md to create prompt.md for the MCP.
* *
* Sections removed: * Sections removed:
* - "## CLI Usage" section and all its subsections * - "## CLI Usage" section and all its subsections
*/ */
function stripCliSectionsFromSkill(skillContent: string): string { function stripCliSectionsFromSkill(skillContent: string): string {
// Parse markdown tokens // Parse markdown tokens
const tokens = Lexer.lex(skillContent) const tokens = Lexer.lex(skillContent)
// Filter out CLI Usage section and its subsections // Filter out CLI Usage section and its subsections
const filteredTokens: Token[] = [] const filteredTokens: Token[] = []
let skipUntilLevel: number | null = null let skipUntilLevel: number | null = null
for (const token of tokens) { for (const token of tokens) {
if (token.type === 'heading') { if (token.type === 'heading') {
const heading = token as Tokens.Heading const heading = token as Tokens.Heading
@@ -156,27 +154,34 @@ function stripCliSectionsFromSkill(skillContent: string): string {
skipUntilLevel = null skipUntilLevel = null
} }
} }
if (skipUntilLevel === null) { if (skipUntilLevel === null) {
filteredTokens.push(token) filteredTokens.push(token)
} }
} }
// 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() {
// Read skill.md as source of truth // Read skill.md as source of truth
const skillPath = path.join(playwriterDir, 'src', 'skill.md') const skillPath = path.join(playwriterDir, 'src', 'skill.md')
const skillContent = fs.readFileSync(skillPath, 'utf-8') const skillContent = fs.readFileSync(skillPath, 'utf-8')
// Generate prompt.md for MCP (without CLI sections) // Generate prompt.md for MCP (without CLI sections)
const promptContent = stripCliSectionsFromSkill(skillContent) const promptContent = stripCliSectionsFromSkill(skillContent)
const distPromptPath = path.join(distDir, 'prompt.md') const distPromptPath = path.join(distDir, 'prompt.md')
fs.writeFileSync(distPromptPath, promptContent, 'utf-8') fs.writeFileSync(distPromptPath, promptContent, 'utf-8')
console.log('Generated playwriter/dist/prompt.md (from skill.md)') console.log('Generated playwriter/dist/prompt.md (from skill.md)')
// Copy full skill.md to website/public/ for hosting at playwriter.dev/SKILL.md // Copy full skill.md to website/public/ for hosting at playwriter.dev/SKILL.md
const websitePublicRoot = path.join(playwriterDir, '..', 'website', 'public') const websitePublicRoot = path.join(playwriterDir, '..', 'website', 'public')
ensureDir(websitePublicRoot) ensureDir(websitePublicRoot)
@@ -193,10 +198,10 @@ function parseFrontmatter(content: string): { frontmatter: Record<string, string
if (!match) { if (!match) {
return { frontmatter: {}, body: content } return { frontmatter: {}, body: content }
} }
const yamlContent = match[1] const yamlContent = match[1]
const body = match[2] const body = match[2]
// Simple YAML parsing for key: value pairs // Simple YAML parsing for key: value pairs
const frontmatter: Record<string, string> = {} const frontmatter: Record<string, string> = {}
for (const line of yamlContent.split('\n')) { for (const line of yamlContent.split('\n')) {
@@ -207,17 +212,17 @@ function parseFrontmatter(content: string): { frontmatter: Record<string, string
frontmatter[key] = value frontmatter[key] = value
} }
} }
return { frontmatter, body } return { frontmatter, body }
} }
/** /**
* Builds the Well-Known Skills Discovery structure. * Builds the Well-Known Skills Discovery structure.
* *
* Creates: * Creates:
* - /.well-known/skills/index.json - discovery endpoint * - /.well-known/skills/index.json - discovery endpoint
* - /.well-known/skills/playwriter/SKILL.md - skill file * - /.well-known/skills/playwriter/SKILL.md - skill file
* *
* See: https://agentskills.io/specification * See: https://agentskills.io/specification
*/ */
function buildWellKnownSkills() { function buildWellKnownSkills() {
@@ -226,35 +231,31 @@ function buildWellKnownSkills() {
const websitePublicRoot = path.join(repoRoot, 'website', 'public') const websitePublicRoot = path.join(repoRoot, 'website', 'public')
const wellKnownDir = path.join(websitePublicRoot, '.well-known', 'skills') const wellKnownDir = path.join(websitePublicRoot, '.well-known', 'skills')
const playwriterSkillDir = path.join(wellKnownDir, 'playwriter') const playwriterSkillDir = path.join(wellKnownDir, 'playwriter')
// Read and parse the skill file // Read and parse the skill file
const skillContent = fs.readFileSync(skillSourcePath, 'utf-8') const skillContent = fs.readFileSync(skillSourcePath, 'utf-8')
const { frontmatter } = parseFrontmatter(skillContent) const { frontmatter } = parseFrontmatter(skillContent)
// Ensure directories exist // Ensure directories exist
ensureDir(wellKnownDir) ensureDir(wellKnownDir)
ensureDir(playwriterSkillDir) ensureDir(playwriterSkillDir)
// Copy SKILL.md to well-known location // Copy SKILL.md to well-known location
fs.writeFileSync(path.join(playwriterSkillDir, 'SKILL.md'), skillContent, 'utf-8') fs.writeFileSync(path.join(playwriterSkillDir, 'SKILL.md'), skillContent, 'utf-8')
console.log('Generated website/public/.well-known/skills/playwriter/SKILL.md') console.log('Generated website/public/.well-known/skills/playwriter/SKILL.md')
// Generate index.json // Generate index.json
const indexJson = { const indexJson = {
skills: [ skills: [
{ {
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')
} }
+38 -46
View File
@@ -1,58 +1,50 @@
import playwright from 'playwright-core' 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()
console.log(`Found ${contexts.length} browser context(s)`)
const contexts = browser.contexts() // Sleep 200 ms
console.log(`Found ${contexts.length} browser context(s)`) await new Promise((resolve) => setTimeout(resolve, 200))
for (const context of contexts) {
const pages = context.pages()
console.log(`Context has ${pages.length} page(s):`)
// Sleep 200 ms for (const page of pages) {
await new Promise((resolve) => setTimeout(resolve, 200)) await page.emulateMedia({ colorScheme: null })
for (const context of contexts) { const url = page.url()
const pages = context.pages() console.log(`\nPage URL: ${url}`)
console.log(`Context has ${pages.length} page(s):`)
for (const page of pages) { const html = await page.content()
await page.emulateMedia({colorScheme: null, }) const lines = html.split('\n').slice(0, 3)
const url = page.url() console.log('First 3 lines of HTML:')
console.log(`\nPage URL: ${url}`) lines.forEach((line, i) => {
console.log(` ${i + 1}: ${line}`)
})
// Watch for browser console logs and log them in Node.js
page.on('console', (msg) => {
console.log(`Browser log: [${msg.type()}] ${msg.text()}`)
})
const html = await page.content() console.log(`running eval`)
const lines = html.split('\n').slice(0, 3) // Evaluate a sum in the browser and log something from inside the browser context
console.log('First 3 lines of HTML:') const sumResult = await page.evaluate(() => {
lines.forEach((line, i) => { console.log('Logging from inside browser context!')
console.log(` ${i + 1}: ${line}`) return 1 + 2 + 3
}) })
// Watch for browser console logs and log them in Node.js console.log(`Sum result evaluated in browser: ${sumResult}`)
page.on('console', (msg) => { if ((page as any)._snapshotForAI) {
console.log(`Browser log: [${msg.type()}] ${msg.text()}`) const snapshot = await (page as any)._snapshotForAI()
}) const snapshotStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot)
console.log('First 100 chars of _snapshotForAI():', snapshotStr.slice(0, 100))
console.log(`running eval`) } else {
// Evaluate a sum in the browser and log something from inside the browser context console.log('_snapshotForAI is not available on this page.')
const sumResult = await page.evaluate(() => { }
console.log('Logging from inside browser context!')
return 1 + 2 + 3
})
console.log(`Sum result evaluated in browser: ${sumResult}`)
if ((page as any)._snapshotForAI) {
const snapshot = await (page as any)._snapshotForAI()
const snapshotStr =
typeof snapshot === 'string'
? snapshot
: JSON.stringify(snapshot)
console.log(
'First 100 chars of _snapshotForAI():',
snapshotStr.slice(0, 100),
)
} else {
console.log('_snapshotForAI is not available on this page.')
}
}
} }
}
} }
main() main()
+14 -16
View File
@@ -1,24 +1,22 @@
import playwright from 'playwright-core' 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()
console.log(`Found ${contexts.length} browser context(s)`)
// Sleep 200 ms
await new Promise((resolve) => setTimeout(resolve, 1000))
for (const context of contexts) {
const pages = context.pages()
console.log(`Context has ${pages.length} page(s):`)
// Log urls of current pages
pages.forEach((page, idx) => {
console.log(` Page ${idx + 1} URL: ${page.url()}`)
}) })
}
const contexts = browser.contexts()
console.log(`Found ${contexts.length} browser context(s)`)
// Sleep 200 ms
await new Promise((resolve) => setTimeout(resolve, 1000))
for (const context of contexts) {
const pages = context.pages()
console.log(`Context has ${pages.length} page(s):`)
// Log urls of current pages
pages.forEach((page, idx) => {
console.log(` Page ${idx + 1} URL: ${page.url()}`)
})
}
} }
main() main()
+19 -19
View File
@@ -1,28 +1,28 @@
import playwright from 'playwright-core' 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)`)
// Sleep 200 ms // Sleep 200 ms
await new Promise((resolve) => setTimeout(resolve, 200)) await new Promise((resolve) => setTimeout(resolve, 200))
for (const context of contexts) { for (const context of contexts) {
const pages = context.pages() const pages = context.pages()
console.log(`Context has ${pages.length} page(s):`) console.log(`Context has ${pages.length} page(s):`)
// Create a new page // Create a new page
const newPage = await context.newPage() const newPage = await context.newPage()
// Evaluate a sum (e.g., 2 + 3) and log the result // Evaluate a sum (e.g., 2 + 3) and log the result
const sumResult = await newPage.evaluate(() => 2 + 3) const sumResult = await newPage.evaluate(() => 2 + 3)
console.log(`Evaluated sum 2 + 3 = ${sumResult}`) console.log(`Evaluated sum 2 + 3 = ${sumResult}`)
// Sleep 1 second // Sleep 1 second
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
// Close the page // Close the page
await newPage.close() await newPage.close()
} }
} }
main() main()
-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
+5 -4
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, {
hideA11yLabels, renderA11yLabels,
} 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()
+189 -98
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,15 +317,16 @@ 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 =
? buildTextLine(node.name, nodeIndent) node.role === 'text'
: buildSnapshotLine({ ? buildTextLine(node.name, nodeIndent)
role: node.role, : buildSnapshotLine({
name: node.name, role: node.role,
baseLocator: node.baseLocator, name: node.name,
indent: nodeIndent, baseLocator: node.baseLocator,
hasChildren: node.children.length > 0, indent: nodeIndent,
}) hasChildren: node.children.length > 0,
})
return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)] return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)]
}) })
} }
@@ -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 ?? [])
return buildRawSnapshotTree({ .map((childId) => {
nodeId: childId, return buildRawSnapshotTree({
axById: options.axById, nodeId: childId,
isNodeInScope: options.isNodeInScope, axById: options.axById,
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,18 +633,20 @@ export function finalizeSnapshotOutput(
}, []) }, [])
let lineLocatorIndex = 0 let lineLocatorIndex = 0
const snapshot = lines.map((line) => { const snapshot = lines
let text = line.text .map((line) => {
if (line.baseLocator) { let text = line.text
const locator = locatorSequence[lineLocatorIndex] if (line.baseLocator) {
lineLocatorIndex += 1 const locator = locatorSequence[lineLocatorIndex]
text = buildLocatorLineText({ line, locator }) lineLocatorIndex += 1
} text = buildLocatorLineText({ line, locator })
if (line.hasChildren) { }
text += ':' if (line.hasChildren) {
} text += ':'
return text }
}).join('\n') return text
})
.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,29 +831,29 @@ 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()
// which has frameId() for OOPIF session attachment. // which has frameId() for OOPIF session attachment.
const resolvedFrame = await resolveFrame({ frame, page }) const resolvedFrame = await resolveFrame({ frame, page })
// For cross-origin iframes (OOPIFs), we need to attach to the iframe's target // For cross-origin iframes (OOPIFs), we need to attach to the iframe's target
// to get a separate CDP session. Same-origin iframes can use frameId directly. // to get a separate CDP session. Same-origin iframes can use frameId directly.
let oopifSessionId: string | null = null let oopifSessionId: string | null = null
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.setAttribute(data.attr, data.value) (element, data) => {
}, { attr: scopeAttr, value: scopeValue }) element.setAttribute(data.attr, data.value)
},
{ 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,16 +983,18 @@ 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
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope }) ? rootNode.childIds
}).filter(isTruthy) .map((childId) => {
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy) return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
})
.filter(isTruthy)
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
const filtered = rawRoots.flatMap((rawNode) => { const filtered = rawRoots.flatMap((rawNode) => {
if (interactiveOnly) { if (interactiveOnly) {
@@ -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(
return [entry.ref, entry.shortRef] refs.map((entry) => {
})) 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 }) => {
__a11y?: { const a11y = (
renderA11yLabels?: (labels: typeof entries) => number globalThis as {
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => { labelCount: number } __a11y?: {
renderA11yLabels?: (labels: typeof entries) => number
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => {
labelCount: number
}
}
}
).__a11y
if (a11y?.renderA11yLabels) {
return a11y.renderA11yLabels(entries)
} }
}).__a11y if (a11y?.computeA11ySnapshot) {
if (a11y?.renderA11yLabels) { const rootElement = root || document.body
return a11y.renderA11yLabels(entries) return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true })
} .labelCount
if (a11y?.computeA11ySnapshot) { }
const rootElement = root || document.body throw new Error('a11y client not loaded')
return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true }).labelCount },
} { entries: shortLabels, root: rootHandle, interactiveOnly },
throw new Error('a11y client not loaded') )
}, { 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
+199 -141
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, { [
nodeId: rootId, rootId,
ignored: false, {
role: roleValue('rootwebarea'), nodeId: rootId,
childIds: [mainId, navId], ignored: false,
}], role: roleValue('rootwebarea'),
[mainId, { childIds: [mainId, navId],
nodeId: mainId, },
ignored: false, ],
role: roleValue('main'), [
childIds: [headingId, buttonId], mainId,
backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId, {
}], nodeId: mainId,
[navId, { ignored: false,
nodeId: navId, role: roleValue('main'),
ignored: false, childIds: [headingId, buttonId],
role: roleValue('navigation'), backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId,
childIds: [listId], },
backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId, ],
}], [
[listId, { navId,
nodeId: listId, {
ignored: false, nodeId: navId,
role: roleValue('list'), ignored: false,
childIds: [listItemId], role: roleValue('navigation'),
backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId, childIds: [listId],
}], backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId,
[listItemId, { },
nodeId: listItemId, ],
ignored: false, [
role: roleValue('listitem'), listId,
childIds: [linkId], {
backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId, nodeId: listId,
}], ignored: false,
[linkId, { role: roleValue('list'),
nodeId: linkId, childIds: [listItemId],
ignored: false, backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId,
role: roleValue('link'), },
name: nameValue('Docs'), ],
backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId, [
}], listItemId,
[headingId, { {
nodeId: headingId, nodeId: listItemId,
ignored: false, ignored: false,
role: roleValue('heading'), role: roleValue('listitem'),
name: nameValue('Title'), childIds: [linkId],
backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId, backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId,
}], },
[buttonId, { ],
nodeId: buttonId, [
ignored: false, linkId,
role: roleValue('button'), {
name: nameValue('Submit'), nodeId: linkId,
backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId, ignored: false,
}], role: roleValue('link'),
name: nameValue('Docs'),
backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId,
},
],
[
headingId,
{
nodeId: headingId,
ignored: false,
role: roleValue('heading'),
name: nameValue('Title'),
backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId,
},
],
[
buttonId,
{
nodeId: buttonId,
ignored: false,
role: roleValue('button'),
name: nameValue('Submit'),
backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId,
},
],
]) ])
const allowed = new Set<Protocol.DOM.BackendNodeId>([204 as Protocol.DOM.BackendNodeId]) 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<
nodeId: Protocol.DOM.NodeId Protocol.DOM.BackendNodeId,
parentId?: Protocol.DOM.NodeId {
backendNodeId: Protocol.DOM.BackendNodeId nodeId: Protocol.DOM.NodeId
nodeName: string parentId?: Protocol.DOM.NodeId
attributes: Map<string, string> backendNodeId: Protocol.DOM.BackendNodeId
}>([ nodeName: string
[1 as Protocol.DOM.BackendNodeId, { attributes: Map<string, string>
nodeId: 10 as Protocol.DOM.NodeId, }
backendNodeId: 1 as Protocol.DOM.BackendNodeId, >([
nodeName: 'BUTTON', [
attributes: new Map([['id', 'save-btn']]), 1 as Protocol.DOM.BackendNodeId,
}], {
[2 as Protocol.DOM.BackendNodeId, { nodeId: 10 as Protocol.DOM.NodeId,
nodeId: 11 as Protocol.DOM.NodeId, backendNodeId: 1 as Protocol.DOM.BackendNodeId,
backendNodeId: 2 as Protocol.DOM.BackendNodeId, nodeName: 'BUTTON',
nodeName: 'A', attributes: new Map([['id', 'save-btn']]),
attributes: new Map([['data-testid', 'nav-home']]), },
}], ],
[3 as Protocol.DOM.BackendNodeId, { [
nodeId: 12 as Protocol.DOM.NodeId, 2 as Protocol.DOM.BackendNodeId,
backendNodeId: 3 as Protocol.DOM.BackendNodeId, {
nodeName: 'BUTTON', nodeId: 11 as Protocol.DOM.NodeId,
attributes: new Map([['id', 'ignored-action']]), backendNodeId: 2 as Protocol.DOM.BackendNodeId,
}], nodeName: 'A',
attributes: new Map([['data-testid', 'nav-home']]),
},
],
[
3 as Protocol.DOM.BackendNodeId,
{
nodeId: 12 as Protocol.DOM.NodeId,
backendNodeId: 3 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON',
attributes: new Map([['id', 'ignored-action']]),
},
],
]) ])
let refCounter = 0 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<
nodeId: Protocol.DOM.NodeId Protocol.DOM.BackendNodeId,
parentId?: Protocol.DOM.NodeId {
backendNodeId: Protocol.DOM.BackendNodeId nodeId: Protocol.DOM.NodeId
nodeName: string parentId?: Protocol.DOM.NodeId
attributes: Map<string, string> backendNodeId: Protocol.DOM.BackendNodeId
}>([ nodeName: string
[2 as Protocol.DOM.BackendNodeId, { attributes: Map<string, string>
nodeId: 20 as Protocol.DOM.NodeId, }
backendNodeId: 2 as Protocol.DOM.BackendNodeId, >([
nodeName: 'INPUT', [
attributes: new Map([['data-testid', 'email-input']]), 2 as Protocol.DOM.BackendNodeId,
}], {
[3 as Protocol.DOM.BackendNodeId, { nodeId: 20 as Protocol.DOM.NodeId,
nodeId: 21 as Protocol.DOM.NodeId, backendNodeId: 2 as Protocol.DOM.BackendNodeId,
backendNodeId: 3 as Protocol.DOM.BackendNodeId, nodeName: 'INPUT',
nodeName: 'BUTTON', attributes: new Map([['data-testid', 'email-input']]),
attributes: new Map([['id', 'save-primary']]), },
}], ],
[4 as Protocol.DOM.BackendNodeId, { [
nodeId: 22 as Protocol.DOM.NodeId, 3 as Protocol.DOM.BackendNodeId,
backendNodeId: 4 as Protocol.DOM.BackendNodeId, {
nodeName: 'BUTTON', nodeId: 21 as Protocol.DOM.NodeId,
attributes: new Map([['id', 'save-secondary']]), backendNodeId: 3 as Protocol.DOM.BackendNodeId,
}], nodeName: 'BUTTON',
attributes: new Map([['id', 'save-primary']]),
},
],
[
4 as Protocol.DOM.BackendNodeId,
{
nodeId: 22 as Protocol.DOM.NodeId,
backendNodeId: 4 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON',
attributes: new Map([['id', 'save-secondary']]),
},
],
]) ])
let refCounter = 0 let refCounter = 0
@@ -432,13 +478,16 @@ describe('aria-snapshot tree filters', () => {
], ],
} }
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, { const domByBackendId = new Map<
nodeId: Protocol.DOM.NodeId Protocol.DOM.BackendNodeId,
parentId?: Protocol.DOM.NodeId {
backendNodeId: Protocol.DOM.BackendNodeId nodeId: Protocol.DOM.NodeId
nodeName: string parentId?: Protocol.DOM.NodeId
attributes: Map<string, string> backendNodeId: Protocol.DOM.BackendNodeId
}>() nodeName: 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<
nodeId: Protocol.DOM.NodeId Protocol.DOM.BackendNodeId,
parentId?: Protocol.DOM.NodeId {
backendNodeId: Protocol.DOM.BackendNodeId nodeId: Protocol.DOM.NodeId
nodeName: string parentId?: Protocol.DOM.NodeId
attributes: Map<string, string> backendNodeId: Protocol.DOM.BackendNodeId
}>([ nodeName: string
[5 as Protocol.DOM.BackendNodeId, { attributes: Map<string, string>
nodeId: 30 as Protocol.DOM.NodeId, }
backendNodeId: 5 as Protocol.DOM.BackendNodeId, >([
nodeName: 'BUTTON', [
attributes: new Map([['id', 'delete']]), 5 as Protocol.DOM.BackendNodeId,
}], {
[6 as Protocol.DOM.BackendNodeId, { nodeId: 30 as Protocol.DOM.NodeId,
nodeId: 31 as Protocol.DOM.NodeId, backendNodeId: 5 as Protocol.DOM.BackendNodeId,
backendNodeId: 6 as Protocol.DOM.BackendNodeId, nodeName: 'BUTTON',
nodeName: 'BUTTON', attributes: new Map([['id', 'delete']]),
attributes: new Map([['id', 'save']]), },
}], ],
[
6 as Protocol.DOM.BackendNodeId,
{
nodeId: 31 as Protocol.DOM.NodeId,
backendNodeId: 6 as Protocol.DOM.BackendNodeId,
nodeName: 'BUTTON',
attributes: new Map([['id', 'save']]),
},
],
]) ])
let refCounter = 0 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)) {
File diff suppressed because it is too large Load Diff
+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
} }
+49 -37
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', {
stableKey: undefined, extensionId: 'default',
browser: fallbackData?.browser, stableKey: undefined,
profile: fallbackData?.profile, browser: fallbackData?.browser,
activeTargets: fallbackData?.activeTargets, profile: fallbackData?.profile,
playwriterVersion: fallbackData?.playwriterVersion || null, activeTargets: fallbackData?.activeTargets,
}] 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 =
? null selectedExtension.extensionId === 'default'
: (selectedExtension.stableKey || selectedExtension.extensionId) ? null
: 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,20 +528,16 @@ 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') console.log(`relay: ${LOG_FILE_PATH}`)
.action(() => { console.log(`cdp: ${LOG_CDP_FILE_PATH}`)
console.log(`relay: ${LOG_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') const skillPath = path.join(__dirname, '..', 'src', 'skill.md')
.action(() => { const content = fs.readFileSync(skillPath, 'utf-8')
const skillPath = path.join(__dirname, '..', 'src', 'skill.md') console.log(content)
const content = fs.readFileSync(skillPath, 'utf-8') })
console.log(content)
})
cli.help() cli.help()
cli.version(VERSION) cli.version(VERSION)
+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)
} }
+2 -5
View File
@@ -16,7 +16,7 @@ export interface CreateSmartDiffOptions {
/** /**
* Creates a smart diff that returns full content when changes exceed threshold. * Creates a smart diff that returns full content when changes exceed threshold.
* *
* When more than `threshold` (default 50%) of lines have changed, showing a diff * When more than `threshold` (default 50%) of lines have changed, showing a diff
* is not useful - we return the full new content instead. * is not useful - we return the full new content instead.
*/ */
@@ -54,10 +54,7 @@ export function createSmartDiff(options: CreateSmartDiffOptions): SmartDiffResul
const changeRatio = Math.min(changedLines / maxLines, 1) // Cap at 100% 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 { const data = (await response.json()) as {
extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number; playwriterVersion?: string | null }> 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`
} }
File diff suppressed because it is too large Load Diff
+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')
+361 -399
View File
@@ -2,427 +2,389 @@ import posthtml from 'posthtml'
import beautify from 'posthtml-beautify' import beautify from 'posthtml-beautify'
export interface FormatHtmlOptions { export interface FormatHtmlOptions {
html: string html: string
keepStyles?: boolean keepStyles?: boolean
maxAttrLen?: number maxAttrLen?: number
maxContentLen?: number maxContentLen?: number
} }
export async function formatHtmlForPrompt({ export async function formatHtmlForPrompt({
html, html,
keepStyles = false, keepStyles = false,
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
'label', 'label',
'title', 'title',
'alt', 'alt',
'href', 'href',
'name', 'name',
'value', 'value',
'checked', 'checked',
'placeholder', 'placeholder',
'type', 'type',
'role', 'role',
'target', 'target',
// Descriptive aria attributes (text content) // Descriptive aria attributes (text content)
'aria-label', 'aria-label',
'aria-placeholder', 'aria-placeholder',
'aria-valuetext', 'aria-valuetext',
'aria-roledescription', 'aria-roledescription',
// Useful aria state attributes // Useful aria state attributes
'aria-hidden', 'aria-hidden',
'aria-expanded', 'aria-expanded',
'aria-checked', 'aria-checked',
'aria-selected', 'aria-selected',
'aria-disabled', 'aria-disabled',
'aria-pressed', 'aria-pressed',
'aria-required', 'aria-required',
'aria-current', 'aria-current',
// Test IDs (data-testid, data-test, data-cy, data-qa are covered by data-* prefix) // Test IDs (data-testid, data-test, data-cy, data-qa are covered by data-* prefix)
'testid', 'testid',
'test-id', 'test-id',
'tid', 'tid',
'qa', 'qa',
'qa-id', 'qa-id',
'e2e', 'e2e',
'e2e-id', 'e2e-id',
'automation-id', 'automation-id',
'automationid', 'automationid',
'selenium', 'selenium',
'pw', 'pw',
'vimium-label', 'vimium-label',
// Conditionally added: 'style', 'class' // Conditionally added: 'style', 'class'
] ]
if (keepStyles) { if (keepStyles) {
attributesToKeep.push('style', 'class') attributesToKeep.push('style', 'class')
} }
const truncate = (str: string, maxLen: number): string => { const truncate = (str: string, maxLen: number): string => {
if (str.length <= maxLen) return str if (str.length <= maxLen) return str
const remaining = str.length - maxLen const remaining = str.length - maxLen
return str.slice(0, maxLen) + `...${remaining} more characters` return str.slice(0, maxLen) + `...${remaining} more characters`
} }
// Create a custom plugin to remove tags and filter attributes // Create a custom plugin to remove tags and filter attributes
const removeTagsAndAttrsPlugin = () => { const removeTagsAndAttrsPlugin = () => {
return (tree) => { return (tree) => {
// Remove comments at root level // Remove comments at root level
tree = tree.filter((item) => { tree = tree.filter((item) => {
if (typeof item === 'string') { if (typeof item === 'string') {
const trimmed = item.trim() const trimmed = item.trim()
return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->')) return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->'))
}
return true
})
// Process each node recursively
const processNode = (node) => {
if (typeof node === 'string') {
// Truncate text content
const trimmed = node.trim()
if (trimmed.length === 0) return node
return truncate(node, maxContentLen)
}
// Remove unwanted tags
if (node.tag && tagsToRemove.includes(node.tag.toLowerCase())) {
return null
}
// Filter attributes
if (node.attrs) {
const newAttrs: typeof node.attrs = {}
for (const [attr, value] of Object.entries(node.attrs)) {
const shouldKeep =
attr.startsWith('data-') ||
attributesToKeep.includes(attr)
if (shouldKeep) {
// Truncate attribute values
newAttrs[attr] = typeof value === 'string'
? truncate(value, maxAttrLen)
: value
}
}
node.attrs = newAttrs
}
// Process content recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter(item => {
if (item === null) return false
if (typeof item === 'string') {
const trimmed = item.trim()
return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->'))
}
return true
})
}
return node
}
// Process all root nodes
return tree.map(processNode).filter(item => item !== null)
} }
} return true
})
// Plugin to remove aria-hidden="true" subtrees entirely // Process each node recursively
// These are hidden from assistive tech and usually decorative const processNode = (node) => {
const removeAriaHiddenPlugin = () => { if (typeof node === 'string') {
return (tree) => { // Truncate text content
const processNode = (node) => { const trimmed = node.trim()
if (typeof node === 'string') return node if (trimmed.length === 0) return node
if (!node.tag) return node return truncate(node, maxContentLen)
// Remove if aria-hidden="true"
if (node.attrs?.['aria-hidden'] === 'true') {
return null
}
// Process children recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter((item) => item !== null)
}
return node
}
return tree.map(processNode).filter((item) => item !== null)
}
}
// Plugin to remove images with empty alt text (purely decorative)
// Runs before decorative subtree pruning so containers become empty
const removeEmptyAltImagesPlugin = () => {
return (tree) => {
const processNode = (node) => {
if (typeof node === 'string') return node
if (!node.tag) return node
// Remove img with empty or missing alt
if (node.tag.toLowerCase() === 'img') {
const alt = node.attrs?.alt
if (alt === '' || alt === undefined) {
return null
}
}
// Process children recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter((item) => item !== null)
}
return node
}
return tree.map(processNode).filter((item) => item !== null)
}
}
// Plugin to remove decorative subtrees that have no useful content for agents
// A subtree is decorative if it has:
// - No text content (leaf text nodes)
// - No actionable elements with meaningful attributes
const removeDecorativeSubtreesPlugin = () => {
const actionableTags = ['button', 'a', 'input', 'select', 'textarea']
const meaningfulAttrs = [
'aria-label',
'title',
'alt',
'value',
'placeholder',
'href',
'name',
]
// Form elements are always actionable, keep unconditionally
const formTags = ['input', 'select', 'textarea']
// Check if a subtree has any useful content
const hasUsefulContent = (node): boolean => {
if (typeof node === 'string') {
return node.trim().length > 0
}
if (!node.tag) return false
// Form elements are always useful for agents to interact with
if (formTags.includes(node.tag.toLowerCase())) {
return true
}
// Images with non-empty alt text are useful (descriptive content)
if (node.tag.toLowerCase() === 'img') {
const alt = node.attrs?.alt
if (typeof alt === 'string' && alt.trim().length > 0) {
return true
}
}
// Check if this is an actionable element with meaningful attributes
if (actionableTags.includes(node.tag.toLowerCase())) {
if (node.attrs) {
for (const attr of meaningfulAttrs) {
const value = node.attrs[attr]
if (typeof value === 'string' && value.trim().length > 0) {
return true
}
}
}
}
// Check children recursively
if (node.content && Array.isArray(node.content)) {
for (const child of node.content) {
if (hasUsefulContent(child)) {
return true
}
}
}
return false
} }
return (tree) => { // Remove unwanted tags
const processNode = (node) => { if (node.tag && tagsToRemove.includes(node.tag.toLowerCase())) {
if (typeof node === 'string') return node return null
if (!node.tag) return node
// First process children
if (node.content && Array.isArray(node.content)) {
node.content = node.content
.map(processNode)
.filter((item) => item !== null)
}
// After processing children, check if this subtree is now decorative
// Skip root-level semantic elements (body, main, etc.)
const semanticTags = [
'html',
'body',
'main',
'header',
'footer',
'nav',
'section',
'article',
'aside',
]
if (semanticTags.includes(node.tag.toLowerCase())) {
return node
}
// If no useful content in this subtree, remove it
if (!hasUsefulContent(node)) {
return null
}
return node
}
return tree.map(processNode).filter((item) => item !== null)
} }
// Filter attributes
if (node.attrs) {
const newAttrs: typeof node.attrs = {}
for (const [attr, value] of Object.entries(node.attrs)) {
const shouldKeep = attr.startsWith('data-') || attributesToKeep.includes(attr)
if (shouldKeep) {
// Truncate attribute values
newAttrs[attr] = typeof value === 'string' ? truncate(value, maxAttrLen) : value
}
}
node.attrs = newAttrs
}
// Process content recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content.map(processNode).filter((item) => {
if (item === null) return false
if (typeof item === 'string') {
const trimmed = item.trim()
return !(trimmed.startsWith('<!--') && trimmed.endsWith('-->'))
}
return true
})
}
return node
}
// Process all root nodes
return tree.map(processNode).filter((item) => item !== null)
}
}
// Plugin to remove aria-hidden="true" subtrees entirely
// These are hidden from assistive tech and usually decorative
const removeAriaHiddenPlugin = () => {
return (tree) => {
const processNode = (node) => {
if (typeof node === 'string') return node
if (!node.tag) return node
// Remove if aria-hidden="true"
if (node.attrs?.['aria-hidden'] === 'true') {
return null
}
// Process children recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content.map(processNode).filter((item) => item !== null)
}
return node
}
return tree.map(processNode).filter((item) => item !== null)
}
}
// Plugin to remove images with empty alt text (purely decorative)
// Runs before decorative subtree pruning so containers become empty
const removeEmptyAltImagesPlugin = () => {
return (tree) => {
const processNode = (node) => {
if (typeof node === 'string') return node
if (!node.tag) return node
// Remove img with empty or missing alt
if (node.tag.toLowerCase() === 'img') {
const alt = node.attrs?.alt
if (alt === '' || alt === undefined) {
return null
}
}
// Process children recursively
if (node.content && Array.isArray(node.content)) {
node.content = node.content.map(processNode).filter((item) => item !== null)
}
return node
}
return tree.map(processNode).filter((item) => item !== null)
}
}
// Plugin to remove decorative subtrees that have no useful content for agents
// A subtree is decorative if it has:
// - No text content (leaf text nodes)
// - No actionable elements with meaningful attributes
const removeDecorativeSubtreesPlugin = () => {
const actionableTags = ['button', 'a', 'input', 'select', 'textarea']
const meaningfulAttrs = ['aria-label', 'title', 'alt', 'value', 'placeholder', 'href', 'name']
// Form elements are always actionable, keep unconditionally
const formTags = ['input', 'select', 'textarea']
// Check if a subtree has any useful content
const hasUsefulContent = (node): boolean => {
if (typeof node === 'string') {
return node.trim().length > 0
}
if (!node.tag) return false
// Form elements are always useful for agents to interact with
if (formTags.includes(node.tag.toLowerCase())) {
return true
}
// Images with non-empty alt text are useful (descriptive content)
if (node.tag.toLowerCase() === 'img') {
const alt = node.attrs?.alt
if (typeof alt === 'string' && alt.trim().length > 0) {
return true
}
}
// Check if this is an actionable element with meaningful attributes
if (actionableTags.includes(node.tag.toLowerCase())) {
if (node.attrs) {
for (const attr of meaningfulAttrs) {
const value = node.attrs[attr]
if (typeof value === 'string' && value.trim().length > 0) {
return true
}
}
}
}
// Check children recursively
if (node.content && Array.isArray(node.content)) {
for (const child of node.content) {
if (hasUsefulContent(child)) {
return true
}
}
}
return false
} }
// Plugin to unwrap unnecessary nested wrapper elements return (tree) => {
// e.g., <div><div><div><p>text</p></div></div></div> -> <div><p>text</p></div> const processNode = (node) => {
const unwrapNestedWrappersPlugin = () => { if (typeof node === 'string') return node
return (tree) => { if (!node.tag) return node
const isWhitespaceOnly = (node) => {
return typeof node === 'string' && node.trim().length === 0
}
const hasNoAttrs = (node) => { // First process children
return !node.attrs || Object.keys(node.attrs).length === 0 if (node.content && Array.isArray(node.content)) {
} node.content = node.content.map(processNode).filter((item) => item !== null)
const unwrapNode = (node) => {
if (typeof node === 'string') return node
if (!node.tag) return node
// First, recursively process children
if (node.content && Array.isArray(node.content)) {
node.content = node.content.map(unwrapNode)
}
// Check if this node is an unnecessary wrapper:
// - has no attributes
// - has exactly one non-whitespace child that is an element
if (hasNoAttrs(node) && node.content && Array.isArray(node.content)) {
const nonWhitespaceChildren = node.content.filter(c => !isWhitespaceOnly(c))
if (nonWhitespaceChildren.length === 1) {
const onlyChild = nonWhitespaceChildren[0]
// If the only child is also an element (not text), unwrap
if (typeof onlyChild !== 'string' && onlyChild.tag) {
// Replace this node with its child
return onlyChild
}
}
}
return node
}
// Apply multiple passes until stable (handles deeply nested wrappers)
let result = tree.map(unwrapNode)
let prevJson = ''
let currJson = JSON.stringify(result)
while (prevJson !== currJson) {
prevJson = currJson
result = result.map(unwrapNode)
currJson = JSON.stringify(result)
}
return result
} }
}
// Plugin to remove empty elements (no attrs, no content) // After processing children, check if this subtree is now decorative
// Runs repeatedly until no more empty elements exist // Skip root-level semantic elements (body, main, etc.)
const removeEmptyElementsPlugin = () => { const semanticTags = ['html', 'body', 'main', 'header', 'footer', 'nav', 'section', 'article', 'aside']
return (tree) => { if (semanticTags.includes(node.tag.toLowerCase())) {
const isEmptyElement = (node) => { return node
if (typeof node === 'string') return false
if (!node.tag) return false
const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0
const hasContent = node.content && node.content.some(c =>
typeof c === 'string' ? c.trim().length > 0 : true
)
return !hasAttrs && !hasContent
}
const removeEmpty = (content) => {
if (!content || !Array.isArray(content)) return content
return content
.map(node => {
if (typeof node === 'string') return node
if (node.content) {
node.content = removeEmpty(node.content)
}
return node
})
.filter(node => !isEmptyElement(node))
}
// Apply multiple passes until stable
let result = removeEmpty(tree)
let prevJson = ''
let currJson = JSON.stringify(result)
while (prevJson !== currJson) {
prevJson = currJson
result = removeEmpty(result)
currJson = JSON.stringify(result)
}
return result
} }
// If no useful content in this subtree, remove it
if (!hasUsefulContent(node)) {
return null
}
return node
}
return tree.map(processNode).filter((item) => item !== null)
} }
}
// Process HTML // Plugin to unwrap unnecessary nested wrapper elements
const processor = posthtml() // e.g., <div><div><div><p>text</p></div></div></div> -> <div><p>text</p></div>
.use(removeTagsAndAttrsPlugin()) const unwrapNestedWrappersPlugin = () => {
.use(removeAriaHiddenPlugin()) return (tree) => {
.use(removeEmptyAltImagesPlugin()) const isWhitespaceOnly = (node) => {
.use(removeDecorativeSubtreesPlugin()) return typeof node === 'string' && node.trim().length === 0
.use(removeEmptyElementsPlugin()) }
.use(unwrapNestedWrappersPlugin())
.use(beautify({ const hasNoAttrs = (node) => {
rules: { return !node.attrs || Object.keys(node.attrs).length === 0
indent: 1, // 1-space indent }
blankLines: false, // no extra blank lines
maxlen: 100000 // effectively never wrap by content length const unwrapNode = (node) => {
}, if (typeof node === 'string') return node
jsBeautifyOptions: { if (!node.tag) return node
wrap_line_length: 0, // disable js-beautify wrapping
preserve_newlines: false // reduce stray newlines // First, recursively process children
if (node.content && Array.isArray(node.content)) {
node.content = node.content.map(unwrapNode)
}
// Check if this node is an unnecessary wrapper:
// - has no attributes
// - has exactly one non-whitespace child that is an element
if (hasNoAttrs(node) && node.content && Array.isArray(node.content)) {
const nonWhitespaceChildren = node.content.filter((c) => !isWhitespaceOnly(c))
if (nonWhitespaceChildren.length === 1) {
const onlyChild = nonWhitespaceChildren[0]
// If the only child is also an element (not text), unwrap
if (typeof onlyChild !== 'string' && onlyChild.tag) {
// Replace this node with its child
return onlyChild
} }
})) }
}
// Process with await return node
const result = await processor.process(html) }
return result.html // Apply multiple passes until stable (handles deeply nested wrappers)
let result = tree.map(unwrapNode)
let prevJson = ''
let currJson = JSON.stringify(result)
while (prevJson !== currJson) {
prevJson = currJson
result = result.map(unwrapNode)
currJson = JSON.stringify(result)
}
return result
}
}
// Plugin to remove empty elements (no attrs, no content)
// Runs repeatedly until no more empty elements exist
const removeEmptyElementsPlugin = () => {
return (tree) => {
const isEmptyElement = (node) => {
if (typeof node === 'string') return false
if (!node.tag) return false
const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0
const hasContent =
node.content && node.content.some((c) => (typeof c === 'string' ? c.trim().length > 0 : true))
return !hasAttrs && !hasContent
}
const removeEmpty = (content) => {
if (!content || !Array.isArray(content)) return content
return content
.map((node) => {
if (typeof node === 'string') return node
if (node.content) {
node.content = removeEmpty(node.content)
}
return node
})
.filter((node) => !isEmptyElement(node))
}
// Apply multiple passes until stable
let result = removeEmpty(tree)
let prevJson = ''
let currJson = JSON.stringify(result)
while (prevJson !== currJson) {
prevJson = currJson
result = removeEmpty(result)
currJson = JSON.stringify(result)
}
return result
}
}
// Process HTML
const processor = posthtml()
.use(removeTagsAndAttrsPlugin())
.use(removeAriaHiddenPlugin())
.use(removeEmptyAltImagesPlugin())
.use(removeDecorativeSubtreesPlugin())
.use(removeEmptyElementsPlugin())
.use(unwrapNestedWrappersPlugin())
.use(
beautify({
rules: {
indent: 1, // 1-space indent
blankLines: false, // no extra blank lines
maxlen: 100000, // effectively never wrap by content length
},
jsBeautifyOptions: {
wrap_line_length: 0, // disable js-beautify wrapping
preserve_newlines: false, // reduce stray newlines
},
}),
)
// Process with await
const result = await processor.process(html)
return result.html
} }
+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',
+16 -13
View File
@@ -91,12 +91,12 @@ async function getOrCreateExecutor(): Promise<PlaywrightExecutor> {
if (executor) { if (executor) {
return executor return executor
} }
const remote = getRemoteConfig() const remote = getRemoteConfig()
if (!remote) { if (!remote) {
await ensureRelayServerForMcp() await ensureRelayServerForMcp()
} }
// Pass config instead of pre-generated URL so executor can generate unique URLs for each connection // Pass config instead of pre-generated URL so executor can generate unique URLs for each connection
const cdpConfig = remote || { port: RELAY_PORT } const cdpConfig = remote || { port: RELAY_PORT }
executor = new PlaywrightExecutor({ executor = new PlaywrightExecutor({
@@ -104,7 +104,7 @@ async function getOrCreateExecutor(): Promise<PlaywrightExecutor> {
logger: mcpLogger, logger: mcpLogger,
cwd: process.cwd(), cwd: process.cwd(),
}) })
return executor return executor
} }
@@ -198,37 +198,37 @@ server.tool(
if (!remote) { if (!remote) {
await ensureRelayServerForMcp() await ensureRelayServerForMcp()
} }
const exec = await getOrCreateExecutor() const exec = await getOrCreateExecutor()
const result = await exec.execute(code, timeout) const result = await exec.execute(code, timeout)
// Transform executor result to MCP format // Transform executor result to MCP format
const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [ const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
{ type: 'text', text: result.text }, { type: 'text', text: result.text },
] ]
for (const image of result.images) { for (const image of result.images) {
content.push({ type: 'image', data: image.data, mimeType: image.mimeType }) content.push({ type: 'image', data: image.data, mimeType: image.mimeType })
} }
if (result.isError) { if (result.isError) {
return { content, isError: true } return { content, isError: true }
} }
return { content } return { content }
} catch (error: any) { } catch (error: any) {
const errorStack = error.stack || error.message const errorStack = error.stack || error.message
const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError' const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
console.error('Error in execute tool:', errorStack) console.error('Error in execute tool:', errorStack)
if (!isTimeoutError) { if (!isTimeoutError) {
sendLogToRelayServer('error', 'Error in execute tool:', errorStack) sendLogToRelayServer('error', 'Error in execute tool:', errorStack)
} }
const resetHint = isTimeoutError const resetHint = isTimeoutError
? '' ? ''
: '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]' : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]'
return { return {
content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }], content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }],
isError: true, isError: true,
@@ -256,13 +256,16 @@ server.tool(
if (!remote) { if (!remote) {
await ensureRelayServerForMcp() await ensureRelayServerForMcp()
} }
const exec = await getOrCreateExecutor() const exec = await getOrCreateExecutor()
const { page, context } = await exec.reset() const { page, context } = await exec.reset()
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) {
+5 -5
View File
@@ -1,6 +1,6 @@
/** /**
* Extract page content as markdown using Mozilla Readability. * Extract page content as markdown using Mozilla Readability.
* *
* This utility injects the Readability library into the page and extracts * This utility injects the Readability library into the page and extracts
* the main content, similar to Firefox Reader View. * the main content, similar to Firefox Reader View.
*/ */
@@ -65,7 +65,7 @@ function isRegExp(value: unknown): value is RegExp {
/** /**
* Extract page content as markdown using Mozilla Readability. * Extract page content as markdown using Mozilla Readability.
* *
* Injects Readability into the page if not already present, then extracts * Injects Readability into the page if not already present, then extracts
* the main content. Returns plain text content (no HTML). * the main content. Returns plain text content (no HTML).
*/ */
@@ -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,11 +131,11 @@ 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[] = []
if (result.title) { if (result.title) {
lines.push(`# ${result.title}`) lines.push(`# ${result.title}`)
lines.push('') lines.push('')
+69 -57
View File
@@ -2,19 +2,18 @@ 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' params: {
params: { method: K
method: K sessionId?: string
sessionId?: string params?: ProtocolMapping.Commands[K]['paramsType'][0]
params?: ProtocolMapping.Commands[K]['paramsType'][0] source?: 'playwriter'
source?: 'playwriter'
}
} }
}[keyof ProtocolMapping.Commands] }
}[keyof ProtocolMapping.Commands]
export type ExtensionCommandMessage = ForwardCDPCommand export type ExtensionCommandMessage = ForwardCDPCommand
@@ -29,18 +28,17 @@ 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' params: {
params: { method: CDPEventFor<K>['method']
method: CDPEventFor<K>['method'] sessionId?: string
sessionId?: string params?: CDPEventFor<K>['params']
params?: CDPEventFor<K>['params']
}
} }
}[keyof ProtocolMapping.Events] }
}[keyof ProtocolMapping.Events]
export type ExtensionLogMessage = { export type ExtensionLogMessage = {
id?: undefined id?: undefined
@@ -78,7 +76,13 @@ export type RecordingCancelledMessage = {
} }
} }
export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage | ExtensionLogMessage | ExtensionPongMessage | RecordingDataMessage | RecordingCancelledMessage export type ExtensionMessage =
| ExtensionResponseMessage
| ExtensionEventMessage
| ExtensionLogMessage
| ExtensionPongMessage
| RecordingDataMessage
| RecordingCancelledMessage
// Recording command messages (MCP -> Extension via relay) // Recording command messages (MCP -> Extension via relay)
export type StartRecordingParams = { export type StartRecordingParams = {
@@ -137,36 +141,42 @@ export type RecordingCommandMessage =
| CancelRecordingMessage | CancelRecordingMessage
// Recording result types // Recording result types
export type StartRecordingResult = { export type StartRecordingResult =
success: true | {
tabId: number success: true
startedAt: number tabId: number
} | { startedAt: number
success: false }
error: string | {
} success: false
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 | {
tabId: number success: true
duration: number tabId: number
} | { duration: number
success: false }
error: string | {
} success: false
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 | {
tabId: number success: true
duration: number tabId: number
path: string duration: number
size: number path: string
} | { size: number
success: false }
error: string | {
} success: false
error: string
}
export type IsRecordingResult = { export type IsRecordingResult = {
isRecording: boolean isRecording: boolean
@@ -193,10 +203,12 @@ export type GhostBrowserCommandMessage = {
} }
} }
export type GhostBrowserCommandResult = { export type GhostBrowserCommandResult =
success: true | {
result: unknown success: true
} | { result: unknown
success: false }
error: string | {
} success: false
error: string
}
+20 -12
View File
@@ -22,7 +22,7 @@ import type {
// Recording state - tracks active recordings and their accumulated chunks // Recording state - tracks active recordings and their accumulated chunks
export interface ActiveRecording { export interface ActiveRecording {
tabId: number tabId: number
sessionId?: string // The sessionId used to start this recording, for lookup when stopping sessionId?: string // The sessionId used to start this recording, for lookup when stopping
outputPath: string outputPath: string
chunks: Buffer[] chunks: Buffer[]
startedAt: number startedAt: number
@@ -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)
+19 -9
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(
port?: number options: {
timeoutMs?: number port?: number
logger?: { log: (...args: any[]) => void } timeoutMs?: number
} = {}): Promise<boolean> { logger?: { log: (...args: any[]) => void }
} = {},
): 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 })
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -49
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)
``` ```
@@ -223,17 +219,14 @@ const pageTitle = await page.evaluate(() => document.title)
// Modify page // Modify page
await page.evaluate(() => { await page.evaluate(() => {
document.body.style.backgroundColor = 'red' document.body.style.backgroundColor = 'red'
}) })
// Pass arguments to page context // Pass arguments to page context
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
@@ -244,8 +237,8 @@ const href = await page.getByRole('link').evaluate((el) => el.href)
// Modify element // Modify element
await page.getByRole('button').evaluate((el) => { await page.getByRole('button').evaluate((el) => {
el.style.backgroundColor = 'green' el.style.backgroundColor = 'green'
el.disabled = true el.disabled = true
}) })
// Scroll element into view // Scroll element into view
@@ -261,9 +254,7 @@ await page.getByText('Section').evaluate((el) => el.scrollIntoView())
await page.getByLabel('Upload file').setInputFiles('/path/to/file.pdf') 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
@@ -295,11 +285,11 @@ console.log('Request method:', request.method())
// Get all resources loaded by the page // Get all resources loaded by the page
const resources = await page.evaluate(() => const resources = await page.evaluate(() =>
performance.getEntriesByType('resource').map((r) => ({ performance.getEntriesByType('resource').map((r) => ({
name: r.name, name: r.name,
duration: r.duration, duration: r.duration,
size: r.transferSize, size: r.transferSize,
})), })),
) )
console.log('Page resources:', resources) console.log('Page resources:', resources)
``` ```
@@ -314,9 +304,9 @@ console.log('Page resources:', resources)
// To trigger console messages from the page: // To trigger console messages from the page:
await page.evaluate(() => { await page.evaluate(() => {
console.log('This message will be captured') console.log('This message will be captured')
console.error('This error will be captured') console.error('This error will be captured')
console.warn('This warning will be captured') console.warn('This warning will be captured')
}) })
// Then use the console_logs MCP tool to retrieve all captured messages // Then use the console_logs MCP tool to retrieve all captured messages
@@ -338,10 +328,7 @@ await page.waitForURL(/github\.com.*\/pull/)
await page.waitForURL(/\/new-org/) 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
} }
+40 -29
View File
@@ -1,7 +1,7 @@
/** /**
* Screen recording utility for playwriter using chrome.tabCapture. * Screen recording utility for playwriter using chrome.tabCapture.
* Recording happens in the extension context, so it survives page navigation. * Recording happens in the extension context, so it survives page navigation.
* *
* This module communicates with the relay server which forwards commands to the extension. * This module communicates with the relay server which forwards commands to the extension.
* SessionId (pw-tab-X format) is used to identify which tab to record. * SessionId (pw-tab-X format) is used to identify which tab to record.
*/ */
@@ -9,26 +9,22 @@
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'
/** /**
* Generate a shell command to quit and restart Chrome with flags that allow automatic tab capture. * Generate a shell command to quit and restart Chrome with flags that allow automatic tab capture.
* This enables screen recording without user interaction (clicking extension icon). * This enables screen recording without user interaction (clicking extension icon).
* *
* Required flags: * Required flags:
* - --allowlisted-extension-id=<id> - grants the extension special privileges (one per extension) * - --allowlisted-extension-id=<id> - grants the extension special privileges (one per extension)
* - --auto-accept-this-tab-capture - auto-accepts tab capture permission requests * - --auto-accept-this-tab-capture - auto-accepts tab capture permission requests
*/ */
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('activeTab') || error.includes('Extension has not been invoked') ||
error.includes('enable recording') error.includes('activeTab') ||
error.includes('enable recording')
)
} }
export interface StartRecordingOptions { export interface StartRecordingOptions {
@@ -96,34 +94,41 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
outputPath, outputPath,
relayPort = 19988, relayPort = 19988,
} = options } = options
// Resolve relative paths to absolute using the caller's cwd. // Resolve relative paths to absolute using the caller's cwd.
// The relay server may have a different cwd, so we must resolve here. // The relay server may have a different cwd, so we must resolve here.
const absoluteOutputPath = path.resolve(outputPath) const absoluteOutputPath = path.resolve(outputPath)
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'
// If the error is about missing activeTab permission, provide helpful guidance // If the error is about missing activeTab permission, provide helpful guidance
if (isActiveTabPermissionError(errorMsg)) { if (isActiveTabPermissionError(errorMsg)) {
const restartCmd = getChromeRestartCommand() const restartCmd = getChromeRestartCommand()
throw new Error( throw new Error(
`Failed to start recording: ${errorMsg}\n\n` + `Failed to start recording: ${errorMsg}\n\n` +
`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.`,
) )
} }
throw new Error(`Failed to start recording: ${errorMsg}`) throw new Error(`Failed to start recording: ${errorMsg}`)
} }
@@ -138,7 +143,9 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
* Stop recording and save to file. * 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}`)
+249 -181
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,28 +307,31 @@ 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()'
# GOOD: use single quotes inside, or template strings # GOOD: use single quotes inside, or template strings
@@ -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.
@@ -421,10 +441,10 @@ Example output:
```md ```md
- banner: - banner:
- link "Home" [id="nav-home"] - link "Home" [id="nav-home"]
- navigation: - navigation:
- link "Docs" [data-testid="docs-link"] - link "Docs" [data-testid="docs-link"]
- link "Blog" role=link[name="Blog"] - link "Blog" role=link[name="Blog"]
``` ```
Each interactive line ends with a Playwright locator you can pass to `state.page.locator()`. Each interactive line ends with a Playwright locator you can pass to `state.page.locator()`.
@@ -454,11 +474,12 @@ const snap = await snapshot({ page: state.page, search: /button|submit/i })
**Filtering large snapshots in JS** — when the built-in `search` isn't enough (e.g., you need multiple patterns or custom logic), filter the snapshot string directly: **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
@@ -504,9 +527,9 @@ state.page.locator('button').nth(2).click()
If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`: If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`:
```js ```js
await state.page.locator('button').first().click() // first match await state.page.locator('button').first().click() // first match
await state.page.locator('.item').last().click() // last match await state.page.locator('.item').last().click() // last match
await state.page.locator('li').nth(3).click() // 4th item (0-indexed) await state.page.locator('li').nth(3).click() // 4th item (0-indexed)
``` ```
## working with pages ## working with pages
@@ -521,8 +544,8 @@ On your very first execute call, reuse an existing empty tab or create a new one
// Reuse an empty about:blank tab if available, otherwise create a new one. // 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
``` ```
@@ -822,31 +867,32 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
```js ```js
// Start recording - outputPath must be specified upfront // Start recording - outputPath must be specified upfront
await startRecording({ await startRecording({
page: state.page, page: state.page,
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,21 +1052,24 @@ This section covers low-level mouse/keyboard APIs not documented elsewhere. For
await state.page.locator('button[name="Submit"]').click() await state.page.locator('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
await state.page.mouse.click(450, 320, { button: 'right' }) // right click await state.page.mouse.click(450, 320, { button: 'right' }) // right click
await state.page.mouse.dblclick(450, 320) // double click await state.page.mouse.dblclick(450, 320) // double click
await state.page.mouse.click(450, 320, { clickCount: 3 }) // triple click await state.page.mouse.click(450, 320, { clickCount: 3 }) // triple click
await state.page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click await state.page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
``` ```
### hover ### hover
```js ```js
await state.page.locator('.tooltip-trigger').hover() // by locator (preferred) await state.page.locator('.tooltip-trigger').hover() // by locator (preferred)
await state.page.mouse.move(450, 320) // by coordinates await state.page.mouse.move(450, 320) // by coordinates
``` ```
### scroll ### scroll
@@ -1013,17 +1079,19 @@ await state.page.mouse.move(450, 320) // by coordinates
await state.page.locator('#footer').scrollIntoViewIfNeeded() await state.page.locator('#footer').scrollIntoViewIfNeeded()
// By pixel (for canvas, maps, infinite scroll) // By pixel (for canvas, maps, infinite scroll)
await state.page.mouse.wheel(0, 300) // scroll down 300px await state.page.mouse.wheel(0, 300) // scroll down 300px
await state.page.mouse.wheel(0, -300) // scroll up await state.page.mouse.wheel(0, -300) // scroll up
await state.page.mouse.wheel(300, 0) // scroll right await state.page.mouse.wheel(300, 0) // scroll right
await state.page.mouse.wheel(-300, 0) // scroll left await state.page.mouse.wheel(-300, 0) // scroll left
// Scroll at a specific position // Scroll at a specific position
await state.page.mouse.move(450, 320) 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
@@ -1035,7 +1103,7 @@ await state.page.locator('#item').dragTo(state.page.locator('#target'))
// By coordinates (for canvas, sliders, custom drag targets) // By coordinates (for canvas, sliders, custom drag targets)
await state.page.mouse.move(100, 200) await state.page.mouse.move(100, 200)
await state.page.mouse.down() await state.page.mouse.down()
await state.page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag await state.page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
await state.page.mouse.up() await state.page.mouse.up()
``` ```
@@ -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:
File diff suppressed because it is too large Load Diff
+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,
}
+20 -21
View File
@@ -120,12 +120,12 @@ export async function getStylesForLocator({
const matchedStyles = await cdp.send('CSS.getMatchedStylesForNode', { nodeId }) const matchedStyles = await cdp.send('CSS.getMatchedStylesForNode', { nodeId })
const stylesheetUrls = new Map<string, string>() const stylesheetUrls = new Map<string, string>()
const processStyleSheetId = async (styleSheetId: string | undefined): Promise<StyleSource | null> => { const processStyleSheetId = async (styleSheetId: string | undefined): Promise<StyleSource | null> => {
if (!styleSheetId) { if (!styleSheetId) {
return null return null
} }
if (!stylesheetUrls.has(styleSheetId)) { if (!stylesheetUrls.has(styleSheetId)) {
try { try {
const header = await cdp.send('CSS.getStyleSheetText', { styleSheetId }) const header = await cdp.send('CSS.getStyleSheetText', { styleSheetId })
@@ -134,7 +134,7 @@ export async function getStylesForLocator({
stylesheetUrls.set(styleSheetId, '') stylesheetUrls.set(styleSheetId, '')
} }
} }
return null return null
} }
@@ -145,14 +145,15 @@ export async function getStylesForLocator({
const rule = ruleMatch.rule const rule = ruleMatch.rule
const sourceRange = (rule as any).selectorList?.range as SourceRange | undefined const sourceRange = (rule as any).selectorList?.range as SourceRange | undefined
const styleSheetId = rule.styleSheetId const styleSheetId = rule.styleSheetId
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',
line: sourceRange.startLine + 1, line: sourceRange.startLine + 1,
@@ -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,
@@ -239,7 +238,7 @@ function extractDeclarations(style: CSSStyle): StyleDeclarations {
if (!style?.cssProperties) { if (!style?.cssProperties) {
return {} return {}
} }
const result: StyleDeclarations = {} const result: StyleDeclarations = {}
for (const prop of style.cssProperties) { for (const prop of style.cssProperties) {
if (!prop.value || prop.value === 'initial' || prop.name.startsWith('-webkit-')) { if (!prop.value || prop.value === 'initial' || prop.name.startsWith('-webkit-')) {
@@ -253,13 +252,13 @@ function extractDeclarations(style: CSSStyle): StyleDeclarations {
function formatElementDescription(node: any): string { function formatElementDescription(node: any): string {
let desc = node.localName || node.nodeName?.toLowerCase() || 'element' let desc = node.localName || node.nodeName?.toLowerCase() || 'element'
if (node.attributes) { if (node.attributes) {
const attrs: Record<string, string> = {} const attrs: Record<string, string> = {}
for (let i = 0; i < node.attributes.length; i += 2) { for (let i = 0; i < node.attributes.length; i += 2) {
attrs[node.attributes[i]] = node.attributes[i + 1] attrs[node.attributes[i]] = node.attributes[i + 1]
} }
if (attrs.id) { if (attrs.id) {
desc += `#${attrs.id}` desc += `#${attrs.id}`
} }
@@ -267,7 +266,7 @@ function formatElementDescription(node: any): string {
desc += `.${attrs.class.split(' ').join('.')}` desc += `.${attrs.class.split(' ').join('.')}`
} }
} }
return desc return desc
} }
@@ -282,10 +281,10 @@ async function getStylesheetUrl(cdp: ICDPSession, styleSheetId: string): Promise
export function formatStylesAsText(styles: StylesResult): string { export function formatStylesAsText(styles: StylesResult): string {
const lines: string[] = [] const lines: string[] = []
lines.push(`Element: ${styles.element}`) lines.push(`Element: ${styles.element}`)
lines.push('') lines.push('')
if (styles.inlineStyle) { if (styles.inlineStyle) {
lines.push('Inline styles:') lines.push('Inline styles:')
for (const [prop, value] of Object.entries(styles.inlineStyle)) { for (const [prop, value] of Object.entries(styles.inlineStyle)) {
@@ -293,10 +292,10 @@ export function formatStylesAsText(styles: StylesResult): string {
} }
lines.push('') lines.push('')
} }
const directRules = styles.rules.filter((r) => !r.inheritedFrom) const directRules = styles.rules.filter((r) => !r.inheritedFrom)
const inheritedRules = styles.rules.filter((r) => r.inheritedFrom) const inheritedRules = styles.rules.filter((r) => r.inheritedFrom)
if (directRules.length > 0) { if (directRules.length > 0) {
lines.push('Matched rules:') lines.push('Matched rules:')
for (const rule of directRules) { for (const rule of directRules) {
@@ -312,7 +311,7 @@ export function formatStylesAsText(styles: StylesResult): string {
} }
lines.push('') lines.push('')
} }
if (inheritedRules.length > 0) { if (inheritedRules.length > 0) {
const byAncestor = new Map<string, StyleRule[]>() const byAncestor = new Map<string, StyleRule[]>()
for (const rule of inheritedRules) { for (const rule of inheritedRules) {
@@ -322,7 +321,7 @@ export function formatStylesAsText(styles: StylesResult): string {
} }
byAncestor.get(key)!.push(rule) byAncestor.get(key)!.push(rule)
} }
for (const [ancestor, rules] of byAncestor) { for (const [ancestor, rules] of byAncestor) {
lines.push(`Inherited from ${ancestor}:`) lines.push(`Inherited from ${ancestor}:`)
for (const rule of rules) { for (const rule of rules) {
@@ -339,6 +338,6 @@ export function formatStylesAsText(styles: StylesResult): string {
lines.push('') lines.push('')
} }
} }
return lines.join('\n') return lines.join('\n')
} }
+6 -6
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 {}
+106 -93
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,129 +273,134 @@ 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>({
return await new Promise<T>((resolve, reject) => { promise,
const timeoutId = setTimeout(() => { timeoutMs,
reject(new Error(errorMessage)) errorMessage,
}, timeoutMs) }: {
promise: Promise<T>
timeoutMs: number
errorMessage: string
}): Promise<T> {
return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
promise promise
.then((value) => { .then((value) => {
clearTimeout(timeoutId) clearTimeout(timeoutId)
resolve(value) resolve(value)
}) })
.catch((error) => { .catch((error) => {
clearTimeout(timeoutId) clearTimeout(timeoutId)
reject(error) reject(error)
}) })
}) })
} }
/** 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) {
try { try {
return JSON.parse(str) return JSON.parse(str)
} catch { } catch {
return str return str
} }
} }
/** /**
* Safely close a browser connected via connectOverCDP. * Safely close a browser connected via connectOverCDP.
* *
* Playwright's CRConnection uses async message handling (messageWrap) that can cause * Playwright's CRConnection uses async message handling (messageWrap) that can cause
* a race condition where _onClose() runs before all pending _onMessage() handlers complete. * a race condition where _onClose() runs before all pending _onMessage() handlers complete.
* This results in "Assertion error" from crConnection.js when a CDP response arrives * This results in "Assertion error" from crConnection.js when a CDP response arrives
* after callbacks were cleared by dispose(). * after callbacks were cleared by dispose().
* *
* This helper waits for the message queue to drain before closing, avoiding the race. * This helper waits for the message queue to drain before closing, avoiding the race.
* *
* @param browser - Browser instance from chromium.connectOverCDP() * @param browser - Browser instance from chromium.connectOverCDP()
* @param drainDelayMs - Time to wait for pending messages to be processed (default: 50ms) * @param drainDelayMs - Time to wait for pending messages to be processed (default: 50ms)
*/ */
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()
} }
export type SimpleServer = { export type SimpleServer = {
baseUrl: string baseUrl: string
close: () => Promise<void> close: () => Promise<void>
} }
/** Minimal local HTTP server for tests that need cross-origin iframes or custom routes */ /** Minimal local HTTP server for tests that need cross-origin iframes or custom routes */
export async function createSimpleServer({ routes }: { routes: Record<string, string> }): Promise<SimpleServer> { export async function createSimpleServer({ routes }: { routes: Record<string, string> }): Promise<SimpleServer> {
const openSockets: Set<net.Socket> = new Set() const openSockets: Set<net.Socket> = new Set()
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const url = req.url || '/' const url = req.url || '/'
const body = routes[url] const body = routes[url]
if (!body) { if (!body) {
res.writeHead(404, { 'Content-Type': 'text/plain' }) res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('not found') res.end('not found')
return return
}
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(body)
})
server.on('connection', (socket) => {
openSockets.add(socket)
socket.on('close', () => {
openSockets.delete(socket)
})
})
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
} }
res.writeHead(200, { 'Content-Type': 'text/html' }) resolve()
res.end(body) })
}) })
throw new Error('Failed to start test server')
}
server.on('connection', (socket) => { return {
openSockets.add(socket) baseUrl: `http://127.0.0.1:${address.port}`,
socket.on('close', () => { close: async () => {
openSockets.delete(socket) for (const socket of openSockets) {
socket.destroy()
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
}) })
}) })
},
await new Promise<void>((resolve) => { }
server.listen(0, '127.0.0.1', () => {
resolve()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
throw new Error('Failed to start test server')
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => {
for (const socket of openSockets) {
socket.destroy()
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
},
}
} }
+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[]
+236 -235
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'
@@ -8,271 +7,273 @@ import { killPortProcess } from '../src/kill-port.js'
const TEST_PORT = 19999 const TEST_PORT = 19999
async function killProcessOnPort(port: number): Promise<void> { async function killProcessOnPort(port: number): Promise<void> {
try { try {
await killPortProcess({ port }) await killPortProcess({ port })
} catch (err) { } catch (err) {
// Ignore if no process is running // Ignore if no process is running
} }
} }
describe('Security Tests', () => { describe('Security Tests', () => {
let server: any = null let server: any = null
afterEach(async () => { afterEach(async () => {
if (server) { if (server) {
server.close() server.close()
server = null server = null
} }
await killProcessOnPort(TEST_PORT) await killProcessOnPort(TEST_PORT)
})
it('should enforce token authentication for /cdp endpoint', async () => {
const token = 'secret-token'
const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({
port: TEST_PORT,
token,
logger,
}) })
it('should enforce token authentication for /cdp endpoint', async () => { // Helper to try connecting
const token = 'secret-token' const tryConnect = (tokenParam?: string) => {
const logger = createFileLogger() return new Promise<void>((resolve, reject) => {
const url = `ws://127.0.0.1:${TEST_PORT}/cdp${tokenParam ? `?token=${tokenParam}` : ''}`
server = await startPlayWriterCDPRelayServer({ const ws = new WebSocket(url)
port: TEST_PORT,
token, ws.on('open', () => {
logger ws.close()
resolve()
}) })
// Helper to try connecting ws.on('error', (err) => {
const tryConnect = (tokenParam?: string) => { reject(err)
return new Promise<void>((resolve, reject) => {
const url = `ws://127.0.0.1:${TEST_PORT}/cdp${tokenParam ? `?token=${tokenParam}` : ''}`
const ws = new WebSocket(url)
ws.on('open', () => {
ws.close()
resolve()
})
ws.on('error', (err) => {
reject(err)
})
ws.on('unexpected-response', (req, res) => {
reject(new Error(`Unexpected response: ${res.statusCode}`))
ws.close()
})
})
}
// 1. No token -> Should fail
await expect(tryConnect()).rejects.toThrow(/Unexpected response: (400|401|403)/)
// 2. Wrong token -> Should fail
await expect(tryConnect('wrong-token')).rejects.toThrow(/Unexpected response: (400|401|403)/)
// 3. Correct token -> Should succeed
await expect(tryConnect(token)).resolves.not.toThrow()
})
it('should enforce localhost restrictions for /extension endpoint', async () => {
const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({
port: TEST_PORT,
logger
}) })
const tryConnectExtension = (origin?: string) => { ws.on('unexpected-response', (req, res) => {
return new Promise<void>((resolve, reject) => { reject(new Error(`Unexpected response: ${res.statusCode}`))
const url = `ws://127.0.0.1:${TEST_PORT}/extension` ws.close()
const options = origin ? { headers: { Origin: origin } } : {}
const ws = new WebSocket(url, options)
ws.on('open', () => {
ws.close()
resolve()
})
ws.on('error', (err) => {
reject(err)
})
ws.on('unexpected-response', (req, res) => {
reject(new Error(`Unexpected response: ${res.statusCode}`))
ws.close()
})
})
}
// 1. Valid chrome-extension origin -> Should succeed
// Use a valid extension ID from ALLOWED_EXTENSION_IDS in cdp-relay.ts
await expect(tryConnectExtension('chrome-extension://jfeammnjpkecdekppnclgkkffahnhfhe')).resolves.not.toThrow()
// 2. Invalid origin (e.g., http://evil.com) -> Should fail
await expect(tryConnectExtension('http://evil.com')).rejects.toThrow(/Unexpected response: (400|401|403)/)
// 3. No origin -> Should likely fail if strict checking is enabled, but typically extension connection requires specific origin handling.
// Based on implementation, usually it checks if it starts with chrome-extension://
await expect(tryConnectExtension()).rejects.toThrow(/Unexpected response: (400|401|403)/)
})
// =========================================================================
// Privileged HTTP route hardening (/cli/*, /recording/*)
//
// These tests verify that cross-origin browser requests are blocked even
// without CORS preflight (the "simple request" attack vector where POST +
// Content-Type: text/plain bypasses CORS entirely).
// =========================================================================
const httpRequest = ({ path, method = 'POST', headers = {} }: {
path: string
method?: string
headers?: Record<string, string>
}) => {
return fetch(`http://127.0.0.1:${TEST_PORT}${path}`, {
method,
headers,
body: method === 'POST' ? JSON.stringify({ sessionId: '1', code: 'true' }) : undefined,
}) })
})
} }
it('should block cross-origin browser requests to /cli/* via Sec-Fetch-Site', async () => { // 1. No token -> Should fail
const logger = createFileLogger() await expect(tryConnect()).rejects.toThrow(/Unexpected response: (400|401|403)/)
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
// cross-site browser request → 403 // 2. Wrong token -> Should fail
const crossSite = await httpRequest({ await expect(tryConnect('wrong-token')).rejects.toThrow(/Unexpected response: (400|401|403)/)
path: '/cli/execute',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' },
})
expect(crossSite.status).toBe(403)
// same-site but not same-origin → 403 // 3. Correct token -> Should succeed
const sameSite = await httpRequest({ await expect(tryConnect(token)).resolves.not.toThrow()
path: '/cli/execute', })
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' },
}) it('should enforce localhost restrictions for /extension endpoint', async () => {
expect(sameSite.status).toBe(403) const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({
port: TEST_PORT,
logger,
}) })
it('should block cross-origin browser requests to /recording/* via Sec-Fetch-Site', async () => { const tryConnectExtension = (origin?: string) => {
const logger = createFileLogger() return new Promise<void>((resolve, reject) => {
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) const url = `ws://127.0.0.1:${TEST_PORT}/extension`
const options = origin ? { headers: { Origin: origin } } : {}
const ws = new WebSocket(url, options)
const res = await httpRequest({ ws.on('open', () => {
path: '/recording/status', ws.close()
method: 'GET', resolve()
headers: { 'Sec-Fetch-Site': 'cross-site' },
}) })
expect(res.status).toBe(403)
ws.on('error', (err) => {
reject(err)
})
ws.on('unexpected-response', (req, res) => {
reject(new Error(`Unexpected response: ${res.statusCode}`))
ws.close()
})
})
}
// 1. Valid chrome-extension origin -> Should succeed
// Use a valid extension ID from ALLOWED_EXTENSION_IDS in cdp-relay.ts
await expect(tryConnectExtension('chrome-extension://jfeammnjpkecdekppnclgkkffahnhfhe')).resolves.not.toThrow()
// 2. Invalid origin (e.g., http://evil.com) -> Should fail
await expect(tryConnectExtension('http://evil.com')).rejects.toThrow(/Unexpected response: (400|401|403)/)
// 3. No origin -> Should likely fail if strict checking is enabled, but typically extension connection requires specific origin handling.
// Based on implementation, usually it checks if it starts with chrome-extension://
await expect(tryConnectExtension()).rejects.toThrow(/Unexpected response: (400|401|403)/)
})
// =========================================================================
// Privileged HTTP route hardening (/cli/*, /recording/*)
//
// These tests verify that cross-origin browser requests are blocked even
// without CORS preflight (the "simple request" attack vector where POST +
// Content-Type: text/plain bypasses CORS entirely).
// =========================================================================
const httpRequest = ({
path,
method = 'POST',
headers = {},
}: {
path: string
method?: string
headers?: Record<string, string>
}) => {
return fetch(`http://127.0.0.1:${TEST_PORT}${path}`, {
method,
headers,
body: method === 'POST' ? JSON.stringify({ sessionId: '1', code: 'true' }) : undefined,
}) })
}
it('should block POST with non-JSON Content-Type (text/plain bypass)', async () => { it('should block cross-origin browser requests to /cli/* via Sec-Fetch-Site', async () => {
const logger = createFileLogger() const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
// text/plain is the classic CORS preflight bypass // cross-site browser request → 403
const textPlain = await httpRequest({ const crossSite = await httpRequest({
path: '/cli/execute', path: '/cli/execute',
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' },
})
expect(textPlain.status).toBe(415)
// form-urlencoded is another simple request type
const formData = await httpRequest({
path: '/cli/execute',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(formData.status).toBe(415)
// missing Content-Type entirely
const noContentType = await httpRequest({
path: '/cli/execute',
headers: {},
})
expect(noContentType.status).toBe(415)
}) })
expect(crossSite.status).toBe(403)
it('should allow requests without Sec-Fetch-Site (Node.js/CLI clients)', async () => { // same-site but not same-origin → 403
const logger = createFileLogger() const sameSite = await httpRequest({
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) path: '/cli/execute',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' },
// Node.js clients don't send Sec-Fetch-Site, only Content-Type: application/json.
// Request should pass the middleware (will 404 because no session exists, which is fine).
const res = await httpRequest({
path: '/cli/execute',
headers: { 'Content-Type': 'application/json' },
})
// 404 = passed middleware, session just doesn't exist
expect(res.status).toBe(404)
}) })
expect(sameSite.status).toBe(403)
})
it('should allow same-origin browser requests', async () => { it('should block cross-origin browser requests to /recording/* via Sec-Fetch-Site', async () => {
const logger = createFileLogger() const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
const res = await httpRequest({ const res = await httpRequest({
path: '/cli/execute', path: '/recording/status',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' }, method: 'GET',
}) headers: { 'Sec-Fetch-Site': 'cross-site' },
// 404 = passed middleware
expect(res.status).toBe(404)
}) })
expect(res.status).toBe(403)
})
it('should enforce token on /cli/* and /recording/* when token mode is enabled', async () => { it('should block POST with non-JSON Content-Type (text/plain bypass)', async () => {
const secretToken = 'test-secret-token' const logger = createFileLogger()
const logger = createFileLogger() server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, token: secretToken, logger })
// No token → 401 // text/plain is the classic CORS preflight bypass
const noToken = await httpRequest({ const textPlain = await httpRequest({
path: '/cli/sessions', path: '/cli/execute',
method: 'GET', headers: { 'Content-Type': 'text/plain' },
headers: {},
})
expect(noToken.status).toBe(401)
// Wrong token → 401
const wrongToken = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: { 'Authorization': 'Bearer wrong-token' },
})
expect(wrongToken.status).toBe(401)
// Correct token via Authorization header → pass middleware
const bearerOk = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: { 'Authorization': `Bearer ${secretToken}` },
})
expect(bearerOk.status).toBe(200)
// Correct token via query param → pass middleware
const queryOk = await fetch(
`http://127.0.0.1:${TEST_PORT}/cli/sessions?token=${secretToken}`,
)
expect(queryOk.status).toBe(200)
// Token also enforced on /recording/*
const recordingNoToken = await httpRequest({
path: '/recording/status',
method: 'GET',
headers: {},
})
expect(recordingNoToken.status).toBe(401)
const recordingWithToken = await httpRequest({
path: '/recording/status',
method: 'GET',
headers: { 'Authorization': `Bearer ${secretToken}` },
})
expect(recordingWithToken.status).toBe(200)
}) })
expect(textPlain.status).toBe(415)
it('should not require token on /cli/* when no token is configured', async () => { // form-urlencoded is another simple request type
const logger = createFileLogger() const formData = await httpRequest({
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) path: '/cli/execute',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
// Without token mode, /cli/sessions should work with just proper headers
const res = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: {},
})
expect(res.status).toBe(200)
}) })
expect(formData.status).toBe(415)
// missing Content-Type entirely
const noContentType = await httpRequest({
path: '/cli/execute',
headers: {},
})
expect(noContentType.status).toBe(415)
})
it('should allow requests without Sec-Fetch-Site (Node.js/CLI clients)', async () => {
const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
// Node.js clients don't send Sec-Fetch-Site, only Content-Type: application/json.
// Request should pass the middleware (will 404 because no session exists, which is fine).
const res = await httpRequest({
path: '/cli/execute',
headers: { 'Content-Type': 'application/json' },
})
// 404 = passed middleware, session just doesn't exist
expect(res.status).toBe(404)
})
it('should allow same-origin browser requests', async () => {
const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
const res = await httpRequest({
path: '/cli/execute',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
})
// 404 = passed middleware
expect(res.status).toBe(404)
})
it('should enforce token on /cli/* and /recording/* when token mode is enabled', async () => {
const secretToken = 'test-secret-token'
const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, token: secretToken, logger })
// No token → 401
const noToken = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: {},
})
expect(noToken.status).toBe(401)
// Wrong token → 401
const wrongToken = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: { Authorization: 'Bearer wrong-token' },
})
expect(wrongToken.status).toBe(401)
// Correct token via Authorization header → pass middleware
const bearerOk = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: { Authorization: `Bearer ${secretToken}` },
})
expect(bearerOk.status).toBe(200)
// Correct token via query param → pass middleware
const queryOk = await fetch(`http://127.0.0.1:${TEST_PORT}/cli/sessions?token=${secretToken}`)
expect(queryOk.status).toBe(200)
// Token also enforced on /recording/*
const recordingNoToken = await httpRequest({
path: '/recording/status',
method: 'GET',
headers: {},
})
expect(recordingNoToken.status).toBe(401)
const recordingWithToken = await httpRequest({
path: '/recording/status',
method: 'GET',
headers: { Authorization: `Bearer ${secretToken}` },
})
expect(recordingWithToken.status).toBe(200)
})
it('should not require token on /cli/* when no token is configured', async () => {
const logger = createFileLogger()
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
// Without token mode, /cli/sessions should work with just proper headers
const res = await httpRequest({
path: '/cli/sessions',
method: 'GET',
headers: {},
})
expect(res.status).toBe(200)
})
}) })
+6 -6
View File
@@ -1,8 +1,8 @@
/** /**
* Vitest setup - handles Playwright CDP disconnect race condition. * Vitest setup - handles Playwright CDP disconnect race condition.
* *
* ROOT CAUSE (from Playwright source crConnection.ts:164): * ROOT CAUSE (from Playwright source crConnection.ts:164):
* *
* _onMessage(object: ProtocolResponse) { * _onMessage(object: ProtocolResponse) {
* if (object.id && this._callbacks.has(object.id)) { * if (object.id && this._callbacks.has(object.id)) {
* // Handle response with matching callback * // Handle response with matching callback
@@ -12,7 +12,7 @@
* assert(!object.id); // ← FAILS: expects event, got orphaned response * assert(!object.id); // ← FAILS: expects event, got orphaned response
* } * }
* } * }
* *
* WHY IT HAPPENS: * WHY IT HAPPENS:
* 1. Relay sends CDP response to Playwright * 1. Relay sends CDP response to Playwright
* 2. Playwright's messageWrap() schedules _onMessage for next task * 2. Playwright's messageWrap() schedules _onMessage for next task
@@ -20,11 +20,11 @@
* 4. _onClose() fires IMMEDIATELY and clears callbacks via dispose() * 4. _onClose() fires IMMEDIATELY and clears callbacks via dispose()
* 5. Scheduled _onMessage finally runs * 5. Scheduled _onMessage finally runs
* 6. Looks for callback NOT FOUND assertion fails * 6. Looks for callback NOT FOUND assertion fails
* *
* This is a race condition in Playwright's async message handling that we cannot * This is a race condition in Playwright's async message handling that we cannot
* fix without patching Playwright. The assertion error during disconnect is benign * fix without patching Playwright. The assertion error during disconnect is benign
* and expected - it just means a CDP response arrived after we stopped caring. * and expected - it just means a CDP response arrived after we stopped caring.
* *
* See: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/chromium/crConnection.ts * See: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/chromium/crConnection.ts
*/ */
@@ -37,7 +37,7 @@ process.on('unhandledRejection', (reason: any) => {
return return
} }
} }
// Re-throw other unhandled rejections to fail the test // Re-throw other unhandled rejections to fail the test
throw reason throw reason
}) })
+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
+57 -19
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,14 +38,15 @@ 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,
locator, locator,
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,12 +164,13 @@ type AriaSnapshotNode = {
``` ```
### AriaRef ### AriaRef
```typescript ```typescript
interface AriaRef { interface AriaRef {
role: string role: string
name: string name: string
ref: string // Full ref (testid or e1, e2, e3...) ref: string // Full ref (testid or e1, e2, e3...)
shortRef: string // Short ref (e1, e2, e3...) shortRef: string // Short ref (e1, e2, e3...)
backendNodeId?: Protocol.DOM.BackendNodeId backendNodeId?: Protocol.DOM.BackendNodeId
} }
``` ```
@@ -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',
]) ])
``` ```
@@ -214,7 +244,7 @@ const accessibilitySnapshot = async (options: {
wsUrl: getCdpUrl(this.cdpConfig), wsUrl: getCdpUrl(this.cdpConfig),
interactiveOnly, interactiveOnly,
}) })
// Store refs for refToLocator() function // Store refs for refToLocator() function
// Handle search filtering // Handle search filtering
// Handle diff mode // Handle diff mode
@@ -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
+100 -90
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
@@ -27,10 +28,10 @@ This document contains findings from exploring the Playwright source code to und
// Server-side (packages/playwright-core/src/server/frames.ts:1371) // Server-side (packages/playwright-core/src/server/frames.ts:1371)
async ariaSnapshot(progress: Progress, selector: string): Promise<string> { async ariaSnapshot(progress: Progress, selector: string): Promise<string> {
return await this._retryWithProgressIfNotConnected( return await this._retryWithProgressIfNotConnected(
progress, progress,
selector, selector,
true, true,
true, true,
handle => progress.race(handle.ariaSnapshot()) handle => progress.race(handle.ariaSnapshot())
); );
} }
@@ -38,7 +39,7 @@ async ariaSnapshot(progress: Progress, selector: string): Promise<string> {
// Element handle (packages/playwright-core/src/server/dom.ts:757) // Element handle (packages/playwright-core/src/server/dom.ts:757)
async ariaSnapshot(): Promise<string> { async ariaSnapshot(): Promise<string> {
return await this.evaluateInUtility( return await this.evaluateInUtility(
([injected, element]) => injected.ariaSnapshot(element, { mode: 'expect' }), ([injected, element]) => injected.ariaSnapshot(element, { mode: 'expect' }),
{} {}
); );
} }
@@ -48,7 +49,7 @@ ariaSnapshot(node: Node, options: AriaTreeOptions): string {
return this.incrementalAriaSnapshot(node, options).full; return this.incrementalAriaSnapshot(node, options).full;
} }
incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string }): incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string }):
{ full: string, incremental?: string, iframeRefs: string[] } { { full: string, incremental?: string, iframeRefs: string[] } {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
@@ -69,26 +70,27 @@ 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',
name: '', name: '',
children: [], // ⚠️ Empty children - no content from iframe children: [], // ⚠️ Empty children - no content from iframe
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,34 +117,37 @@ 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
### 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,56 +283,57 @@ 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 |
| Cross-browser support | ✅ Yes | Works in all browsers via DOM traversal | | Cross-browser support | ✅ Yes | Works in all browsers via DOM traversal |
| Frame-specific queries via CDP | ⚠️ Available | `frameId` parameter exists but unused | | Frame-specific queries via CDP | ⚠️ Available | `frameId` parameter exists but unused |
| 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
+28 -28
View File
@@ -1,30 +1,30 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"allowJs": false, "allowJs": false,
"composite": true, "composite": true,
"outDir": "${configDir}/dist", "outDir": "${configDir}/dist",
"rootDir": "${configDir}/src", "rootDir": "${configDir}/src",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"lib": [ "lib": [
"es2024", "es2024",
"es2022", "es2022",
"es2017", "es2017",
"es7", "es7",
"es6" "es6"
// "dom" // "dom"
], ],
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"strict": true, "strict": true,
"downlevelIteration": true, "downlevelIteration": true,
"esModuleInterop": true, "esModuleInterop": true,
"noImplicitAny": false, "noImplicitAny": false,
"useUnknownInCatchVariables": false, "useUnknownInCatchVariables": false,
"sourceMap": true, "sourceMap": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"skipLibCheck": true "skipLibCheck": true
}, },
"exclude": ["**/node_modules", "**/.*/"] "exclude": ["**/node_modules", "**/.*/"]
} }
+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
+225 -166
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,28 +304,31 @@ 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()'
# GOOD: use single quotes inside, or template strings # GOOD: use single quotes inside, or template strings
@@ -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.
@@ -404,10 +423,10 @@ Example output:
```md ```md
- banner: - banner:
- link "Home" [id="nav-home"] - link "Home" [id="nav-home"]
- navigation: - navigation:
- link "Docs" [data-testid="docs-link"] - link "Docs" [data-testid="docs-link"]
- link "Blog" role=link[name="Blog"] - link "Blog" role=link[name="Blog"]
``` ```
Each interactive line ends with a Playwright locator you can pass to `page.locator()`. Each interactive line ends with a Playwright locator you can pass to `page.locator()`.
@@ -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
@@ -487,9 +509,9 @@ page.locator('button').nth(2).click()
If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`: If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`:
```js ```js
await page.locator('button').first().click() // first match await page.locator('button').first().click() // first match
await page.locator('.item').last().click() // last match await page.locator('.item').last().click() // last match
await page.locator('li').nth(3).click() // 4th item (0-indexed) await page.locator('li').nth(3).click() // 4th item (0-indexed)
``` ```
## working with pages ## working with pages
@@ -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
``` ```
@@ -769,31 +805,32 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
```js ```js
// Start recording - outputPath must be specified upfront // Start recording - outputPath must be specified upfront
await startRecording({ await startRecording({
page, page,
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,21 +990,24 @@ 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
await page.mouse.click(450, 320, { button: 'right' }) // right click await page.mouse.click(450, 320, { button: 'right' }) // right click
await page.mouse.dblclick(450, 320) // double click await page.mouse.dblclick(450, 320) // double click
await page.mouse.click(450, 320, { clickCount: 3 }) // triple click await page.mouse.click(450, 320, { clickCount: 3 }) // triple click
await page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click await page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
``` ```
### hover ### hover
```js ```js
await page.locator('.tooltip-trigger').hover() // by locator (preferred) await page.locator('.tooltip-trigger').hover() // by locator (preferred)
await page.mouse.move(450, 320) // by coordinates await page.mouse.move(450, 320) // by coordinates
``` ```
### scroll ### scroll
@@ -960,17 +1017,19 @@ await page.mouse.move(450, 320) // by coordinates
await page.locator('#footer').scrollIntoViewIfNeeded() await page.locator('#footer').scrollIntoViewIfNeeded()
// By pixel (for canvas, maps, infinite scroll) // By pixel (for canvas, maps, infinite scroll)
await page.mouse.wheel(0, 300) // scroll down 300px await page.mouse.wheel(0, 300) // scroll down 300px
await page.mouse.wheel(0, -300) // scroll up await page.mouse.wheel(0, -300) // scroll up
await page.mouse.wheel(300, 0) // scroll right await page.mouse.wheel(300, 0) // scroll right
await page.mouse.wheel(-300, 0) // scroll left await page.mouse.wheel(-300, 0) // scroll left
// Scroll at a specific position // Scroll at a specific position
await page.mouse.move(450, 320) 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
@@ -982,7 +1041,7 @@ await page.locator('#item').dragTo(page.locator('#target'))
// By coordinates (for canvas, sliders, custom drag targets) // By coordinates (for canvas, sliders, custom drag targets)
await page.mouse.move(100, 200) await page.mouse.move(100, 200)
await page.mouse.down() await page.mouse.down()
await page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag await page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
await page.mouse.up() await page.mouse.up()
``` ```
@@ -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:
+333 -358
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,345 +45,321 @@ 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.
* *
* @param options - Configuration options * @param options - Configuration options
* @param options.cdp - A CDPSession instance for sending CDP commands (works with both * @param options.cdp - A CDPSession instance for sending CDP commands (works with both
* our CDPSession and Playwright's CDPSession) * our CDPSession and Playwright's CDPSession)
* *
* @example * @example
* ```ts * ```ts
* const cdp = await getCDPSessionForPage({ page, wsUrl }) * const cdp = await getCDPSessionForPage({ page, wsUrl })
* 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.
/** * Also resumes execution if the target was started with --inspect-brk.
* Enables the debugger and runtime domains. Called automatically by other methods. *
* Also resumes execution if the target was started with --inspect-brk. * @example
* * ```ts
* @example * await dbg.enable()
* ```ts * ```
* await dbg.enable() */
* ``` enable(): Promise<void>
*/ /**
enable(): Promise<void>; * Sets a breakpoint at a specified URL and line number.
/** * Use the URL from listScripts() to find available scripts.
* Sets a breakpoint at a specified URL and line number. *
* Use the URL from listScripts() to find available scripts. * @param options - Breakpoint options
* * @param options.file - Script URL (e.g. https://example.com/app.js)
* @param options - Breakpoint options * @param options.line - Line number (1-based)
* @param options.file - Script URL (e.g. https://example.com/app.js) * @param options.condition - Optional JS expression; only pause when it evaluates to true
* @param options.line - Line number (1-based) * @returns The breakpoint ID for later removal
* @param options.condition - Optional JS expression; only pause when it evaluates to true *
* @returns The breakpoint ID for later removal * @example
* * ```ts
* @example * const id = await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 })
* ```ts * // later:
* const id = await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 }) * await dbg.deleteBreakpoint({ breakpointId: id })
* // later: *
* await dbg.deleteBreakpoint({ breakpointId: id }) * // Conditional breakpoint - only pause when userId is 123
* * await dbg.setBreakpoint({
* // Conditional breakpoint - only pause when userId is 123 * file: 'https://example.com/app.js',
* await dbg.setBreakpoint({ * line: 42,
* file: 'https://example.com/app.js', * condition: 'userId === 123'
* line: 42, * })
* condition: 'userId === 123' * ```
* }) */
* ``` setBreakpoint({ file, line, condition }: { file: string; line: number; condition?: string }): Promise<string>
*/ /**
setBreakpoint({ file, line, condition }: { * Removes a breakpoint by its ID.
file: string; *
line: number; * @param options - Options
condition?: string; * @param options.breakpointId - The breakpoint ID returned by setBreakpoint
}): Promise<string>; *
/** * @example
* Removes a breakpoint by its ID. * ```ts
* * await dbg.deleteBreakpoint({ breakpointId: 'bp-123' })
* @param options - Options * ```
* @param options.breakpointId - The breakpoint ID returned by setBreakpoint */
* deleteBreakpoint({ breakpointId }: { breakpointId: string }): Promise<void>
* @example /**
* ```ts * Returns a list of all active breakpoints set by this debugger instance.
* await dbg.deleteBreakpoint({ breakpointId: 'bp-123' }) *
* ``` * @returns Array of breakpoint info objects
*/ *
deleteBreakpoint({ breakpointId }: { * @example
breakpointId: string; * ```ts
}): Promise<void>; * const breakpoints = dbg.listBreakpoints()
/** * // [{ id: 'bp-123', file: 'https://example.com/index.js', line: 42 }]
* Returns a list of all active breakpoints set by this debugger instance. * ```
* */
* @returns Array of breakpoint info objects listBreakpoints(): BreakpointInfo[]
* /**
* @example * Inspects local variables in the current call frame.
* ```ts * Must be paused at a breakpoint. String values over 1000 chars are truncated.
* const breakpoints = dbg.listBreakpoints() * Use evaluate() for full control over reading specific values.
* // [{ id: 'bp-123', file: 'https://example.com/index.js', line: 42 }] *
* ``` * @returns Record of variable names to values
*/ * @throws Error if not paused or no active call frames
listBreakpoints(): BreakpointInfo[]; *
/** * @example
* Inspects local variables in the current call frame. * ```ts
* Must be paused at a breakpoint. String values over 1000 chars are truncated. * const vars = await dbg.inspectLocalVariables()
* Use evaluate() for full control over reading specific values. * // { myVar: 'hello', count: 42 }
* * ```
* @returns Record of variable names to values */
* @throws Error if not paused or no active call frames inspectLocalVariables(): Promise<Record<string, unknown>>
* /**
* @example * Returns global lexical scope variable names.
* ```ts *
* const vars = await dbg.inspectLocalVariables() * @returns Array of global variable names
* // { myVar: 'hello', count: 42 } *
* ``` * @example
*/ * ```ts
inspectLocalVariables(): Promise<Record<string, unknown>>; * const globals = await dbg.inspectGlobalVariables()
/** * // ['myGlobal', 'CONFIG']
* Returns global lexical scope variable names. * ```
* */
* @returns Array of global variable names inspectGlobalVariables(): Promise<string[]>
* /**
* @example * Evaluates a JavaScript expression and returns the result.
* ```ts * When paused at a breakpoint, evaluates in the current stack frame scope,
* const globals = await dbg.inspectGlobalVariables() * allowing access to local variables. Otherwise evaluates in global scope.
* // ['myGlobal', 'CONFIG'] * Values are not truncated, use this for full control over reading specific variables.
* ``` *
*/ * @param options - Options
inspectGlobalVariables(): Promise<string[]>; * @param options.expression - JavaScript expression to evaluate
/** * @returns The result value
* Evaluates a JavaScript expression and returns the result. *
* When paused at a breakpoint, evaluates in the current stack frame scope, * @example
* allowing access to local variables. Otherwise evaluates in global scope. * ```ts
* Values are not truncated, use this for full control over reading specific variables. * // When paused, can access local variables:
* * const result = await dbg.evaluate({ expression: 'localVar + 1' })
* @param options - Options *
* @param options.expression - JavaScript expression to evaluate * // Read a large string that would be truncated in inspectLocalVariables:
* @returns The result value * const full = await dbg.evaluate({ expression: 'largeStringVar' })
* * ```
* @example */
* ```ts evaluate({ expression }: { expression: string }): Promise<EvaluateResult>
* // When paused, can access local variables: /**
* const result = await dbg.evaluate({ expression: 'localVar + 1' }) * Gets the current execution location when paused at a breakpoint.
* * Includes the call stack and surrounding source code for context.
* // Read a large string that would be truncated in inspectLocalVariables: *
* const full = await dbg.evaluate({ expression: 'largeStringVar' }) * @returns Location info with URL, line number, call stack, and source context
* ``` * @throws Error if debugger is not paused
*/ *
evaluate({ expression }: { * @example
expression: string; * ```ts
}): Promise<EvaluateResult>; * const location = await dbg.getLocation()
/** * console.log(location.url) // 'https://example.com/src/index.js'
* Gets the current execution location when paused at a breakpoint. * console.log(location.lineNumber) // 42
* Includes the call stack and surrounding source code for context. * console.log(location.callstack) // [{ functionName: 'handleRequest', ... }]
* * console.log(location.sourceContext)
* @returns Location info with URL, line number, call stack, and source context * // ' 40: function handleRequest(req) {
* @throws Error if debugger is not paused * // 41: const data = req.body
* * // > 42: processData(data)
* @example * // 43: }'
* ```ts * ```
* const location = await dbg.getLocation() */
* console.log(location.url) // 'https://example.com/src/index.js' getLocation(): Promise<LocationInfo>
* console.log(location.lineNumber) // 42 /**
* console.log(location.callstack) // [{ functionName: 'handleRequest', ... }] * Steps over to the next line of code, not entering function calls.
* console.log(location.sourceContext) *
* // ' 40: function handleRequest(req) { * @throws Error if debugger is not paused
* // 41: const data = req.body *
* // > 42: processData(data) * @example
* // 43: }' * ```ts
* ``` * await dbg.stepOver()
*/ * const newLocation = await dbg.getLocation()
getLocation(): Promise<LocationInfo>; * ```
/** */
* Steps over to the next line of code, not entering function calls. stepOver(): Promise<void>
* /**
* @throws Error if debugger is not paused * Steps into a function call on the current line.
* *
* @example * @throws Error if debugger is not paused
* ```ts *
* await dbg.stepOver() * @example
* const newLocation = await dbg.getLocation() * ```ts
* ``` * await dbg.stepInto()
*/ * const location = await dbg.getLocation()
stepOver(): Promise<void>; * // now inside the called function
/** * ```
* Steps into a function call on the current line. */
* stepInto(): Promise<void>
* @throws Error if debugger is not paused /**
* * Steps out of the current function, returning to the caller.
* @example *
* ```ts * @throws Error if debugger is not paused
* await dbg.stepInto() *
* const location = await dbg.getLocation() * @example
* // now inside the called function * ```ts
* ``` * await dbg.stepOut()
*/ * const location = await dbg.getLocation()
stepInto(): Promise<void>; * // back in the calling function
/** * ```
* Steps out of the current function, returning to the caller. */
* stepOut(): Promise<void>
* @throws Error if debugger is not paused /**
* * Resumes code execution until the next breakpoint or completion.
* @example *
* ```ts * @throws Error if debugger is not paused
* await dbg.stepOut() *
* const location = await dbg.getLocation() * @example
* // back in the calling function * ```ts
* ``` * await dbg.resume()
*/ * // execution continues
stepOut(): Promise<void>; * ```
/** */
* Resumes code execution until the next breakpoint or completion. resume(): Promise<void>
* /**
* @throws Error if debugger is not paused * Returns whether the debugger is currently paused at a breakpoint.
* *
* @example * @returns true if paused, false otherwise
* ```ts *
* await dbg.resume() * @example
* // execution continues * ```ts
* ``` * if (dbg.isPaused()) {
*/ * const vars = await dbg.inspectLocalVariables()
resume(): Promise<void>; * }
/** * ```
* Returns whether the debugger is currently paused at a breakpoint. */
* isPaused(): boolean
* @returns true if paused, false otherwise /**
* * Configures the debugger to pause on exceptions.
* @example *
* ```ts * @param options - Options
* if (dbg.isPaused()) { * @param options.state - When to pause: 'none' (never), 'uncaught' (only uncaught), or 'all' (all exceptions)
* const vars = await dbg.inspectLocalVariables() *
* } * @example
* ``` * ```ts
*/ * // Pause only on uncaught exceptions
isPaused(): boolean; * await dbg.setPauseOnExceptions({ state: 'uncaught' })
/** *
* Configures the debugger to pause on exceptions. * // Pause on all exceptions (caught and uncaught)
* * await dbg.setPauseOnExceptions({ state: 'all' })
* @param options - Options *
* @param options.state - When to pause: 'none' (never), 'uncaught' (only uncaught), or 'all' (all exceptions) * // Disable pausing on exceptions
* * await dbg.setPauseOnExceptions({ state: 'none' })
* @example * ```
* ```ts */
* // Pause only on uncaught exceptions setPauseOnExceptions({ state }: { state: 'none' | 'uncaught' | 'all' }): Promise<void>
* await dbg.setPauseOnExceptions({ state: 'uncaught' }) /**
* * Lists available scripts where breakpoints can be set.
* // Pause on all exceptions (caught and uncaught) * Automatically enables the debugger if not already enabled.
* await dbg.setPauseOnExceptions({ state: 'all' }) *
* * @param options - Options
* // Disable pausing on exceptions * @param options.search - Optional string to filter scripts by URL (case-insensitive)
* await dbg.setPauseOnExceptions({ state: 'none' }) * @returns Array of up to 20 matching scripts with scriptId and url
* ``` *
*/ * @example
setPauseOnExceptions({ state }: { * ```ts
state: 'none' | 'uncaught' | 'all'; * // List all scripts
}): Promise<void>; * const scripts = await dbg.listScripts()
/** * // [{ scriptId: '1', url: 'https://example.com/app.js' }, ...]
* Lists available scripts where breakpoints can be set. *
* Automatically enables the debugger if not already enabled. * // Search for specific files
* * const handlers = await dbg.listScripts({ search: 'handler' })
* @param options - Options * // [{ scriptId: '5', url: 'https://example.com/handlers.js' }]
* @param options.search - Optional string to filter scripts by URL (case-insensitive) * ```
* @returns Array of up to 20 matching scripts with scriptId and url */
* listScripts({ search }?: { search?: string }): Promise<ScriptInfo[]>
* @example setXHRBreakpoint({ url }: { url: string }): Promise<void>
* ```ts removeXHRBreakpoint({ url }: { url: string }): Promise<void>
* // List all scripts listXHRBreakpoints(): string[]
* const scripts = await dbg.listScripts() /**
* // [{ scriptId: '1', url: 'https://example.com/app.js' }, ...] * Sets regex patterns for scripts to blackbox (skip when stepping).
* * Blackboxed scripts are hidden from the call stack and stepped over automatically.
* // Search for specific files * Useful for ignoring framework/library code during debugging.
* const handlers = await dbg.listScripts({ search: 'handler' }) *
* // [{ scriptId: '5', url: 'https://example.com/handlers.js' }] * @param options - Options
* ``` * @param options.patterns - Array of regex patterns to match script URLs
*/ *
listScripts({ search }?: { * @example
search?: string; * ```ts
}): Promise<ScriptInfo[]>; * // Skip all node_modules
setXHRBreakpoint({ url }: { * await dbg.setBlackboxPatterns({ patterns: ['node_modules'] })
url: string; *
}): Promise<void>; * // Skip React and other frameworks
removeXHRBreakpoint({ url }: { * await dbg.setBlackboxPatterns({
url: string; * patterns: [
}): Promise<void>; * 'node_modules/react',
listXHRBreakpoints(): string[]; * 'node_modules/react-dom',
/** * 'node_modules/next',
* Sets regex patterns for scripts to blackbox (skip when stepping). * 'webpack://',
* Blackboxed scripts are hidden from the call stack and stepped over automatically. * ]
* Useful for ignoring framework/library code during debugging. * })
* *
* @param options - Options * // Skip all third-party scripts
* @param options.patterns - Array of regex patterns to match script URLs * await dbg.setBlackboxPatterns({ patterns: ['^https://cdn\\.'] })
* *
* @example * // Clear all blackbox patterns
* ```ts * await dbg.setBlackboxPatterns({ patterns: [] })
* // Skip all node_modules * ```
* await dbg.setBlackboxPatterns({ patterns: ['node_modules'] }) */
* setBlackboxPatterns({ patterns }: { patterns: string[] }): Promise<void>
* // Skip React and other frameworks /**
* await dbg.setBlackboxPatterns({ * Adds a single regex pattern to the blackbox list.
* patterns: [ *
* 'node_modules/react', * @param options - Options
* 'node_modules/react-dom', * @param options.pattern - Regex pattern to match script URLs
* 'node_modules/next', *
* 'webpack://', * @example
* ] * ```ts
* }) * await dbg.addBlackboxPattern({ pattern: 'node_modules/lodash' })
* * await dbg.addBlackboxPattern({ pattern: 'node_modules/axios' })
* // Skip all third-party scripts * ```
* await dbg.setBlackboxPatterns({ patterns: ['^https://cdn\\.'] }) */
* addBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
* // Clear all blackbox patterns /**
* await dbg.setBlackboxPatterns({ patterns: [] }) * Removes a pattern from the blackbox list.
* ``` *
*/ * @param options - Options
setBlackboxPatterns({ patterns }: { * @param options.pattern - The exact pattern string to remove
patterns: string[]; */
}): Promise<void>; removeBlackboxPattern({ pattern }: { pattern: string }): Promise<void>
/** /**
* Adds a single regex pattern to the blackbox list. * Returns the current list of blackbox patterns.
* */
* @param options - Options listBlackboxPatterns(): string[]
* @param options.pattern - Regex pattern to match script URLs private truncateValue
* private formatPropertyValue
* @example private processRemoteObject
* ```ts
* await dbg.addBlackboxPattern({ pattern: 'node_modules/lodash' })
* await dbg.addBlackboxPattern({ pattern: 'node_modules/axios' })
* ```
*/
addBlackboxPattern({ pattern }: {
pattern: string;
}): Promise<void>;
/**
* Removes a pattern from the blackbox list.
*
* @param options - Options
* @param options.pattern - The exact pattern string to remove
*/
removeBlackboxPattern({ pattern }: {
pattern: string;
}): Promise<void>;
/**
* Returns the current list of blackbox patterns.
*/
listBlackboxPatterns(): string[];
private truncateValue;
private formatPropertyValue;
private processRemoteObject;
} }
``` ````
## Examples ## Examples
@@ -454,5 +430,4 @@ async function cleanupBreakpoints() {
} }
export { listScriptsAndSetBreakpoint, inspectWhenPaused, stepThroughCode, cleanupBreakpoints } export { listScriptsAndSetBreakpoint, inspectWhenPaused, stepThroughCode, cleanupBreakpoints }
```
```
+171 -172
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,165 +49,155 @@ 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.
/** * Scripts are collected from Debugger.scriptParsed events.
* Enables the editor. Must be called before other methods. * Reload the page after enabling to capture all scripts.
* Scripts are collected from Debugger.scriptParsed events. */
* Reload the page after enabling to capture all scripts. enable(): Promise<void>
*/ private getIdByUrl
enable(): Promise<void>; /**
private getIdByUrl; * Lists available script and stylesheet URLs. Use pattern to filter by regex.
/** * Automatically enables the editor if not already enabled.
* Lists available script and stylesheet URLs. Use pattern to filter by regex. *
* Automatically enables the editor if not already enabled. * @param options - Options
* * @param options.pattern - Optional regex to filter URLs
* @param options - Options * @returns Array of URLs
* @param options.pattern - Optional regex to filter URLs *
* @returns Array of URLs * @example
* * ```ts
* @example * // List all scripts and stylesheets
* ```ts * const urls = await editor.list()
* // List all scripts and stylesheets *
* const urls = await editor.list() * // List only JS files
* * const jsFiles = await editor.list({ pattern: /\.js/ })
* // List only JS files *
* const jsFiles = await editor.list({ pattern: /\.js/ }) * // List only CSS files
* * const cssFiles = await editor.list({ pattern: /\.css/ })
* // List only CSS files *
* const cssFiles = await editor.list({ pattern: /\.css/ }) * // Search for specific scripts
* * const appScripts = await editor.list({ pattern: /app/ })
* // Search for specific scripts * ```
* const appScripts = await editor.list({ pattern: /app/ }) */
* ``` list({ pattern }?: { pattern?: RegExp }): Promise<string[]>
*/ /**
list({ pattern }?: { * Reads a script or stylesheet's source code by URL.
pattern?: RegExp; * Returns line-numbered content like Claude Code's Read tool.
}): Promise<string[]>; * For inline scripts, use the `inline://` URL from list() or grep().
/** *
* Reads a script or stylesheet's source code by URL. * @param options - Options
* Returns line-numbered content like Claude Code's Read tool. * @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* For inline scripts, use the `inline://` URL from list() or grep(). * @param options.offset - Line number to start from (0-based, default 0)
* * @param options.limit - Number of lines to return (default 2000)
* @param options - Options * @returns Content with line numbers, total lines, and range info
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs) *
* @param options.offset - Line number to start from (0-based, default 0) * @example
* @param options.limit - Number of lines to return (default 2000) * ```ts
* @returns Content with line numbers, total lines, and range info * // Read by URL
* * const { content, totalLines } = await editor.read({
* @example * url: 'https://example.com/app.js'
* ```ts * })
* // Read by URL *
* const { content, totalLines } = await editor.read({ * // Read a CSS file
* url: 'https://example.com/app.js' * const { content } = await editor.read({ url: 'https://example.com/styles.css' })
* }) *
* * // Read lines 100-200
* // Read a CSS file * const { content } = await editor.read({
* const { content } = await editor.read({ url: 'https://example.com/styles.css' }) * url: 'https://example.com/app.js',
* * offset: 100,
* // Read lines 100-200 * limit: 100
* const { content } = await editor.read({ * })
* url: 'https://example.com/app.js', * ```
* offset: 100, */
* limit: 100 read({ url, offset, limit }: { url: string; offset?: number; limit?: number }): Promise<ReadResult>
* }) private getSource
* ``` /**
*/ * Edits a script or stylesheet by replacing oldString with newString.
read({ url, offset, limit }: { * Like Claude Code's Edit tool - performs exact string replacement.
url: string; *
offset?: number; * @param options - Options
limit?: number; * @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
}): Promise<ReadResult>; * @param options.oldString - Exact string to find and replace
private getSource; * @param options.newString - Replacement string
/** * @param options.dryRun - If true, validate without applying (default false)
* Edits a script or stylesheet by replacing oldString with newString. * @returns Result with success status
* Like Claude Code's Edit tool - performs exact string replacement. *
* * @example
* @param options - Options * ```ts
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs) * // Replace a string in JS
* @param options.oldString - Exact string to find and replace * await editor.edit({
* @param options.newString - Replacement string * url: 'https://example.com/app.js',
* @param options.dryRun - If true, validate without applying (default false) * oldString: 'const DEBUG = false',
* @returns Result with success status * newString: 'const DEBUG = true'
* * })
* @example *
* ```ts * // Edit CSS
* // Replace a string in JS * await editor.edit({
* await editor.edit({ * url: 'https://example.com/styles.css',
* url: 'https://example.com/app.js', * oldString: 'color: red',
* oldString: 'const DEBUG = false', * newString: 'color: blue'
* newString: 'const DEBUG = true' * })
* }) * ```
* */
* // Edit CSS edit({
* await editor.edit({ url,
* url: 'https://example.com/styles.css', oldString,
* oldString: 'color: red', newString,
* newString: 'color: blue' dryRun,
* }) }: {
* ``` url: string
*/ oldString: string
edit({ url, oldString, newString, dryRun, }: { newString: string
url: string; dryRun?: boolean
oldString: string; }): Promise<EditResult>
newString: string; private setSource
dryRun?: boolean; /**
}): Promise<EditResult>; * Searches for a regex across all scripts and stylesheets.
private setSource; * Like Claude Code's Grep tool - returns matching lines with context.
/** *
* Searches for a regex across all scripts and stylesheets. * @param options - Options
* Like Claude Code's Grep tool - returns matching lines with context. * @param options.regex - Regular expression to search for in file contents
* * @param options.pattern - Optional regex to filter which URLs to search
* @param options - Options * @returns Array of matches with url, line number, and line content
* @param options.regex - Regular expression to search for in file contents *
* @param options.pattern - Optional regex to filter which URLs to search * @example
* @returns Array of matches with url, line number, and line content * ```ts
* * // Search all scripts and stylesheets for "color"
* @example * const matches = await editor.grep({ regex: /color/ })
* ```ts *
* // Search all scripts and stylesheets for "color" * // Search only CSS files
* const matches = await editor.grep({ regex: /color/ }) * const matches = await editor.grep({
* * regex: /background-color/,
* // Search only CSS files * pattern: /\.css/
* const matches = await editor.grep({ * })
* regex: /background-color/, *
* pattern: /\.css/ * // Regex search for console methods in JS
* }) * const matches = await editor.grep({
* * regex: /console\.(log|error|warn)/,
* // Regex search for console methods in JS * pattern: /\.js/
* const matches = await editor.grep({ * })
* regex: /console\.(log|error|warn)/, * ```
* pattern: /\.js/ */
* }) grep({ regex, pattern }: { regex: RegExp; pattern?: RegExp }): Promise<SearchMatch[]>
* ``` /**
*/ * Writes entire content to a script or stylesheet, replacing all existing code.
grep({ regex, pattern }: { * Use with caution - prefer edit() for targeted changes.
regex: RegExp; *
pattern?: RegExp; * @param options - Options
}): Promise<SearchMatch[]>; * @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
/** * @param options.content - New content
* Writes entire content to a script or stylesheet, replacing all existing code. * @param options.dryRun - If true, validate without applying (default false, only works for JS)
* Use with caution - prefer edit() for targeted changes. */
* write({ url, content, dryRun }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult>
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/
write({ url, content, dryRun }: {
url: string;
content: string;
dryRun?: boolean;
}): Promise<EditResult>;
} }
``` ````
## 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,
}
```
+33 -23
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;
+238 -243
View File
@@ -1,294 +1,289 @@
@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";
:root { :root {
/* ===== shadcn/ui tokens ===== */ /* ===== shadcn/ui tokens ===== */
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823); --popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885); --primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375); --muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938); --muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375); --accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.637 0.237 25.331);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.871 0.006 286.286);
--ring: oklch(0.871 0.006 286.286);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.871 0.006 286.286);
--sidebar-primary: oklch(0.37 0.013 285.805);
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.871 0.006 286.286);
/* ===== Editorial design tokens ===== */
--font-primary:
'Inter var', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
--font-secondary: 'Newsreader', Georgia, 'Times New Roman', serif;
--font-code:
'JetBrainsMono NF Mono', 'JetBrains Mono', 'SF Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo,
Courier, monospace;
/* Bleed: code blocks & images extend beyond prose column.
44px = 8px div padding-left + 36px line-number width */
--bleed: 44px;
/* Spacing scale */
--spacing-xxs: 0.5rem;
--spacing-xs: 1rem;
--spacing-sm: 1.5rem;
--spacing-md: 2rem;
--spacing-lg: 2.5rem;
--spacing-xl: 3rem;
--spacing-xxl: 3.5rem;
/* Timing */
--duration-snappy: 220ms;
--ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1);
--duration-swift: 800ms;
--ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--duration-smooth: 300ms;
--ease-smooth: cubic-bezier(0.19, 1, 0.22, 1);
/* Colors — near-black, not pure black */
--text-primary: rgb(17, 17, 17);
--text-secondary: rgba(0, 0, 0, 0.4);
--text-tertiary: rgba(0, 0, 0, 0.25);
--text-muted: rgba(0, 0, 0, 0.5);
--text-hover: rgba(0, 0, 0, 0.7);
--bg: #fff;
--page-border: #e3e3e3;
--divider: rgb(242, 242, 242);
--code-bg: rgba(0, 0, 0, 0.02);
--code-line-nr: rgba(0, 0, 0, 0.3);
--selection-bg: rgba(0, 0, 0, 0.08);
/* Button / back button */
--btn-bg: #fff;
--btn-shadow:
rgba(0, 0, 0, 0.08) 0px 2px 8px, rgba(0, 0, 0, 0.04) 0px 4px 16px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
/* Link accent color (renamed from --accent to avoid shadcn conflict) */
--link-accent: #0969da;
/* P3 accent colors (renamed from --primary/--secondary to avoid shadcn conflict) */
--brand-primary: #3e9fff;
--brand-secondary: #f09637;
/* Overlay / glass */
--overlay-filter: blur(1rem);
--overlay-bg: hsla(0, 0%, 100%, 0.8);
--overlay-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04), 0 1.625rem 3.375rem rgba(0, 0, 0, 0.04), 0 1rem 2rem rgba(0, 0, 0, 0.03),
0 0.625rem 1rem rgba(0, 0, 0, 0.024), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016), 0 0 0.125rem rgba(0, 0, 0, 0.01);
/* Header fade gradient stops (white) */
--fade-0: rgb(255, 255, 255);
--fade-1: rgba(255, 255, 255, 0.737);
--fade-2: rgba(255, 255, 255, 0.541);
--fade-3: rgba(255, 255, 255, 0.382);
--fade-4: rgba(255, 255, 255, 0.278);
--fade-5: rgba(255, 255, 255, 0.194);
--fade-6: rgba(255, 255, 255, 0.126);
--fade-7: rgba(255, 255, 255, 0.075);
--fade-8: rgba(255, 255, 255, 0.042);
--fade-9: rgba(255, 255, 255, 0.021);
--fade-10: rgba(255, 255, 255, 0.008);
--fade-11: rgba(255, 255, 255, 0.002);
--fade-12: rgba(255, 255, 255, 0);
/* Dark mode overrides uses prefers-color-scheme media query
via @custom-variant dark above */
@variant dark {
/* shadcn/ui dark */
--background: oklch(0.21 0.006 285.885);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.637 0.237 25.331); --destructive: oklch(0.637 0.237 25.331);
--destructive-foreground: oklch(0.637 0.237 25.331); --destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.92 0.004 286.32); --border: oklch(0.274 0.006 286.033);
--input: oklch(0.871 0.006 286.286); --input: oklch(0.274 0.006 286.033);
--ring: oklch(0.871 0.006 286.286); --ring: oklch(0.442 0.017 285.786);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.645 0.246 16.439);
--radius: 0.625rem; --sidebar: #000;
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.871 0.006 286.286); --sidebar-foreground: oklch(0.871 0.006 286.286);
--sidebar-primary: oklch(0.37 0.013 285.805); --sidebar-primary: oklch(0.37 0.013 285.805);
--sidebar-primary-foreground: oklch(0.871 0.006 286.286); --sidebar-primary-foreground: oklch(0.871 0.006 286.286);
--sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.871 0.006 286.286); --sidebar-accent-foreground: oklch(0.871 0.006 286.286);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.274 0.006 286.033);
--sidebar-ring: oklch(0.871 0.006 286.286); --sidebar-ring: oklch(0.442 0.017 285.786);
/* ===== Editorial design tokens ===== */ /* Editorial dark */
--font-primary: "Inter var", "Inter", system-ui, -apple-system, --text-primary: rgba(255, 255, 255, 0.9);
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --text-secondary: rgba(255, 255, 255, 0.45);
--font-secondary: "Newsreader", Georgia, "Times New Roman", serif; --text-tertiary: rgba(255, 255, 255, 0.25);
--font-code: "JetBrainsMono NF Mono", "JetBrains Mono", "SF Mono", --text-muted: rgba(255, 255, 255, 0.35);
"SFMono-Regular", "Consolas", "Liberation Mono", Menlo, Courier, monospace; --text-hover: rgba(255, 255, 255, 0.7);
--bg: rgb(17, 17, 17);
--page-border: rgba(255, 255, 255, 0.1);
--divider: rgba(255, 255, 255, 0.06);
--code-bg: rgba(255, 255, 255, 0.05);
--code-line-nr: rgba(255, 255, 255, 0.3);
--selection-bg: rgba(255, 255, 255, 0.1);
/* Bleed: code blocks & images extend beyond prose column. --btn-bg: rgb(30, 30, 30);
44px = 8px div padding-left + 36px line-number width */ --btn-shadow:
--bleed: 44px; 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;
/* Spacing scale */ --link-accent: #58a6ff;
--spacing-xxs: 0.5rem; --brand-primary: #60a5fa;
--spacing-xs: 1rem; --brand-secondary: #f5a623;
--spacing-sm: 1.5rem;
--spacing-md: 2rem;
--spacing-lg: 2.5rem;
--spacing-xl: 3rem;
--spacing-xxl: 3.5rem;
/* Timing */ --overlay-bg: hsla(0, 0%, 7%, 0.8);
--duration-snappy: 220ms; --overlay-shadow:
--ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1); 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),
--duration-swift: 800ms; 0 0.625rem 1rem rgba(0, 0, 0, 0.15), 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
--ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275); 0 0.125rem 0.25rem rgba(0, 0, 0, 0.08), 0 0 0.125rem rgba(0, 0, 0, 0.05);
--duration-smooth: 300ms;
--ease-smooth: cubic-bezier(0.19, 1, 0.22, 1);
/* Colors — near-black, not pure black */ /* Header fade gradient stops (dark) */
--text-primary: rgb(17, 17, 17); --fade-0: rgb(17, 17, 17);
--text-secondary: rgba(0, 0, 0, 0.4); --fade-1: rgba(17, 17, 17, 0.737);
--text-tertiary: rgba(0, 0, 0, 0.25); --fade-2: rgba(17, 17, 17, 0.541);
--text-muted: rgba(0, 0, 0, 0.5); --fade-3: rgba(17, 17, 17, 0.382);
--text-hover: rgba(0, 0, 0, 0.7); --fade-4: rgba(17, 17, 17, 0.278);
--bg: #fff; --fade-5: rgba(17, 17, 17, 0.194);
--page-border: #e3e3e3; --fade-6: rgba(17, 17, 17, 0.126);
--divider: rgb(242, 242, 242); --fade-7: rgba(17, 17, 17, 0.075);
--code-bg: rgba(0, 0, 0, 0.02); --fade-8: rgba(17, 17, 17, 0.042);
--code-line-nr: rgba(0, 0, 0, 0.3); --fade-9: rgba(17, 17, 17, 0.021);
--selection-bg: rgba(0, 0, 0, 0.08); --fade-10: rgba(17, 17, 17, 0.008);
--fade-11: rgba(17, 17, 17, 0.002);
/* Button / back button */ --fade-12: rgba(17, 17, 17, 0);
--btn-bg: #fff; }
--btn-shadow: rgba(0, 0, 0, 0.08) 0px 2px 8px,
rgba(0, 0, 0, 0.04) 0px 4px 16px,
rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset;
/* Link accent color (renamed from --accent to avoid shadcn conflict) */
--link-accent: #0969da;
/* P3 accent colors (renamed from --primary/--secondary to avoid shadcn conflict) */
--brand-primary: #3e9fff;
--brand-secondary: #f09637;
/* Overlay / glass */
--overlay-filter: blur(1rem);
--overlay-bg: hsla(0, 0%, 100%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04),
0 1.625rem 3.375rem rgba(0, 0, 0, 0.04),
0 1rem 2rem rgba(0, 0, 0, 0.03),
0 0.625rem 1rem rgba(0, 0, 0, 0.024),
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016),
0 0 0.125rem rgba(0, 0, 0, 0.01);
/* Header fade gradient stops (white) */
--fade-0: rgb(255, 255, 255);
--fade-1: rgba(255, 255, 255, 0.737);
--fade-2: rgba(255, 255, 255, 0.541);
--fade-3: rgba(255, 255, 255, 0.382);
--fade-4: rgba(255, 255, 255, 0.278);
--fade-5: rgba(255, 255, 255, 0.194);
--fade-6: rgba(255, 255, 255, 0.126);
--fade-7: rgba(255, 255, 255, 0.075);
--fade-8: rgba(255, 255, 255, 0.042);
--fade-9: rgba(255, 255, 255, 0.021);
--fade-10: rgba(255, 255, 255, 0.008);
--fade-11: rgba(255, 255, 255, 0.002);
--fade-12: rgba(255, 255, 255, 0);
/* Dark mode overrides uses prefers-color-scheme media query
via @custom-variant dark above */
@variant dark {
/* shadcn/ui dark */
--background: oklch(0.21 0.006 285.885);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.637 0.237 25.331);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.274 0.006 286.033);
--input: oklch(0.274 0.006 286.033);
--ring: oklch(0.442 0.017 285.786);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #000;
--sidebar-foreground: oklch(0.871 0.006 286.286);
--sidebar-primary: oklch(0.37 0.013 285.805);
--sidebar-primary-foreground: oklch(0.871 0.006 286.286);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.871 0.006 286.286);
--sidebar-border: oklch(0.274 0.006 286.033);
--sidebar-ring: oklch(0.442 0.017 285.786);
/* Editorial dark */
--text-primary: rgba(255, 255, 255, 0.9);
--text-secondary: rgba(255, 255, 255, 0.45);
--text-tertiary: rgba(255, 255, 255, 0.25);
--text-muted: rgba(255, 255, 255, 0.35);
--text-hover: rgba(255, 255, 255, 0.7);
--bg: rgb(17, 17, 17);
--page-border: rgba(255, 255, 255, 0.1);
--divider: rgba(255, 255, 255, 0.06);
--code-bg: rgba(255, 255, 255, 0.05);
--code-line-nr: rgba(255, 255, 255, 0.3);
--selection-bg: rgba(255, 255, 255, 0.1);
--btn-bg: rgb(30, 30, 30);
--btn-shadow: rgba(255, 255, 255, 0.05) 0px 2px 8px,
rgba(255, 255, 255, 0.02) 0px 4px 16px,
rgba(255, 255, 255, 0.06) 0px 0px 0px 1px inset;
--link-accent: #58a6ff;
--brand-primary: #60a5fa;
--brand-secondary: #f5a623;
--overlay-bg: hsla(0, 0%, 7%, 0.8);
--overlay-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06),
0 1.625rem 3.375rem rgba(0, 0, 0, 0.3),
0 1rem 2rem rgba(0, 0, 0, 0.2),
0 0.625rem 1rem rgba(0, 0, 0, 0.15),
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.1),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.08),
0 0 0.125rem rgba(0, 0, 0, 0.05);
/* Header fade gradient stops (dark) */
--fade-0: rgb(17, 17, 17);
--fade-1: rgba(17, 17, 17, 0.737);
--fade-2: rgba(17, 17, 17, 0.541);
--fade-3: rgba(17, 17, 17, 0.382);
--fade-4: rgba(17, 17, 17, 0.278);
--fade-5: rgba(17, 17, 17, 0.194);
--fade-6: rgba(17, 17, 17, 0.126);
--fade-7: rgba(17, 17, 17, 0.075);
--fade-8: rgba(17, 17, 17, 0.042);
--fade-9: rgba(17, 17, 17, 0.021);
--fade-10: rgba(17, 17, 17, 0.008);
--fade-11: rgba(17, 17, 17, 0.002);
--fade-12: rgba(17, 17, 17, 0);
}
} }
/* P3 wide-gamut accent colors */ /* P3 wide-gamut accent colors */
@supports (color: color(display-p3 1 1 1)) { @supports (color: color(display-p3 1 1 1)) {
:root { :root {
--brand-primary: color(display-p3 0.243137 0.623529 1 / 1); --brand-primary: color(display-p3 0.243137 0.623529 1 / 1);
--brand-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1); --brand-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1);
} }
} }
@theme inline { @theme inline {
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* Custom scrollbar styles: shadcn-inspired */ /* Custom scrollbar styles: shadcn-inspired */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
background: transparent; background: transparent;
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: none; border: none;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
} }
@variant dark { @variant dark {
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
} }

Some files were not shown because too many files have changed in this diff Show More