format
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
playwright
|
||||||
|
pnpm-lock.yaml
|
||||||
|
claude-extension
|
||||||
|
opensrc
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"jsxSingleQuote": true,
|
"jsxSingleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
|
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ tests use these utilities from `test-utils.ts`:
|
|||||||
const testCtx = await setupTestContext({
|
const testCtx = await setupTestContext({
|
||||||
port: 19987,
|
port: 19987,
|
||||||
tempDirPrefix: 'pw-test-',
|
tempDirPrefix: 'pw-test-',
|
||||||
toggleExtension: true // creates initial page with extension enabled
|
toggleExtension: true, // creates initial page with extension enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
// get extension service worker to call extension functions
|
// get extension service worker to call extension functions
|
||||||
@@ -122,7 +122,7 @@ import { createMCPClient } from './mcp-client.js'
|
|||||||
const { client, cleanup } = await createMCPClient({ port: 19987 })
|
const { client, cleanup } = await createMCPClient({ port: 19987 })
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'execute',
|
name: 'execute',
|
||||||
arguments: { code: 'await page.goto("https://example.com")' }
|
arguments: { code: 'await page.goto("https://example.com")' },
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,10 +171,8 @@ when you do an any change, update relevant CHANGELOG.md files for each package.
|
|||||||
|
|
||||||
also bump package.json versions and IMPORTANTLY also the extension/manifest.json version!
|
also bump package.json versions and IMPORTANTLY also the extension/manifest.json version!
|
||||||
|
|
||||||
|
|
||||||
you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version`
|
you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version`
|
||||||
|
|
||||||
|
|
||||||
## debugging playwriter mcp issues
|
## debugging playwriter mcp issues
|
||||||
|
|
||||||
sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it
|
sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it
|
||||||
@@ -220,6 +218,7 @@ pnpm bootstrap
|
|||||||
```
|
```
|
||||||
|
|
||||||
this does:
|
this does:
|
||||||
|
|
||||||
1. `git submodule update --init` - init the playwright submodule
|
1. `git submodule update --init` - init the playwright submodule
|
||||||
2. `pnpm install` - install deps and link workspace packages
|
2. `pnpm install` - install deps and link workspace packages
|
||||||
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/`
|
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/`
|
||||||
@@ -240,16 +239,18 @@ upstream playwright bundles all dependencies into single files (zero runtime dep
|
|||||||
**1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps
|
**1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps
|
||||||
|
|
||||||
**2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
|
**2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// before (bundled)
|
// before (bundled)
|
||||||
export const ws = require('./utilsBundleImpl').ws;
|
export const ws = require('./utilsBundleImpl').ws
|
||||||
|
|
||||||
// after (direct)
|
// after (direct)
|
||||||
import wsLibrary from 'ws';
|
import wsLibrary from 'ws'
|
||||||
export const ws = wsLibrary;
|
export const ws = wsLibrary
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
|
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# transpile src/**/*.ts → lib/**/*.js (0.1s)
|
# transpile src/**/*.ts → lib/**/*.js (0.1s)
|
||||||
# copy third_party/lockfile.js, third_party/extract-zip.js
|
# copy third_party/lockfile.js, third_party/extract-zip.js
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -97,7 +97,7 @@ tests use these utilities from `test-utils.ts`:
|
|||||||
const testCtx = await setupTestContext({
|
const testCtx = await setupTestContext({
|
||||||
port: 19987,
|
port: 19987,
|
||||||
tempDirPrefix: 'pw-test-',
|
tempDirPrefix: 'pw-test-',
|
||||||
toggleExtension: true // creates initial page with extension enabled
|
toggleExtension: true, // creates initial page with extension enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
// get extension service worker to call extension functions
|
// get extension service worker to call extension functions
|
||||||
@@ -120,7 +120,7 @@ import { createMCPClient } from './mcp-client.js'
|
|||||||
const { client, cleanup } = await createMCPClient({ port: 19987 })
|
const { client, cleanup } = await createMCPClient({ port: 19987 })
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'execute',
|
name: 'execute',
|
||||||
arguments: { code: 'await page.goto("https://example.com")' }
|
arguments: { code: 'await page.goto("https://example.com")' },
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -169,10 +169,8 @@ when you do an any change, update relevant CHANGELOG.md files for each package.
|
|||||||
|
|
||||||
also bump package.json versions and IMPORTANTLY also the extension/manifest.json version!
|
also bump package.json versions and IMPORTANTLY also the extension/manifest.json version!
|
||||||
|
|
||||||
|
|
||||||
you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version`
|
you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version`
|
||||||
|
|
||||||
|
|
||||||
## debugging playwriter mcp issues
|
## debugging playwriter mcp issues
|
||||||
|
|
||||||
sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it
|
sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it
|
||||||
@@ -218,6 +216,7 @@ pnpm bootstrap
|
|||||||
```
|
```
|
||||||
|
|
||||||
this does:
|
this does:
|
||||||
|
|
||||||
1. `git submodule update --init` - init the playwright submodule
|
1. `git submodule update --init` - init the playwright submodule
|
||||||
2. `pnpm install` - install deps and link workspace packages
|
2. `pnpm install` - install deps and link workspace packages
|
||||||
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/`
|
3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/`
|
||||||
@@ -238,16 +237,18 @@ upstream playwright bundles all dependencies into single files (zero runtime dep
|
|||||||
**1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps
|
**1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps
|
||||||
|
|
||||||
**2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
|
**2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// before (bundled)
|
// before (bundled)
|
||||||
export const ws = require('./utilsBundleImpl').ws;
|
export const ws = require('./utilsBundleImpl').ws
|
||||||
|
|
||||||
// after (direct)
|
// after (direct)
|
||||||
import wsLibrary from 'ws';
|
import wsLibrary from 'ws'
|
||||||
export const ws = wsLibrary;
|
export const ws = wsLibrary
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
|
**3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# transpile src/**/*.ts → lib/**/*.js (0.1s)
|
# transpile src/**/*.ts → lib/**/*.js (0.1s)
|
||||||
# copy third_party/lockfile.js, third_party/extract-zip.js
|
# copy third_party/lockfile.js, third_party/extract-zip.js
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 iframe’s locator by URL:
|
- Grab the iframe’s 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/ }));"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-76
@@ -32,7 +32,6 @@ type ExtensionIdentity = {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
@@ -126,10 +125,12 @@ function flushRecordingChunkBuffer(ws: WebSocket): void {
|
|||||||
const { tabId, data, final } = chunk
|
const { tabId, data, final } = chunk
|
||||||
|
|
||||||
// Send metadata message first
|
// Send metadata message first
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
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) {
|
||||||
@@ -168,7 +169,7 @@ class ConnectionManager {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reject(new Error('Connection timeout (global)'))
|
reject(new Error('Connection timeout (global)'))
|
||||||
}, GLOBAL_TIMEOUT_MS)
|
}, GLOBAL_TIMEOUT_MS)
|
||||||
})
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -417,7 +418,9 @@ class ConnectionManager {
|
|||||||
const mem = performance.memory
|
const mem = performance.memory
|
||||||
if (mem) {
|
if (mem) {
|
||||||
const formatMB = (b: number) => (b / 1024 / 1024).toFixed(2) + 'MB'
|
const formatMB = (b: number) => (b / 1024 / 1024).toFixed(2) + 'MB'
|
||||||
logger.warn(`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`)
|
logger.warn(
|
||||||
|
`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
logger.warn(`DISCONNECT: WS closed code=${code} reason=${reason || 'none'} stack=${getCallStack()}`)
|
logger.warn(`DISCONNECT: WS closed code=${code} reason=${reason || 'none'} stack=${getCallStack()}`)
|
||||||
@@ -484,12 +487,21 @@ class ConnectionManager {
|
|||||||
// Slot is free when: no extension connected, OR connected but no active tabs.
|
// Slot is free when: no extension connected, OR connected but no active tabs.
|
||||||
if (store.getState().connectionState === 'extension-replaced') {
|
if (store.getState().connectionState === 'extension-replaced') {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
|
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, {
|
||||||
const data = await response.json() as { connected: boolean; activeTargets: number }
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
})
|
||||||
|
const data = (await response.json()) as { connected: boolean; activeTargets: number }
|
||||||
const slotAvailable = !data.connected || data.activeTargets === 0
|
const slotAvailable = !data.connected || data.activeTargets === 0
|
||||||
if (slotAvailable) {
|
if (slotAvailable) {
|
||||||
store.setState({ connectionState: 'idle', errorText: undefined })
|
store.setState({ connectionState: 'idle', errorText: undefined })
|
||||||
logger.debug('Extension slot is free (connected:', data.connected, 'activeTargets:', data.activeTargets, '), cleared error state')
|
logger.debug(
|
||||||
|
'Extension slot is free (connected:',
|
||||||
|
data.connected,
|
||||||
|
'activeTargets:',
|
||||||
|
data.activeTargets,
|
||||||
|
'), cleared error state',
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...')
|
logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...')
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Vendored
+71
-190
@@ -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
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+20
-10
@@ -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
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ function updateTabRecordingState(tabId: number, isRecording: boolean): void {
|
|||||||
export async function handleStartRecording(params: StartRecordingParams): Promise<StartRecordingResult> {
|
export async function handleStartRecording(params: StartRecordingParams): Promise<StartRecordingResult> {
|
||||||
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' }
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeRecordings.has(tabId)) {
|
if (activeRecordings.has(tabId)) {
|
||||||
@@ -133,7 +136,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis
|
|||||||
logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...')
|
logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...')
|
||||||
|
|
||||||
// Send message to offscreen document to start recording
|
// Send message to offscreen document to start recording
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = (await chrome.runtime.sendMessage({
|
||||||
action: 'startRecording',
|
action: 'startRecording',
|
||||||
tabId,
|
tabId,
|
||||||
streamId,
|
streamId,
|
||||||
@@ -141,7 +144,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis
|
|||||||
videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000,
|
videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000,
|
||||||
audioBitsPerSecond: params.audioBitsPerSecond ?? 128000,
|
audioBitsPerSecond: params.audioBitsPerSecond ?? 128000,
|
||||||
audio: params.audio ?? false,
|
audio: params.audio ?? false,
|
||||||
}) as OffscreenStartRecordingResult
|
})) as OffscreenStartRecordingResult
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error || 'Failed to start recording in offscreen document' }
|
return { success: false, error: result.error || 'Failed to start recording in offscreen document' }
|
||||||
@@ -179,16 +182,16 @@ export async function handleStopRecording(params: StopRecordingParams): Promise<
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Send message to offscreen document to stop recording - include tabId for concurrent support
|
// Send message to offscreen document to stop recording - include tabId for concurrent support
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = (await chrome.runtime.sendMessage({
|
||||||
action: 'stopRecording',
|
action: 'stopRecording',
|
||||||
tabId,
|
tabId,
|
||||||
}) as OffscreenStopRecordingResult
|
})) as OffscreenStopRecordingResult
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error || 'Failed to stop recording in offscreen document' }
|
return { success: false, error: result.error || 'Failed to stop recording in offscreen document' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = result.duration || (Date.now() - recording.startedAt)
|
const duration = result.duration || Date.now() - recording.startedAt
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
activeRecordings.delete(tabId)
|
activeRecordings.delete(tabId)
|
||||||
@@ -216,10 +219,10 @@ export async function handleIsRecording(params: IsRecordingParams): Promise<IsRe
|
|||||||
|
|
||||||
// Check with offscreen document for actual recording state - include tabId for concurrent support
|
// Check with offscreen document for actual recording state - include tabId for concurrent support
|
||||||
try {
|
try {
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = (await chrome.runtime.sendMessage({
|
||||||
action: 'isRecording',
|
action: 'isRecording',
|
||||||
tabId,
|
tabId,
|
||||||
}) as OffscreenIsRecordingResult
|
})) as OffscreenIsRecordingResult
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRecording: result.isRecording,
|
isRecording: result.isRecording,
|
||||||
|
|||||||
+23
-24
@@ -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
@@ -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:*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ function writeToDestinations(filename: string, content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanTypes(typesContent: string): string {
|
function cleanTypes(typesContent: string): string {
|
||||||
return typesContent
|
return typesContent.replace(/\/\/# sourceMappingURL=.*$/gm, '').trim()
|
||||||
.replace(/\/\/# sourceMappingURL=.*$/gm, '')
|
|
||||||
.trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDebuggerApi() {
|
function buildDebuggerApi() {
|
||||||
@@ -163,7 +161,14 @@ function stripCliSectionsFromSkill(skillContent: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct markdown from tokens
|
// Reconstruct markdown from tokens
|
||||||
return filteredTokens.map((token) => { return token.raw }).join('').trim() + '\n'
|
return (
|
||||||
|
filteredTokens
|
||||||
|
.map((token) => {
|
||||||
|
return token.raw
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.trim() + '\n'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPromptFromSkill() {
|
function buildPromptFromSkill() {
|
||||||
@@ -245,16 +250,12 @@ function buildWellKnownSkills() {
|
|||||||
{
|
{
|
||||||
name: frontmatter.name || 'playwriter',
|
name: frontmatter.name || 'playwriter',
|
||||||
description: frontmatter.description || '',
|
description: frontmatter.description || '',
|
||||||
files: ['SKILL.md']
|
files: ['SKILL.md'],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(path.join(wellKnownDir, 'index.json'), JSON.stringify(indexJson, null, 2) + '\n', 'utf-8')
|
||||||
path.join(wellKnownDir, 'index.json'),
|
|
||||||
JSON.stringify(indexJson, null, 2) + '\n',
|
|
||||||
'utf-8'
|
|
||||||
)
|
|
||||||
console.log('Generated website/public/.well-known/skills/index.json')
|
console.log('Generated website/public/.well-known/skills/index.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+187
-96
@@ -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,7 +831,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
|||||||
interactiveOnly?: boolean
|
interactiveOnly?: boolean
|
||||||
cdp?: ICDPSession
|
cdp?: ICDPSession
|
||||||
}): Promise<AriaSnapshotResult> {
|
}): Promise<AriaSnapshotResult> {
|
||||||
const session = cdp || await getCDPSessionForPage({ page })
|
const session = cdp || (await getCDPSessionForPage({ page }))
|
||||||
|
|
||||||
// Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame())
|
// Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame())
|
||||||
// is a scoping helper without CDP access. We need the real Frame from page.frames()
|
// is a scoping helper without CDP access. We need the real Frame from page.frames()
|
||||||
@@ -807,16 +844,16 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
|||||||
const frameId = resolvedFrame?.frameId() ?? null
|
const frameId = resolvedFrame?.frameId() ?? null
|
||||||
|
|
||||||
if (frameId) {
|
if (frameId) {
|
||||||
const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse
|
const { targetInfos } = (await session.send('Target.getTargets')) as Protocol.Target.GetTargetsResponse
|
||||||
const frameUrl = resolvedFrame!.url()
|
const frameUrl = resolvedFrame!.url()
|
||||||
const iframeTarget = targetInfos.find((t) => {
|
const iframeTarget = targetInfos.find((t) => {
|
||||||
return t.type === 'iframe' && t.url === frameUrl
|
return t.type === 'iframe' && t.url === frameUrl
|
||||||
})
|
})
|
||||||
if (iframeTarget) {
|
if (iframeTarget) {
|
||||||
const { sessionId } = await session.send('Target.attachToTarget', {
|
const { sessionId } = (await session.send('Target.attachToTarget', {
|
||||||
targetId: iframeTarget.targetId,
|
targetId: iframeTarget.targetId,
|
||||||
flatten: true,
|
flatten: true,
|
||||||
}) as Protocol.Target.AttachToTargetResponse
|
})) as Protocol.Target.AttachToTargetResponse
|
||||||
oopifSessionId = sessionId
|
oopifSessionId = sessionId
|
||||||
await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId)
|
await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId)
|
||||||
}
|
}
|
||||||
@@ -831,13 +868,20 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (scopeLocator) {
|
if (scopeLocator) {
|
||||||
await scopeLocator.evaluate((element, data) => {
|
await scopeLocator.evaluate(
|
||||||
element.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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
+684
-606
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -262,7 +262,10 @@ server.tool(
|
|||||||
const pagesCount = context.pages().length
|
const pagesCount = context.pages().length
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{ type: 'text', text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}` },
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract content using Readability
|
// Extract content using Readability
|
||||||
const result = await page.evaluate(() => {
|
const result = (await page.evaluate(() => {
|
||||||
const readability = (globalThis as any).__readability
|
const readability = (globalThis as any).__readability
|
||||||
if (!readability) {
|
if (!readability) {
|
||||||
throw new Error('Readability not loaded')
|
throw new Error('Readability not loaded')
|
||||||
@@ -131,7 +131,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
|
|||||||
publishedTime: article.publishedTime || null,
|
publishedTime: article.publishedTime || null,
|
||||||
wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length,
|
wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length,
|
||||||
}
|
}
|
||||||
}) as PageMarkdownResult & { _notReadable?: boolean }
|
})) as PageMarkdownResult & { _notReadable?: boolean }
|
||||||
|
|
||||||
// Format output
|
// Format output
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
|
|||||||
+69
-57
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+560
-551
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+21
-49
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
You can also find `getByRole` to get elements on the page.
|
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')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,7 @@
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { Page } from '@xmorse/playwright-core'
|
import type { Page } from '@xmorse/playwright-core'
|
||||||
import type {
|
import type { StartRecordingResult, StopRecordingResult, IsRecordingResult, CancelRecordingResult } from './protocol.js'
|
||||||
StartRecordingResult,
|
|
||||||
StopRecordingResult,
|
|
||||||
IsRecordingResult,
|
|
||||||
CancelRecordingResult,
|
|
||||||
} from './protocol.js'
|
|
||||||
import { EXTENSION_IDS } from './utils.js'
|
import { EXTENSION_IDS } from './utils.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +22,8 @@ import { EXTENSION_IDS } from './utils.js'
|
|||||||
*/
|
*/
|
||||||
export function getChromeRestartCommand(): string {
|
export function getChromeRestartCommand(): string {
|
||||||
const platform = os.platform()
|
const platform = os.platform()
|
||||||
const flags = EXTENSION_IDS.map(id => `--allowlisted-extension-id=${id}`).join(' ') + ' --auto-accept-this-tab-capture'
|
const flags =
|
||||||
|
EXTENSION_IDS.map((id) => `--allowlisted-extension-id=${id}`).join(' ') + ' --auto-accept-this-tab-capture'
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
return `osascript -e 'quit app "Google Chrome"' && sleep 1 && open -a "Google Chrome" --args ${flags}`
|
return `osascript -e 'quit app "Google Chrome"' && sleep 1 && open -a "Google Chrome" --args ${flags}`
|
||||||
@@ -43,9 +39,11 @@ export function getChromeRestartCommand(): string {
|
|||||||
* Check if an error is related to missing activeTab permission for recording.
|
* Check if an error is related to missing activeTab permission for recording.
|
||||||
*/
|
*/
|
||||||
function isActiveTabPermissionError(error: string): boolean {
|
function isActiveTabPermissionError(error: string): boolean {
|
||||||
return error.includes('Extension has not been invoked') ||
|
return (
|
||||||
error.includes('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 {
|
||||||
@@ -104,10 +102,17 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
|||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, frameRate, videoBitsPerSecond, audioBitsPerSecond, audio, outputPath: absoluteOutputPath }),
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
frameRate,
|
||||||
|
videoBitsPerSecond,
|
||||||
|
audioBitsPerSecond,
|
||||||
|
audio,
|
||||||
|
outputPath: absoluteOutputPath,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json() as StartRecordingResult
|
const result = (await response.json()) as StartRecordingResult
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errorMsg = result.error || 'Unknown error'
|
const errorMsg = result.error || 'Unknown error'
|
||||||
@@ -117,10 +122,10 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
|||||||
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.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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}`)
|
||||||
|
|||||||
+247
-179
@@ -14,6 +14,7 @@ If using npx or bunx always use @latest for the first session command. so we are
|
|||||||
### Session management
|
### Session management
|
||||||
|
|
||||||
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
|
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
|
||||||
|
|
||||||
- Keep state separate between different tasks or agents
|
- Keep state separate between different tasks or agents
|
||||||
- Persist data (pages, variables) across multiple execute calls
|
- Persist data (pages, variables) across multiple execute calls
|
||||||
- Avoid interference when multiple agents use playwriter simultaneously
|
- Avoid interference when multiple agents use playwriter simultaneously
|
||||||
@@ -216,30 +217,33 @@ Each step is a separate execute call. Notice how every action is followed by a s
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// 1. Open page and observe — always print URL first
|
// 1. Open page and observe — always print URL first
|
||||||
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
|
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
||||||
await state.page.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
|
await state.page.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' })
|
||||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page }).then(console.log)
|
console.log('URL:', state.page.url())
|
||||||
|
await snapshot({ page: state.page }).then(console.log)
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// 2. Act: open command palette → observe result
|
// 2. Act: open command palette → observe result
|
||||||
await state.page.keyboard.press('Meta+k');
|
await state.page.keyboard.press('Meta+k')
|
||||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /dialog|Search/ }).then(console.log)
|
console.log('URL:', state.page.url())
|
||||||
|
await snapshot({ page: state.page, search: /dialog|Search/ }).then(console.log)
|
||||||
// If dialog didn't appear, observe again before retrying
|
// If dialog didn't appear, observe again before retrying
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// 3. Act: type search query → observe result
|
// 3. Act: type search query → observe result
|
||||||
await state.page.keyboard.type('MCP');
|
await state.page.keyboard.type('MCP')
|
||||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /MCP/ }).then(console.log)
|
console.log('URL:', state.page.url())
|
||||||
|
await snapshot({ page: state.page, search: /MCP/ }).then(console.log)
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// 4. Act: press Enter → observe plugin loaded
|
// 4. Act: press Enter → observe plugin loaded
|
||||||
await state.page.keyboard.press('Enter');
|
await state.page.keyboard.press('Enter')
|
||||||
await state.page.waitForTimeout(1000);
|
await state.page.waitForTimeout(1000)
|
||||||
console.log('URL:', state.page.url());
|
console.log('URL:', state.page.url())
|
||||||
const frame = state.page.frames().find(f => f.url().includes('plugins.framercdn.com'));
|
const frame = state.page.frames().find((f) => f.url().includes('plugins.framercdn.com'))
|
||||||
await snapshot({ page: state.page, frame: frame || undefined }).then(console.log)
|
await snapshot({ page: state.page, frame: frame || undefined }).then(console.log)
|
||||||
// If frame not found, wait and observe again — plugin may still be loading
|
// If frame not found, wait and observe again — plugin may still be loading
|
||||||
```
|
```
|
||||||
@@ -254,7 +258,11 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
|
|||||||
```
|
```
|
||||||
- **Network requests** — verify API calls were made after a form submit or button click:
|
- **Network requests** — verify API calls were made after a form submit or button click:
|
||||||
```js
|
```js
|
||||||
state.page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
|
state.page.on('response', async (res) => {
|
||||||
|
if (res.url().includes('/api/')) {
|
||||||
|
console.log(res.status(), res.url())
|
||||||
|
}
|
||||||
|
})
|
||||||
```
|
```
|
||||||
- **URL changes** — confirm navigation happened:
|
- **URL changes** — confirm navigation happened:
|
||||||
```js
|
```js
|
||||||
@@ -266,28 +274,31 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
|
|||||||
|
|
||||||
**1. Not verifying actions succeeded**
|
**1. Not verifying actions succeeded**
|
||||||
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
|
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await state.page.keyboard.type('my text');
|
await state.page.keyboard.type('my text')
|
||||||
await snapshot({ page: state.page, search: /my text/ })
|
await snapshot({ page: state.page, search: /my text/ })
|
||||||
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
|
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Assuming paste/upload worked**
|
**2. Assuming paste/upload worked**
|
||||||
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
|
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Reliable: use file input
|
// Reliable: use file input
|
||||||
const fileInput = state.page.locator('input[type="file"]').first();
|
const fileInput = state.page.locator('input[type="file"]').first()
|
||||||
await fileInput.setInputFiles('/path/to/image.png');
|
await fileInput.setInputFiles('/path/to/image.png')
|
||||||
|
|
||||||
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
|
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
|
||||||
await state.page.keyboard.press('Meta+v'); // always verify with screenshot!
|
await state.page.keyboard.press('Meta+v') // always verify with screenshot!
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Using stale locators from old snapshots**
|
**3. Using stale locators from old snapshots**
|
||||||
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
|
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: using ref from minutes ago
|
// BAD: using ref from minutes ago
|
||||||
await state.page.locator('[id="old-id"]').click(); // element may have changed
|
await state.page.locator('[id="old-id"]').click() // element may have changed
|
||||||
|
|
||||||
// GOOD: get fresh snapshot, then immediately use locators from it
|
// GOOD: get fresh snapshot, then immediately use locators from it
|
||||||
await snapshot({ page: state.page, showDiffSinceLastCall: true })
|
await snapshot({ page: state.page, showDiffSinceLastCall: true })
|
||||||
@@ -296,26 +307,29 @@ await snapshot({ page: state.page, showDiffSinceLastCall: true })
|
|||||||
|
|
||||||
**4. Wrong assumptions about current page/element**
|
**4. Wrong assumptions about current page/element**
|
||||||
Before destructive actions (delete, submit), verify you're targeting the right thing:
|
Before destructive actions (delete, submit), verify you're targeting the right thing:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Before deleting, verify it's the right item
|
// Before deleting, verify it's the right item
|
||||||
await screenshotWithAccessibilityLabels({ page: state.page });
|
await screenshotWithAccessibilityLabels({ page: state.page })
|
||||||
// READ the screenshot to confirm, THEN proceed with delete
|
// READ the screenshot to confirm, THEN proceed with delete
|
||||||
```
|
```
|
||||||
|
|
||||||
**5. Text concatenation without line breaks**
|
**5. Text concatenation without line breaks**
|
||||||
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
|
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: newlines in string don't create line breaks
|
// BAD: newlines in string don't create line breaks
|
||||||
await state.page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
|
await state.page.keyboard.type('Line 1\nLine 2') // becomes "Line 1Line 2"
|
||||||
|
|
||||||
// GOOD: use Enter key for line breaks
|
// GOOD: use Enter key for line breaks
|
||||||
await state.page.keyboard.type('Line 1');
|
await state.page.keyboard.type('Line 1')
|
||||||
await state.page.keyboard.press('Enter');
|
await state.page.keyboard.press('Enter')
|
||||||
await state.page.keyboard.type('Line 2');
|
await state.page.keyboard.type('Line 2')
|
||||||
```
|
```
|
||||||
|
|
||||||
**6. Quote escaping in $'...' syntax**
|
**6. Quote escaping in $'...' syntax**
|
||||||
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
|
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# BAD: nested double quotes break $'...'
|
# BAD: nested double quotes break $'...'
|
||||||
playwriter -s 1 -e $'await state.page.locator("[id=\"_r_a_\"]").click()'
|
playwriter -s 1 -e $'await state.page.locator("[id=\"_r_a_\"]").click()'
|
||||||
@@ -332,61 +346,65 @@ EOF
|
|||||||
|
|
||||||
**7. Using screenshots when snapshots suffice**
|
**7. Using screenshots when snapshots suffice**
|
||||||
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
|
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
|
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
|
||||||
await state.page.screenshot({ path: 'check.png', scale: 'css' });
|
await state.page.screenshot({ path: 'check.png', scale: 'css' })
|
||||||
|
|
||||||
// GOOD: snapshot is text — fast, cheap, searchable
|
// GOOD: snapshot is text — fast, cheap, searchable
|
||||||
await snapshot({ page: state.page, search: /expected text/i })
|
await snapshot({ page: state.page, search: /expected text/i })
|
||||||
|
|
||||||
// GOOD: evaluate DOM directly for content checks
|
// GOOD: evaluate DOM directly for content checks
|
||||||
const text = await state.page.evaluate(() => document.querySelector('.message')?.textContent);
|
const text = await state.page.evaluate(() => document.querySelector('.message')?.textContent)
|
||||||
```
|
```
|
||||||
|
|
||||||
**8. Assuming page content loaded**
|
**8. Assuming page content loaded**
|
||||||
Even after `goto()`, dynamic content may not be ready:
|
Even after `goto()`, dynamic content may not be ready:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await state.page.goto('https://example.com');
|
await state.page.goto('https://example.com')
|
||||||
// Content may still be loading via JavaScript!
|
// Content may still be loading via JavaScript!
|
||||||
await state.page.waitForSelector('article', { timeout: 10000 });
|
await state.page.waitForSelector('article', { timeout: 10000 })
|
||||||
// Or use waitForPageLoad utility
|
// Or use waitForPageLoad utility
|
||||||
await waitForPageLoad({ page: state.page, timeout: 5000 });
|
await waitForPageLoad({ page: state.page, timeout: 5000 })
|
||||||
```
|
```
|
||||||
|
|
||||||
**9. Not using playwriter for JS-rendered sites**
|
**9. Not using playwriter for JS-rendered sites**
|
||||||
Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These sites return empty HTML shells — the real content is rendered by JavaScript. Use playwriter with a real browser session instead:
|
Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These sites return empty HTML shells — the real content is rendered by JavaScript. Use playwriter with a real browser session instead:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: webfetch/curl on Instagram returns empty HTML, grep finds nothing, huge context wasted
|
// BAD: webfetch/curl on Instagram returns empty HTML, grep finds nothing, huge context wasted
|
||||||
// BAD: Playwright CLI screenshot needs browser install, produces blank/modal-blocked images
|
// BAD: Playwright CLI screenshot needs browser install, produces blank/modal-blocked images
|
||||||
|
|
||||||
// GOOD: use playwriter — real browser, full JS rendering, interactive
|
// GOOD: use playwriter — real browser, full JS rendering, interactive
|
||||||
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
|
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
||||||
await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' });
|
await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' })
|
||||||
await waitForPageLoad({ page: state.page, timeout: 8000 });
|
await waitForPageLoad({ page: state.page, timeout: 8000 })
|
||||||
await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log)
|
await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log)
|
||||||
// Now you can see modals, dismiss them, navigate carousels, extract content
|
// Now you can see modals, dismiss them, navigate carousels, extract content
|
||||||
```
|
```
|
||||||
|
|
||||||
**10. Login buttons that open popups**
|
**10. Login buttons that open popups**
|
||||||
Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead:
|
Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: popup window is not controllable by playwriter
|
// BAD: popup window is not controllable by playwriter
|
||||||
await state.page.click('button:has-text("Login with Google")');
|
await state.page.click('button:has-text("Login with Google")')
|
||||||
|
|
||||||
// GOOD: cmd+click opens in new tab that playwriter can control
|
// GOOD: cmd+click opens in new tab that playwriter can control
|
||||||
await state.page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
|
await state.page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
|
||||||
await state.page.waitForTimeout(2000);
|
await state.page.waitForTimeout(2000)
|
||||||
|
|
||||||
// Verify new tab opened - last page should be the login page
|
// Verify new tab opened - last page should be the login page
|
||||||
const pages = context.pages();
|
const pages = context.pages()
|
||||||
const loginPage = pages[pages.length - 1];
|
const loginPage = pages[pages.length - 1]
|
||||||
if (loginPage.url() === state.page.url()) {
|
if (loginPage.url() === state.page.url()) {
|
||||||
throw new Error('Cmd+click did not open new tab - login may have opened as popup');
|
throw new Error('Cmd+click did not open new tab - login may have opened as popup')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete login flow in loginPage, cookies are shared with original page
|
// Complete login flow in loginPage, cookies are shared with original page
|
||||||
await loginPage.locator('[data-email]').first().click();
|
await loginPage.locator('[data-email]').first().click()
|
||||||
await loginPage.waitForURL('**/callback**');
|
await loginPage.waitForURL('**/callback**')
|
||||||
// Original page should now be authenticated
|
// Original page should now be authenticated
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -396,10 +414,12 @@ After any action (click, submit, navigate), verify what happened. Always print U
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// Always print URL first, then snapshot
|
// Always print URL first, then snapshot
|
||||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page }).then(console.log)
|
console.log('URL:', state.page.url())
|
||||||
|
await snapshot({ page: state.page }).then(console.log)
|
||||||
|
|
||||||
// Filter for specific content when snapshot is large
|
// Filter for specific content when snapshot is large
|
||||||
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /dialog|button|error/i }).then(console.log)
|
console.log('URL:', state.page.url())
|
||||||
|
await snapshot({ page: state.page, search: /dialog|button|error/i }).then(console.log)
|
||||||
```
|
```
|
||||||
|
|
||||||
If nothing changed, try `await waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
|
If nothing changed, try `await waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -825,28 +870,29 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
|
|||||||
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
@@ -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.')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,9 +149,10 @@ export async function getStylesForLocator({
|
|||||||
let source: StyleSource | null = null
|
let source: StyleSource | null = null
|
||||||
if (styleSheetId && sourceRange) {
|
if (styleSheetId && sourceRange) {
|
||||||
const styleSheet = (matchedStyles as any).cssStyleSheetHeaders?.find(
|
const styleSheet = (matchedStyles as any).cssStyleSheetHeaders?.find(
|
||||||
(h: CSSStyleSheetHeader) => h.styleSheetId === styleSheetId
|
(h: CSSStyleSheetHeader) => h.styleSheetId === styleSheetId,
|
||||||
)
|
)
|
||||||
const url = styleSheet?.sourceURL || (rule as any).origin === 'user-agent' ? 'user-agent' : `stylesheet:${styleSheetId}`
|
const url =
|
||||||
|
styleSheet?.sourceURL || (rule as any).origin === 'user-agent' ? 'user-agent' : `stylesheet:${styleSheetId}`
|
||||||
|
|
||||||
source = {
|
source = {
|
||||||
url: (rule as any).styleSheetId ? await getStylesheetUrl(cdp, styleSheetId) : 'user-agent',
|
url: (rule as any).styleSheetId ? await getStylesheetUrl(cdp, styleSheetId) : 'user-agent',
|
||||||
@@ -224,9 +225,7 @@ export async function getStylesForLocator({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredRules = includeUserAgentStyles
|
const filteredRules = includeUserAgentStyles ? rules : rules.filter((r) => r.origin !== 'user-agent')
|
||||||
? rules
|
|
||||||
: rules.filter((r) => r.origin !== 'user-agent')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element: elementDescription,
|
element: elementDescription,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
+103
-90
@@ -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,42 +273,47 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -317,77 +330,77 @@ export function tryJsonParse(str: string) {
|
|||||||
* @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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
+235
-234
@@ -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}` : ''}`
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
|
||||||
server = await startPlayWriterCDPRelayServer({
|
ws.on('open', () => {
|
||||||
port: TEST_PORT,
|
ws.close()
|
||||||
token,
|
resolve()
|
||||||
logger
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ The aria snapshot feature in playwriter extracts an accessibility tree from the
|
|||||||
### Key Functions
|
### Key Functions
|
||||||
|
|
||||||
#### `getAriaSnapshot()`
|
#### `getAriaSnapshot()`
|
||||||
|
|
||||||
**Location:** Line 749-1033 in `aria-snapshot.ts`
|
**Location:** Line 749-1033 in `aria-snapshot.ts`
|
||||||
|
|
||||||
Main entry point that returns `AriaSnapshotResult` containing:
|
Main entry point that returns `AriaSnapshotResult` containing:
|
||||||
|
|
||||||
- `snapshot`: String representation of the accessibility tree
|
- `snapshot`: String representation of the accessibility tree
|
||||||
- `tree`: Structured tree with nodes
|
- `tree`: Structured tree with nodes
|
||||||
- `refs`: Array of references to interactive elements
|
- `refs`: Array of references to interactive elements
|
||||||
@@ -36,6 +38,7 @@ Main entry point that returns `AriaSnapshotResult` containing:
|
|||||||
- `getRefsForLocators()`: Get refs for Playwright locators
|
- `getRefsForLocators()`: Get refs for Playwright locators
|
||||||
|
|
||||||
**Signature:**
|
**Signature:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export async function getAriaSnapshot({
|
export async function getAriaSnapshot({
|
||||||
page,
|
page,
|
||||||
@@ -43,7 +46,7 @@ export async function getAriaSnapshot({
|
|||||||
refFilter,
|
refFilter,
|
||||||
wsUrl,
|
wsUrl,
|
||||||
interactiveOnly = false,
|
interactiveOnly = false,
|
||||||
cdp
|
cdp,
|
||||||
}: {
|
}: {
|
||||||
page: Page
|
page: Page
|
||||||
locator?: Locator
|
locator?: Locator
|
||||||
@@ -60,14 +63,16 @@ export async function getAriaSnapshot({
|
|||||||
|
|
||||||
**Command:** `DOM.getFlattenedDocument`
|
**Command:** `DOM.getFlattenedDocument`
|
||||||
**Location:** Line 772
|
**Location:** Line 772
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', {
|
const { nodes: domNodes } = (await session.send('DOM.getFlattenedDocument', {
|
||||||
depth: -1,
|
depth: -1,
|
||||||
pierce: true
|
pierce: true,
|
||||||
}) as Protocol.DOM.GetFlattenedDocumentResponse
|
})) as Protocol.DOM.GetFlattenedDocumentResponse
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
|
||||||
- `depth: -1` - Get entire subtree
|
- `depth: -1` - Get entire subtree
|
||||||
- `pierce: true` - **Traverses iframes and shadow roots**
|
- `pierce: true` - **Traverses iframes and shadow roots**
|
||||||
|
|
||||||
@@ -77,12 +82,14 @@ const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', {
|
|||||||
|
|
||||||
**Command:** `Accessibility.getFullAXTree`
|
**Command:** `Accessibility.getFullAXTree`
|
||||||
**Location:** Line 791
|
**Location:** Line 791
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
|
const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
|
||||||
as Protocol.Accessibility.GetFullAXTreeResponse
|
as Protocol.Accessibility.GetFullAXTreeResponse
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:** None specified (uses defaults)
|
**Parameters:** None specified (uses defaults)
|
||||||
|
|
||||||
- Default: Returns AX tree for root frame
|
- Default: Returns AX tree for root frame
|
||||||
- **Has optional `frameId` parameter** (not currently used)
|
- **Has optional `frameId` parameter** (not currently used)
|
||||||
|
|
||||||
@@ -93,10 +100,12 @@ const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
|
|||||||
### Current Implementation
|
### Current Implementation
|
||||||
|
|
||||||
**DOM Level:** ✅ **FULL SUPPORT**
|
**DOM Level:** ✅ **FULL SUPPORT**
|
||||||
|
|
||||||
- `DOM.getFlattenedDocument` with `pierce: true` traverses all iframes and shadow roots
|
- `DOM.getFlattenedDocument` with `pierce: true` traverses all iframes and shadow roots
|
||||||
- All DOM nodes from all frames are included in the flattened document
|
- All DOM nodes from all frames are included in the flattened document
|
||||||
|
|
||||||
**Accessibility Level:** ⚠️ **LIMITED**
|
**Accessibility Level:** ⚠️ **LIMITED**
|
||||||
|
|
||||||
- `Accessibility.getFullAXTree` is called **without `frameId` parameter**
|
- `Accessibility.getFullAXTree` is called **without `frameId` parameter**
|
||||||
- According to CDP spec, when `frameId` is omitted, **only the root frame is used**
|
- According to CDP spec, when `frameId` is omitted, **only the root frame is used**
|
||||||
- Cross-origin iframes may have additional restrictions
|
- Cross-origin iframes may have additional restrictions
|
||||||
@@ -104,6 +113,7 @@ const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree')
|
|||||||
### CDP Spec Details
|
### CDP Spec Details
|
||||||
|
|
||||||
From `Accessibility.pdl`:
|
From `Accessibility.pdl`:
|
||||||
|
|
||||||
```
|
```
|
||||||
experimental command getFullAXTree
|
experimental command getFullAXTree
|
||||||
parameters
|
parameters
|
||||||
@@ -126,11 +136,13 @@ experimental command getFullAXTree
|
|||||||
### Evidence from Code
|
### Evidence from Code
|
||||||
|
|
||||||
**Scope handling (Line 760-789):**
|
**Scope handling (Line 760-789):**
|
||||||
|
|
||||||
- Uses `data-pw-scope` attribute to scope snapshots to a locator
|
- Uses `data-pw-scope` attribute to scope snapshots to a locator
|
||||||
- Builds `allowedBackendIds` set from DOM tree traversal
|
- Builds `allowedBackendIds` set from DOM tree traversal
|
||||||
- Filters AX nodes based on `backendDOMNodeId` membership in this set
|
- Filters AX nodes based on `backendDOMNodeId` membership in this set
|
||||||
|
|
||||||
**Node mapping:**
|
**Node mapping:**
|
||||||
|
|
||||||
- Each AX node has `backendDOMNodeId` property linking to DOM node
|
- Each AX node has `backendDOMNodeId` property linking to DOM node
|
||||||
- DOM nodes fetched with `pierce: true` include iframe contents
|
- DOM nodes fetched with `pierce: true` include iframe contents
|
||||||
- But AX tree without `frameId` may not cover all frames
|
- But AX tree without `frameId` may not cover all frames
|
||||||
@@ -138,6 +150,7 @@ experimental command getFullAXTree
|
|||||||
## Key Data Structures
|
## Key Data Structures
|
||||||
|
|
||||||
### AriaSnapshotNode
|
### AriaSnapshotNode
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type AriaSnapshotNode = {
|
type AriaSnapshotNode = {
|
||||||
role: string
|
role: string
|
||||||
@@ -151,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',
|
||||||
])
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ description: Analysis of how Playwright implements accessibility snapshots and h
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This document contains findings from exploring the Playwright source code to understand:
|
This document contains findings from exploring the Playwright source code to understand:
|
||||||
|
|
||||||
1. How Playwright implements accessibility snapshots (ariaSnapshot)
|
1. How Playwright implements accessibility snapshots (ariaSnapshot)
|
||||||
2. Whether Playwright supports getting accessibility tree for iframes/child frames
|
2. Whether Playwright supports getting accessibility tree for iframes/child frames
|
||||||
3. How Playwright handles frame locators for accessibility
|
3. How Playwright handles frame locators for accessibility
|
||||||
@@ -69,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,21 +117,22 @@ The CDP protocol DOES support frame-specific accessibility queries:
|
|||||||
```typescript
|
```typescript
|
||||||
// From protocol.d.ts
|
// From protocol.d.ts
|
||||||
export type getFullAXTreeParameters = {
|
export type getFullAXTreeParameters = {
|
||||||
depth?: number;
|
depth?: number
|
||||||
frameId?: Page.FrameId; // ⚠️ Supports frame-specific queries!
|
frameId?: Page.FrameId // ⚠️ Supports frame-specific queries!
|
||||||
}
|
}
|
||||||
|
|
||||||
export type getPartialAXTreeParameters = {
|
export type getPartialAXTreeParameters = {
|
||||||
nodeId?: DOM.NodeId;
|
nodeId?: DOM.NodeId
|
||||||
backendNodeId?: DOM.BackendNodeId;
|
backendNodeId?: DOM.BackendNodeId
|
||||||
objectId?: Runtime.RemoteObjectId;
|
objectId?: Runtime.RemoteObjectId
|
||||||
fetchRelatives?: boolean;
|
fetchRelatives?: boolean
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**However, Playwright does NOT use these CDP commands anywhere in the codebase.**
|
**However, Playwright does NOT use these CDP commands anywhere in the codebase.**
|
||||||
|
|
||||||
Search results show:
|
Search results show:
|
||||||
|
|
||||||
- CDP types defined in `protocol.d.ts`
|
- CDP types defined in `protocol.d.ts`
|
||||||
- No actual usage in Chromium implementation files
|
- No actual usage in Chromium implementation files
|
||||||
- Firefox uses a custom `Accessibility.getFullAXTree` via Juggler protocol
|
- Firefox uses a custom `Accessibility.getFullAXTree` via Juggler protocol
|
||||||
@@ -137,12 +140,14 @@ Search results show:
|
|||||||
### 5. Why Playwright Uses DOM Traversal Instead of CDP
|
### 5. Why Playwright Uses DOM Traversal Instead of CDP
|
||||||
|
|
||||||
**Advantages of DOM traversal approach:**
|
**Advantages of DOM traversal approach:**
|
||||||
|
|
||||||
1. **Cross-browser compatibility** - Works in Firefox, WebKit, Chromium
|
1. **Cross-browser compatibility** - Works in Firefox, WebKit, Chromium
|
||||||
2. **Full control** - Can customize what gets included/excluded
|
2. **Full control** - Can customize what gets included/excluded
|
||||||
3. **Performance** - No serialization overhead for large trees
|
3. **Performance** - No serialization overhead for large trees
|
||||||
4. **Flexibility** - Can implement custom filtering (visibility, aria roles, etc.)
|
4. **Flexibility** - Can implement custom filtering (visibility, aria roles, etc.)
|
||||||
|
|
||||||
**Disadvantages:**
|
**Disadvantages:**
|
||||||
|
|
||||||
1. **Cannot access iframe content** - Browser security prevents cross-origin access
|
1. **Cannot access iframe content** - Browser security prevents cross-origin access
|
||||||
2. **Must execute in each frame separately** - No single command for entire page tree
|
2. **Must execute in each frame separately** - No single command for entire page tree
|
||||||
3. **Slower for deep trees** - Must traverse DOM node-by-node
|
3. **Slower for deep trees** - Must traverse DOM node-by-node
|
||||||
@@ -158,19 +163,21 @@ Based on code analysis:
|
|||||||
3. **By design**: The `generateAriaTree` function explicitly skips iframe children
|
3. **By design**: The `generateAriaTree` function explicitly skips iframe children
|
||||||
|
|
||||||
**To get iframe content accessibility tree, you would need to:**
|
**To get iframe content accessibility tree, you would need to:**
|
||||||
|
|
||||||
1. Switch to the iframe's frame context
|
1. Switch to the iframe's frame context
|
||||||
2. Call `ariaSnapshot()` again on that frame
|
2. Call `ariaSnapshot()` again on that frame
|
||||||
3. Manually combine the results
|
3. Manually combine the results
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get main frame snapshot
|
// Get main frame snapshot
|
||||||
const mainSnapshot = await page.locator('body').ariaSnapshot();
|
const mainSnapshot = await page.locator('body').ariaSnapshot()
|
||||||
|
|
||||||
// Get iframe content
|
// Get iframe content
|
||||||
const frameElement = await page.frameLocator('iframe');
|
const frameElement = await page.frameLocator('iframe')
|
||||||
const frame = await frameElement.owner();
|
const frame = await frameElement.owner()
|
||||||
const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot();
|
const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot()
|
||||||
|
|
||||||
// Results are separate - no automatic merging
|
// Results are separate - no automatic merging
|
||||||
```
|
```
|
||||||
@@ -184,14 +191,16 @@ const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot();
|
|||||||
3. Merge the results into a single tree
|
3. Merge the results into a single tree
|
||||||
|
|
||||||
**CDP Command:**
|
**CDP Command:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await session.send('Accessibility.getFullAXTree', {
|
await session.send('Accessibility.getFullAXTree', {
|
||||||
frameId: 'frame-id-here',
|
frameId: 'frame-id-here',
|
||||||
depth: -1 // unlimited depth
|
depth: -1, // unlimited depth
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
This would return the full accessibility tree including iframe content, but:
|
This would return the full accessibility tree including iframe content, but:
|
||||||
|
|
||||||
- Only works in Chromium (not Firefox/WebKit)
|
- Only works in Chromium (not Firefox/WebKit)
|
||||||
- Returns CDP's AXNode format, not Playwright's AriaNode format
|
- Returns CDP's AXNode format, not Playwright's AriaNode format
|
||||||
- Would need conversion logic
|
- Would need conversion logic
|
||||||
@@ -205,34 +214,34 @@ Get accessibility tree for each frame separately:
|
|||||||
```typescript
|
```typescript
|
||||||
// Pseudo-code for MCP implementation
|
// Pseudo-code for MCP implementation
|
||||||
async function getAccessibilitySnapshot({ sessionId, includeFrames = false }) {
|
async function getAccessibilitySnapshot({ sessionId, includeFrames = false }) {
|
||||||
const page = getPage(sessionId);
|
const page = getPage(sessionId)
|
||||||
|
|
||||||
// Get main frame snapshot
|
// Get main frame snapshot
|
||||||
const mainSnapshot = await page.evaluate(() => {
|
const mainSnapshot = await page.evaluate(() => {
|
||||||
return injected.ariaSnapshot(document.body, { mode: 'ai' });
|
return injected.ariaSnapshot(document.body, { mode: 'ai' })
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!includeFrames) {
|
if (!includeFrames) {
|
||||||
return mainSnapshot;
|
return mainSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all iframe snapshots
|
// Get all iframe snapshots
|
||||||
const frames = page.frames();
|
const frames = page.frames()
|
||||||
const frameSnapshots = await Promise.all(
|
const frameSnapshots = await Promise.all(
|
||||||
frames.slice(1).map(async (frame) => {
|
frames.slice(1).map(async (frame) => {
|
||||||
return {
|
return {
|
||||||
frameId: frame.name() || frame.url(),
|
frameId: frame.name() || frame.url(),
|
||||||
snapshot: await frame.evaluate(() => {
|
snapshot: await frame.evaluate(() => {
|
||||||
return injected.ariaSnapshot(document.body, { mode: 'ai' });
|
return injected.ariaSnapshot(document.body, { mode: 'ai' })
|
||||||
})
|
}),
|
||||||
};
|
}
|
||||||
})
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
main: mainSnapshot,
|
main: mainSnapshot,
|
||||||
frames: frameSnapshots
|
frames: frameSnapshots,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -242,24 +251,24 @@ Use CDP `Accessibility.getFullAXTree` for each frame:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function getFullAccessibilityTree({ sessionId }) {
|
async function getFullAccessibilityTree({ sessionId }) {
|
||||||
const page = getPage(sessionId);
|
const page = getPage(sessionId)
|
||||||
const frames = page.frames();
|
const frames = page.frames()
|
||||||
|
|
||||||
const trees = await Promise.all(
|
const trees = await Promise.all(
|
||||||
frames.map(async (frame) => {
|
frames.map(async (frame) => {
|
||||||
const session = await frame._client; // Get CDP session
|
const session = await frame._client // Get CDP session
|
||||||
const { nodes } = await session.send('Accessibility.getFullAXTree', {
|
const { nodes } = await session.send('Accessibility.getFullAXTree', {
|
||||||
frameId: frame._id
|
frameId: frame._id,
|
||||||
});
|
})
|
||||||
return {
|
return {
|
||||||
frameId: frame.url(),
|
frameId: frame.url(),
|
||||||
nodes
|
nodes,
|
||||||
};
|
}
|
||||||
})
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
// Convert CDP AXNode[] to Playwright AriaNode format
|
// Convert CDP AXNode[] to Playwright AriaNode format
|
||||||
return convertCDPtoAria(trees);
|
return convertCDPtoAria(trees)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -274,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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+222
-163
@@ -14,6 +14,7 @@ If using npx or bunx always use @latest for the first session command. so we are
|
|||||||
### Session management
|
### Session management
|
||||||
|
|
||||||
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
|
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
|
||||||
|
|
||||||
- Keep state separate between different tasks or agents
|
- Keep state separate between different tasks or agents
|
||||||
- Persist data (pages, variables) across multiple execute calls
|
- Persist data (pages, variables) across multiple execute calls
|
||||||
- Avoid interference when multiple agents use playwriter simultaneously
|
- Avoid interference when multiple agents use playwriter simultaneously
|
||||||
@@ -213,30 +214,33 @@ Each step is a separate execute call. Notice how every action is followed by a s
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// 1. Open page and observe — always print URL first
|
// 1. Open page and observe — always print URL first
|
||||||
state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
|
state.myPage = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
||||||
await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
|
await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' })
|
||||||
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage }).then(console.log)
|
console.log('URL:', state.myPage.url())
|
||||||
|
await snapshot({ page: state.myPage }).then(console.log)
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// 2. Act: open command palette → observe result
|
// 2. Act: open command palette → observe result
|
||||||
await state.myPage.keyboard.press('Meta+k');
|
await state.myPage.keyboard.press('Meta+k')
|
||||||
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log)
|
console.log('URL:', state.myPage.url())
|
||||||
|
await snapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log)
|
||||||
// If dialog didn't appear, observe again before retrying
|
// If dialog didn't appear, observe again before retrying
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// 3. Act: type search query → observe result
|
// 3. Act: type search query → observe result
|
||||||
await state.myPage.keyboard.type('MCP');
|
await state.myPage.keyboard.type('MCP')
|
||||||
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
|
console.log('URL:', state.myPage.url())
|
||||||
|
await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// 4. Act: press Enter → observe plugin loaded
|
// 4. Act: press Enter → observe plugin loaded
|
||||||
await state.myPage.keyboard.press('Enter');
|
await state.myPage.keyboard.press('Enter')
|
||||||
await state.myPage.waitForTimeout(1000);
|
await state.myPage.waitForTimeout(1000)
|
||||||
console.log('URL:', state.myPage.url());
|
console.log('URL:', state.myPage.url())
|
||||||
const frame = state.myPage.frames().find(f => f.url().includes('plugins.framercdn.com'));
|
const frame = state.myPage.frames().find((f) => f.url().includes('plugins.framercdn.com'))
|
||||||
await snapshot({ page: state.myPage, frame: frame || undefined }).then(console.log)
|
await snapshot({ page: state.myPage, frame: frame || undefined }).then(console.log)
|
||||||
// If frame not found, wait and observe again — plugin may still be loading
|
// If frame not found, wait and observe again — plugin may still be loading
|
||||||
```
|
```
|
||||||
@@ -251,7 +255,11 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
|
|||||||
```
|
```
|
||||||
- **Network requests** — verify API calls were made after a form submit or button click:
|
- **Network requests** — verify API calls were made after a form submit or button click:
|
||||||
```js
|
```js
|
||||||
page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
|
page.on('response', async (res) => {
|
||||||
|
if (res.url().includes('/api/')) {
|
||||||
|
console.log(res.status(), res.url())
|
||||||
|
}
|
||||||
|
})
|
||||||
```
|
```
|
||||||
- **URL changes** — confirm navigation happened:
|
- **URL changes** — confirm navigation happened:
|
||||||
```js
|
```js
|
||||||
@@ -263,28 +271,31 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
|
|||||||
|
|
||||||
**1. Not verifying actions succeeded**
|
**1. Not verifying actions succeeded**
|
||||||
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
|
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await page.keyboard.type('my text');
|
await page.keyboard.type('my text')
|
||||||
await snapshot({ page, search: /my text/ })
|
await snapshot({ page, search: /my text/ })
|
||||||
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
|
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Assuming paste/upload worked**
|
**2. Assuming paste/upload worked**
|
||||||
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
|
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Reliable: use file input
|
// Reliable: use file input
|
||||||
const fileInput = page.locator('input[type="file"]').first();
|
const fileInput = page.locator('input[type="file"]').first()
|
||||||
await fileInput.setInputFiles('/path/to/image.png');
|
await fileInput.setInputFiles('/path/to/image.png')
|
||||||
|
|
||||||
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
|
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
|
||||||
await page.keyboard.press('Meta+v'); // always verify with screenshot!
|
await page.keyboard.press('Meta+v') // always verify with screenshot!
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Using stale locators from old snapshots**
|
**3. Using stale locators from old snapshots**
|
||||||
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
|
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: using ref from minutes ago
|
// BAD: using ref from minutes ago
|
||||||
await page.locator('[id="old-id"]').click(); // element may have changed
|
await page.locator('[id="old-id"]').click() // element may have changed
|
||||||
|
|
||||||
// GOOD: get fresh snapshot, then immediately use locators from it
|
// GOOD: get fresh snapshot, then immediately use locators from it
|
||||||
await snapshot({ page, showDiffSinceLastCall: true })
|
await snapshot({ page, showDiffSinceLastCall: true })
|
||||||
@@ -293,26 +304,29 @@ await snapshot({ page, showDiffSinceLastCall: true })
|
|||||||
|
|
||||||
**4. Wrong assumptions about current page/element**
|
**4. Wrong assumptions about current page/element**
|
||||||
Before destructive actions (delete, submit), verify you're targeting the right thing:
|
Before destructive actions (delete, submit), verify you're targeting the right thing:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Before deleting, verify it's the right item
|
// Before deleting, verify it's the right item
|
||||||
await screenshotWithAccessibilityLabels({ page });
|
await screenshotWithAccessibilityLabels({ page })
|
||||||
// READ the screenshot to confirm, THEN proceed with delete
|
// READ the screenshot to confirm, THEN proceed with delete
|
||||||
```
|
```
|
||||||
|
|
||||||
**5. Text concatenation without line breaks**
|
**5. Text concatenation without line breaks**
|
||||||
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
|
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: newlines in string don't create line breaks
|
// BAD: newlines in string don't create line breaks
|
||||||
await page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
|
await page.keyboard.type('Line 1\nLine 2') // becomes "Line 1Line 2"
|
||||||
|
|
||||||
// GOOD: use Enter key for line breaks
|
// GOOD: use Enter key for line breaks
|
||||||
await page.keyboard.type('Line 1');
|
await page.keyboard.type('Line 1')
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter')
|
||||||
await page.keyboard.type('Line 2');
|
await page.keyboard.type('Line 2')
|
||||||
```
|
```
|
||||||
|
|
||||||
**6. Quote escaping in $'...' syntax**
|
**6. Quote escaping in $'...' syntax**
|
||||||
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
|
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# BAD: nested double quotes break $'...'
|
# BAD: nested double quotes break $'...'
|
||||||
playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()'
|
playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()'
|
||||||
@@ -329,47 +343,50 @@ EOF
|
|||||||
|
|
||||||
**7. Using screenshots when snapshots suffice**
|
**7. Using screenshots when snapshots suffice**
|
||||||
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
|
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
|
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
|
||||||
await page.screenshot({ path: 'check.png', scale: 'css' });
|
await page.screenshot({ path: 'check.png', scale: 'css' })
|
||||||
|
|
||||||
// GOOD: snapshot is text — fast, cheap, searchable
|
// GOOD: snapshot is text — fast, cheap, searchable
|
||||||
await snapshot({ page, search: /expected text/i })
|
await snapshot({ page, search: /expected text/i })
|
||||||
|
|
||||||
// GOOD: evaluate DOM directly for content checks
|
// GOOD: evaluate DOM directly for content checks
|
||||||
const text = await page.evaluate(() => document.querySelector('.message')?.textContent);
|
const text = await page.evaluate(() => document.querySelector('.message')?.textContent)
|
||||||
```
|
```
|
||||||
|
|
||||||
**8. Assuming page content loaded**
|
**8. Assuming page content loaded**
|
||||||
Even after `goto()`, dynamic content may not be ready:
|
Even after `goto()`, dynamic content may not be ready:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await page.goto('https://example.com');
|
await page.goto('https://example.com')
|
||||||
// Content may still be loading via JavaScript!
|
// Content may still be loading via JavaScript!
|
||||||
await page.waitForSelector('article', { timeout: 10000 });
|
await page.waitForSelector('article', { timeout: 10000 })
|
||||||
// Or use waitForPageLoad utility
|
// Or use waitForPageLoad utility
|
||||||
await waitForPageLoad({ page, timeout: 5000 });
|
await waitForPageLoad({ page, timeout: 5000 })
|
||||||
```
|
```
|
||||||
|
|
||||||
**9. Login buttons that open popups**
|
**9. Login buttons that open popups**
|
||||||
Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead:
|
Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// BAD: popup window is not controllable by playwriter
|
// BAD: popup window is not controllable by playwriter
|
||||||
await page.click('button:has-text("Login with Google")');
|
await page.click('button:has-text("Login with Google")')
|
||||||
|
|
||||||
// GOOD: cmd+click opens in new tab that playwriter can control
|
// GOOD: cmd+click opens in new tab that playwriter can control
|
||||||
await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
|
await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
// Verify new tab opened - last page should be the login page
|
// Verify new tab opened - last page should be the login page
|
||||||
const pages = context.pages();
|
const pages = context.pages()
|
||||||
const loginPage = pages[pages.length - 1];
|
const loginPage = pages[pages.length - 1]
|
||||||
if (loginPage.url() === page.url()) {
|
if (loginPage.url() === page.url()) {
|
||||||
throw new Error('Cmd+click did not open new tab - login may have opened as popup');
|
throw new Error('Cmd+click did not open new tab - login may have opened as popup')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete login flow in loginPage, cookies are shared with original page
|
// Complete login flow in loginPage, cookies are shared with original page
|
||||||
await loginPage.locator('[data-email]').first().click();
|
await loginPage.locator('[data-email]').first().click()
|
||||||
await loginPage.waitForURL('**/callback**');
|
await loginPage.waitForURL('**/callback**')
|
||||||
// Original page should now be authenticated
|
// Original page should now be authenticated
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -379,10 +396,12 @@ After any action (click, submit, navigate), verify what happened. Always print U
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// Always print URL first, then snapshot
|
// Always print URL first, then snapshot
|
||||||
console.log('URL:', page.url()); await snapshot({ page }).then(console.log)
|
console.log('URL:', page.url())
|
||||||
|
await snapshot({ page }).then(console.log)
|
||||||
|
|
||||||
// Filter for specific content when snapshot is large
|
// Filter for specific content when snapshot is large
|
||||||
console.log('URL:', page.url()); await snapshot({ page, search: /dialog|button|error/i }).then(console.log)
|
console.log('URL:', page.url())
|
||||||
|
await snapshot({ page, search: /dialog|button|error/i }).then(console.log)
|
||||||
```
|
```
|
||||||
|
|
||||||
If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element.
|
If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element.
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -772,28 +808,29 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
|
|||||||
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:
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+301
-312
File diff suppressed because it is too large
Load Diff
@@ -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 />)
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-15
@@ -1,26 +1,26 @@
|
|||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
import { reactRouter } from "@react-router/dev/vite";
|
import { reactRouter } from '@react-router/dev/vite'
|
||||||
import react from "@vitejs/plugin-react";
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import EnvironmentPlugin from "vite-plugin-environment";
|
import EnvironmentPlugin from 'vite-plugin-environment'
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from 'vite'
|
||||||
import {
|
import {
|
||||||
viteExternalsPlugin,
|
viteExternalsPlugin,
|
||||||
enablePreserveModulesPlugin,
|
enablePreserveModulesPlugin,
|
||||||
} from "@xmorse/deployment-utils/dist/vite-externals-plugin.js";
|
} from '@xmorse/deployment-utils/dist/vite-externals-plugin.js'
|
||||||
import { reactRouterServerPlugin } from "@xmorse/deployment-utils/dist/react-router.js";
|
import { reactRouterServerPlugin } from '@xmorse/deployment-utils/dist/react-router.js'
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
const NODE_ENV = JSON.stringify(process.env.NODE_ENV || "production");
|
const NODE_ENV = JSON.stringify(process.env.NODE_ENV || 'production')
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
define: {
|
define: {
|
||||||
"process.env.NODE_ENV": NODE_ENV,
|
'process.env.NODE_ENV': NODE_ENV,
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
pool: "threads",
|
pool: 'threads',
|
||||||
exclude: ["**/dist/**", "**/esm/**", "**/node_modules/**", "**/e2e/**"],
|
exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'],
|
||||||
poolOptions: {
|
poolOptions: {
|
||||||
threads: {
|
threads: {
|
||||||
isolate: false,
|
isolate: false,
|
||||||
@@ -28,8 +28,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
EnvironmentPlugin("all", { prefix: "PUBLIC" }),
|
EnvironmentPlugin('all', { prefix: 'PUBLIC' }),
|
||||||
EnvironmentPlugin("all", { prefix: "NEXT_PUBLIC" }),
|
EnvironmentPlugin('all', { prefix: 'NEXT_PUBLIC' }),
|
||||||
process.env.VITEST ? react() : reactRouter(),
|
process.env.VITEST ? react() : reactRouter(),
|
||||||
tsconfigPaths(),
|
tsconfigPaths(),
|
||||||
// viteExternalsPlugin({
|
// viteExternalsPlugin({
|
||||||
@@ -44,4 +44,4 @@ export default defineConfig({
|
|||||||
// transformMixedEsModules: true,
|
// transformMixedEsModules: true,
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
});
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user