From f87b0243cdc5e1492547f762d4642190efd5d581 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 22 Feb 2026 15:21:38 +0100 Subject: [PATCH] format --- .prettierignore | 4 + .prettierrc.json | 1 + AGENTS.md | 86 +- MCP.md | 1 + PLAYWRITER_AGENTS.md | 39 +- README.md | 62 +- docs/framer-iframe-snapshot-guide.md | 8 + docs/remote-access.md | 14 +- extension/manifest.json | 11 +- extension/package.json | 66 +- extension/permissions.md | 5 + extension/scripts/download-prism.ts | 30 +- extension/src/background.ts | 227 +- extension/src/ghost-browser-api.d.ts | 261 +- extension/src/offscreen-types.ts | 58 +- extension/src/offscreen.html | 14 +- extension/src/offscreen.ts | 34 +- extension/src/recording.ts | 19 +- extension/src/types.ts | 2 +- extension/vite.config.mts | 47 +- package.json | 55 +- playwriter/CHANGELOG.md | 8 +- playwriter/scripts/build-client-bundles.ts | 8 +- playwriter/scripts/build-resources.ts | 83 +- playwriter/scripts/extension-connect.ts | 84 +- playwriter/scripts/extension-current-pages.ts | 30 +- playwriter/scripts/extension-new-page.ts | 38 +- playwriter/scripts/extension-server.ts | 1 - .../src/__snapshots__/x.com.processed.html | 2465 +++-- .../x.com.processed.withStyles.html | 7945 +++++++++++------ playwriter/src/a11y-client.ts | 9 +- playwriter/src/aria-snapshot.test.ts | 7 +- playwriter/src/aria-snapshot.ts | 287 +- playwriter/src/aria-snapshot.unit.test.ts | 340 +- playwriter/src/cdp-log.ts | 5 +- playwriter/src/cdp-relay.ts | 1290 +-- playwriter/src/cdp-session.ts | 15 +- playwriter/src/cdp-types.ts | 102 +- playwriter/src/cli.ts | 86 +- playwriter/src/create-logger.ts | 8 +- playwriter/src/debugger-examples-types.ts | 5 +- playwriter/src/debugger.ts | 6 +- playwriter/src/diff-utils.ts | 7 +- playwriter/src/editor-examples.ts | 12 +- playwriter/src/editor.ts | 12 +- playwriter/src/executor.ts | 66 +- playwriter/src/extension-connection.test.ts | 1131 +-- playwriter/src/ghost-browser.ts | 10 +- playwriter/src/htmlrewrite.test.ts | 8 +- playwriter/src/htmlrewrite.ts | 760 +- playwriter/src/kill-port.ts | 4 +- playwriter/src/mcp-client.ts | 2 +- playwriter/src/mcp.ts | 29 +- playwriter/src/page-markdown.ts | 10 +- playwriter/src/protocol.ts | 126 +- playwriter/src/recording-relay.ts | 32 +- playwriter/src/relay-client.ts | 28 +- playwriter/src/relay-core.test.ts | 1111 +-- playwriter/src/relay-navigation.test.ts | 1002 ++- playwriter/src/relay-session.test.ts | 1899 ++-- playwriter/src/resource.md | 70 +- playwriter/src/scoped-fs.ts | 12 +- playwriter/src/screen-recording.ts | 69 +- playwriter/src/skill.md | 430 +- playwriter/src/snapshot-tools.test.ts | 1118 +-- playwriter/src/start-relay-server.ts | 23 +- playwriter/src/styles-examples.ts | 9 +- playwriter/src/styles.ts | 41 +- playwriter/src/test-declarations.ts | 12 +- playwriter/src/test-utils.ts | 199 +- playwriter/src/utils.ts | 3 +- playwriter/src/wait-for-page-load.ts | 7 +- playwriter/test/security.test.ts | 471 +- playwriter/vitest.setup.ts | 12 +- skills/playwriter/SKILL.md | 1 + slop/aria-snapshot-analysis.md | 76 +- slop/playwright-accessibility-findings.md | 190 +- tsconfig.base.json | 56 +- website/AGENTS.md | 12 +- website/public/.well-known/skills/index.json | 4 +- .../.well-known/skills/playwriter/SKILL.md | 1 + website/public/SKILL.md | 391 +- website/public/resources/debugger-api.md | 691 +- website/public/resources/editor-api.md | 343 +- website/public/resources/styles-api.md | 56 +- website/react-router.config.ts | 11 +- website/scripts/generate-placeholders.ts | 59 +- website/src/components/markdown.tsx | 613 +- website/src/entry.client.tsx | 10 +- website/src/entry.server.tsx | 130 +- website/src/lib/errors.ts | 28 +- website/src/lib/utils.ts | 10 +- website/src/root.tsx | 75 +- website/src/routes.ts | 6 +- website/src/routes/_index.tsx | 425 +- website/src/routes/defer-example.tsx | 14 +- website/src/routes/github.tsx | 8 +- website/src/styles/editorial-prism.css | 11 +- website/src/styles/editorial.css | 13 +- website/src/styles/globals.css | 481 +- website/vite.config.ts | 30 +- 101 files changed, 14997 insertions(+), 11339 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..cdb0271 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +playwright +pnpm-lock.yaml +claude-extension +opensrc diff --git a/.prettierrc.json b/.prettierrc.json index 06618c8..d34a627 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -5,6 +5,7 @@ "jsxSingleQuote": true, "tabWidth": 2, "semi": false, + "singleQuote": true, "trailingComma": "all" } diff --git a/AGENTS.md b/AGENTS.md index a6cdf9c..a953e99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ breaking changes to the WS protocol MUST never be made. publishing the extension ## architecture -- user installs the extension in chrome. we assume there is only one chrome window for now, the first opened. +- user installs the extension in chrome. we assume there is only one chrome window for now, the first opened. - extension connects to a websocket server on port 19988. if this server is not yet open, it retries connecting in a loop - the MCP spawns the ws server if not already listening on 19988, in background. the mcp then connects to this same server with a playwright client - the server exposes /cdp/client-id which is used by playwright clients to communicate with the extension @@ -54,10 +54,10 @@ to test CLI changes without publishing: ```bash # mac/linux: kill any existing relay on 19988 lsof -ti :19988 | xargs kill - + # windows (powershell): kill any existing relay on 19988 Get-NetTCPConnection -LocalPort 19988 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force } - + tsx playwriter/src/cli.ts -s 1 -e "await page.goto('https://example.com')" tsx playwriter/src/cli.ts -s 1 -e "console.log(await accessibilitySnapshot({ page }))" tsx playwriter/src/cli.ts session new @@ -96,10 +96,10 @@ tests use these utilities from `test-utils.ts`: ```ts // setup browser with extension loaded + relay server -const testCtx = await setupTestContext({ - port: 19987, +const testCtx = await setupTestContext({ + port: 19987, tempDirPrefix: 'pw-test-', - toggleExtension: true // creates initial page with extension enabled + toggleExtension: true, // creates initial page with extension enabled }) // get extension service worker to call extension functions @@ -122,7 +122,7 @@ import { createMCPClient } from './mcp-client.js' const { client, cleanup } = await createMCPClient({ port: 19987 }) const result = await client.callTool({ name: 'execute', - arguments: { code: 'await page.goto("https://example.com")' } + arguments: { code: 'await page.goto("https://example.com")' }, }) ``` @@ -171,10 +171,8 @@ when you do an any change, update relevant CHANGELOG.md files for each package. also bump package.json versions and IMPORTANTLY also the extension/manifest.json version! - you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version` - ## debugging playwriter mcp issues sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it @@ -209,7 +207,7 @@ cd playwright && git branch git checkout playwriter ``` -make sure to always bump the package json and update the +make sure to always bump the package json and update the ### bootstrapping the repo @@ -220,6 +218,7 @@ pnpm bootstrap ``` this does: + 1. `git submodule update --init` - init the playwright submodule 2. `pnpm install` - install deps and link workspace packages 3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/` @@ -240,16 +239,18 @@ upstream playwright bundles all dependencies into single files (zero runtime dep **1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps **2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly: + ```ts // before (bundled) -export const ws = require('./utilsBundleImpl').ws; +export const ws = require('./utilsBundleImpl').ws -// after (direct) -import wsLibrary from 'ws'; -export const ws = wsLibrary; +// after (direct) +import wsLibrary from 'ws' +export const ws = wsLibrary ``` **3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files: + ```bash # transpile src/**/*.ts → lib/**/*.js (0.1s) # copy third_party/lockfile.js, third_party/extract-zip.js @@ -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. -| | upstream | ours | -|---|---|---| -| build time | ~30s | 0.1s | +| | upstream | ours | +| ------------ | ----------- | -------------- | +| build time | ~30s | 0.1s | | dependencies | 0 (bundled) | ~20 (external) | -| trace-viewer | built | skipped | +| trace-viewer | built | skipped | ### 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 - `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 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. - NEVER silently suppress errors in catch {} blocks if they contain more than one function call + ```ts // BAD. DO NOT DO THIS -let favicon: string | undefined; +let favicon: string | undefined if (docsConfig?.favicon) { - if (typeof docsConfig.favicon === "string") { - favicon = docsConfig.favicon; + if (typeof docsConfig.favicon === 'string') { + favicon = docsConfig.favicon } else if (docsConfig.favicon?.light) { // Use light favicon as default, could be enhanced with theme detection - favicon = docsConfig.favicon.light; + favicon = docsConfig.favicon.light } } // DO THIS. use an iife. Immediately Invoked Function Expression const favicon: string = (() => { if (!docsConfig?.favicon) { - return ""; + return '' } - if (typeof docsConfig.favicon === "string") { - return docsConfig.favicon; + if (typeof docsConfig.favicon === 'string') { + return docsConfig.favicon } if (docsConfig.favicon?.light) { // Use light favicon as default, could be enhanced with theme detection - return docsConfig.favicon.light; + return docsConfig.favicon.light } - return ""; -})(); + return '' +})() // if you already know the type use it: const favicon: string = () => { // ... -}; +} ``` - when a package has to import files from another packages in the workspace never add a new tsconfig path, instead add that package as a workspace dependency using `pnpm i "package@workspace:*"` @@ -409,12 +411,12 @@ always specify the type when creating arrays, especially for empty arrays. if yo ```ts // BAD: Type will be never[] -const items = []; +const items = [] // GOOD: Specify the expected type -const items: string[] = []; -const numbers: number[] = []; -const users: User[] = []; +const items: string[] = [] +const numbers: number[] = [] +const users: User[] = [] ``` remember to always add the explicit type to avoid unexpected type inference. @@ -572,7 +574,7 @@ to understand how the code you are writing works, you should add inline snapshot - for very long snapshots you should use `toMatchFileSnapshot(filename)` instead of `toMatchInlineSnapshot()`. put the snapshot files in a snapshots/ directory and use the appropriate extension for the file based on the content -never test client react components. only React and browser independent code. +never test client react components. only React and browser independent code. most tests should be simple calls to functions with some expect calls, no mocks. test files should be called the same as the file where the tested function is being exported from. @@ -589,11 +591,12 @@ sometimes tests work directly on database data, using prisma. to run these tests never write tests yourself that call prisma or interact with database or emails. for these, ask the user to write them for you. changelogs.md + # writing docs when generating a .md or .mdx file to document things, always add a frontmatter with title and description. also add a prompt field with the exact prompt used to generate the doc. use @ to reference files and urls and provide any context necessary to be able to recreate this file from scratch using a model. if you used urls also reference them. reference all files you had to read to create the doc. use yaml | syntax to add this prompt and never go over the column width of 80 -# github +# github you can use the `gh` cli to do operations on github for the current repository. For example: open issues, open PRs, check actions status, read workflow logs, etc. @@ -677,6 +680,7 @@ This will download the source code in ./opensrc. which should be put in .gitigno you can control the browser using the playwright mcp tools. these tools let you control the browser to get information or accomplish actions if i ask you to test something in the browser, know that the website dev server is already running at http://localhost:7664 for website and :7777 for docs-website (but docs-website needs to use the website domain specifically, for example name-hash.localhost:7777) + # zod when you need to create a complex type that comes from a prisma table, do not create a new schema that tries to recreate the prisma table structure. instead just use `z.any() as ZodType)` to get type safety but leave any in the schema. this gets most of the benefits of zod without having to define a new zod schema that can easily go out of sync. @@ -686,17 +690,17 @@ when you need to create a complex type that comes from a prisma table, do not cr you MUST use the built in zod v4 toJSONSchema and not the npm package `zod-to-json-schema` which is outdated and does not support zod v4. ```ts -import { toJSONSchema } from "zod"; +import { toJSONSchema } from 'zod' const mySchema = z.object({ id: z.string().uuid(), name: z.string().min(3).max(100), age: z.number().min(0).optional(), -}); +}) const jsonSchema = toJSONSchema(mySchema, { - removeAdditionalStrategy: "strict", -}); + removeAdditionalStrategy: 'strict', +}) ``` github.md @@ -722,4 +726,4 @@ npx opensrc crates: # Rust crate (e.g., npx opensrc crates:serde) npx opensrc / # GitHub repo (e.g., npx opensrc vercel/ai) ``` - \ No newline at end of file + diff --git a/MCP.md b/MCP.md index 7306d6e..192841c 100644 --- a/MCP.md +++ b/MCP.md @@ -28,6 +28,7 @@ npx -y @playwriter/install-mcp playwriter@latest 3. Use the `execute` tool to run Playwright code The MCP exposes: + - `execute` tool - run Playwright code snippets - `reset` tool - reconnect if connection issues occur diff --git a/PLAYWRITER_AGENTS.md b/PLAYWRITER_AGENTS.md index 393af6a..59c0dfc 100644 --- a/PLAYWRITER_AGENTS.md +++ b/PLAYWRITER_AGENTS.md @@ -11,7 +11,7 @@ breaking changes to the WS protocol MUST never be made. publishing the extension ## architecture -- user installs the extension in chrome. we assume there is only one chrome window for now, the first opened. +- user installs the extension in chrome. we assume there is only one chrome window for now, the first opened. - extension connects to a websocket server on port 19988. if this server is not yet open, it retries connecting in a loop - the MCP spawns the ws server if not already listening on 19988, in background. the mcp then connects to this same server with a playwright client - the server exposes /cdp/client-id which is used by playwright clients to communicate with the extension @@ -52,10 +52,10 @@ to test CLI changes without publishing: ```bash # mac/linux: kill any existing relay on 19988 lsof -ti :19988 | xargs kill - + # windows (powershell): kill any existing relay on 19988 Get-NetTCPConnection -LocalPort 19988 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force } - + tsx playwriter/src/cli.ts -s 1 -e "await page.goto('https://example.com')" tsx playwriter/src/cli.ts -s 1 -e "console.log(await snapshot({ page }))" tsx playwriter/src/cli.ts session new @@ -94,10 +94,10 @@ tests use these utilities from `test-utils.ts`: ```ts // setup browser with extension loaded + relay server -const testCtx = await setupTestContext({ - port: 19987, +const testCtx = await setupTestContext({ + port: 19987, tempDirPrefix: 'pw-test-', - toggleExtension: true // creates initial page with extension enabled + toggleExtension: true, // creates initial page with extension enabled }) // get extension service worker to call extension functions @@ -120,7 +120,7 @@ import { createMCPClient } from './mcp-client.js' const { client, cleanup } = await createMCPClient({ port: 19987 }) const result = await client.callTool({ name: 'execute', - arguments: { code: 'await page.goto("https://example.com")' } + arguments: { code: 'await page.goto("https://example.com")' }, }) ``` @@ -169,10 +169,8 @@ when you do an any change, update relevant CHANGELOG.md files for each package. also bump package.json versions and IMPORTANTLY also the extension/manifest.json version! - you also MUST always bump the playwright core package.json version too on any changes made there. so during publishing we know if that package needs to also be published, first, before publishing playwriter. checking if its version is already publishing in npm with `npm show @xmorse/playwright-core version` - ## debugging playwriter mcp issues sometimes the user will ask you to debug an mcp issue. to do this you may want to add logs to the mcp and server. to do this you will also need to restart the server so we use the latest code. restarting the mcp yourself is not possible. instead you will need to ask the user to do it or write a test case, where the mcp can be reloaded. also making changes in the extension will not work. you will have to write a test case for that to work. you can ask the user to reconnect these too. for reloading the extension you can run the `pnpm build` script and do `osascript -e 'tell application "Google Chrome" to open location "chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid"'` to make it easier for the user to reload it @@ -207,7 +205,7 @@ cd playwright && git branch git checkout playwriter ``` -make sure to always bump the package json and update the +make sure to always bump the package json and update the ### bootstrapping the repo @@ -218,6 +216,7 @@ pnpm bootstrap ``` this does: + 1. `git submodule update --init` - init the playwright submodule 2. `pnpm install` - install deps and link workspace packages 3. `node playwright/utils/generate_injected.js` - generate browser scripts to `src/generated/` @@ -238,16 +237,18 @@ upstream playwright bundles all dependencies into single files (zero runtime dep **1. dependencies in package.json** - ws, debug, pngjs, commander, etc. are regular deps **2. rewritten bundle files** - `playwright/packages/playwright-core/src/utilsBundle.ts`, `zipBundle.ts`, `mcpBundle.ts` import directly: + ```ts // before (bundled) -export const ws = require('./utilsBundleImpl').ws; +export const ws = require('./utilsBundleImpl').ws -// after (direct) -import wsLibrary from 'ws'; -export const ws = wsLibrary; +// after (direct) +import wsLibrary from 'ws' +export const ws = wsLibrary ``` **3. simple build script** (`playwright/packages/playwright-core/build.mjs`) - just esbuild transpile + copy vendored files: + ```bash # transpile src/**/*.ts → lib/**/*.js (0.1s) # copy third_party/lockfile.js, third_party/extract-zip.js @@ -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. -| | upstream | ours | -|---|---|---| -| build time | ~30s | 0.1s | +| | upstream | ours | +| ------------ | ----------- | -------------- | +| build time | ~30s | 0.1s | | dependencies | 0 (bundled) | ~20 (external) | -| trace-viewer | built | skipped | +| trace-viewer | built | skipped | ### 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 - `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 jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c diff --git a/README.md b/README.md index d482766..c516820 100644 --- a/README.md +++ b/README.md @@ -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. -| | Playwright MCP | Playwriter | -|---|---|---| -| Browser | Spawns new Chrome | **Uses your Chrome** | -| Extensions | None | Your existing ones | -| Login state | Fresh | Already logged in | -| Bot detection | Always detected | Can bypass (disconnect extension) | -| Collaboration | Separate window | Same browser as user | +| | Playwright MCP | Playwriter | +| ------------- | ----------------- | --------------------------------- | +| Browser | Spawns new Chrome | **Uses your Chrome** | +| Extensions | None | Your existing ones | +| Login state | Fresh | Already logged in | +| Bot detection | Always detected | Can bypass (disconnect extension) | +| Collaboration | Separate window | Same browser as user | ## 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 3. Install the CLI and start automating the browser: + ```bash npm i -g playwriter playwriter -s 1 -e "await page.goto('https://example.com')" @@ -83,12 +84,14 @@ console.log({ title, url: page.url() }); Variables in scope: `page`, `context`, `state` (persists between calls), `require`, and Node.js globals. **Persist data in state:** + ```bash playwriter -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))" playwriter -e "console.log(state.users)" ``` **Intercept network requests:** + ```bash playwriter -e "state.requests = []; page.on('response', r => { if (r.url().includes('/api/')) state.requests.push(r.url()) })" playwriter -e "await Promise.all([page.waitForResponse(r => r.url().includes('/api/')), page.click('button')])" @@ -96,6 +99,7 @@ playwriter -e "console.log(state.requests)" ``` **Set breakpoints and debug:** + ```bash playwriter -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()" playwriter -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))" @@ -103,12 +107,14 @@ playwriter -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: ``` **Live edit page code:** + ```bash playwriter -e "state.cdp = await getCDPSession({ page }); state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()" playwriter -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })" ``` **Screenshot with labels:** + ```bash playwriter -e "await screenshotWithAccessibilityLabels({ page })" ``` @@ -133,33 +139,33 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach= ### vs BrowserMCP -| | BrowserMCP | Playwriter | -|---|---|---| -| Tools | 12+ dedicated tools | 1 `execute` tool | -| API | Limited actions | Full Playwright | -| Context usage | High (tool schemas) | Low | -| LLM knowledge | Must learn tools | Already knows Playwright | +| | BrowserMCP | Playwriter | +| ------------- | ------------------- | ------------------------ | +| Tools | 12+ dedicated tools | 1 `execute` tool | +| API | Limited actions | Full Playwright | +| Context usage | High (tool schemas) | Low | +| LLM knowledge | Must learn tools | Already knows Playwright | ### vs Antigravity (Jetski) -| | Jetski | Playwriter | -|---|---|---| -| Tools | 17+ tools | 1 tool | +| | Jetski | Playwriter | +| -------- | ---------------------------- | ---------------- | +| Tools | 17+ tools | 1 tool | | Subagent | Spawns for each browser task | Direct execution | -| Latency | High (agent overhead) | Low | +| Latency | High (agent overhead) | Low | ### vs Claude Browser Extension -| | Claude Extension | Playwriter | -|---|---|---| -| Agent support | Claude only | Any MCP client | -| Windows WSL | No | Yes | -| Context method | Screenshots (100KB+) | A11y snapshots (5-20KB) | -| Playwright API | No | Full | -| Debugger/breakpoints | No | Yes | -| Live code editing | No | Yes | -| Network interception | Limited | Full | -| Raw CDP access | No | Yes | +| | Claude Extension | Playwriter | +| -------------------- | -------------------- | ----------------------- | +| Agent support | Claude only | Any MCP client | +| Windows WSL | No | Yes | +| Context method | Screenshots (100KB+) | A11y snapshots (5-20KB) | +| Playwright API | No | Full | +| Debugger/breakpoints | No | Yes | +| Live code editing | No | Yes | +| Network interception | Limited | Full | +| Raw CDP access | No | Yes | ## 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: **On host:** + ```bash npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token ``` **From remote:** + ```bash export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev export PLAYWRITER_TOKEN= diff --git a/docs/framer-iframe-snapshot-guide.md b/docs/framer-iframe-snapshot-guide.md index d1b9a68..6b6ec09 100644 --- a/docs/framer-iframe-snapshot-guide.md +++ b/docs/framer-iframe-snapshot-guide.md @@ -18,6 +18,7 @@ prompt: | - Always reuse an existing Framer tab when possible (do not open a new page each run). Use this pattern to pick an existing page first, then navigate only if needed: + ```bash playwriter -s 1 -e "const target = 'https://framer.com/projects/unframer-source--XOxwdyyCrFEE9uKnKFPq-6gX7n?node=augiA20Il'; const framerPage = context.pages().find((p) => p.url().includes('framer.com/projects/unframer-source')) || page; if (!framerPage.url().includes('framer.com/projects/unframer-source')) { await framerPage.goto(target, { waitUntil: 'domcontentloaded' }); } console.log(framerPage.url());" ``` @@ -32,6 +33,7 @@ playwriter -s 1 -e "const target = 'https://framer.com/projects/unframer-source- - Press Command+K to open the command palette. - Verify the palette is open (look for the command dialog and MCP entry in the snapshot output): + ```bash playwriter -s 1 -e "console.log(await snapshot({ page, search: /dialog|Search…|MCP/ }));" ``` @@ -39,31 +41,37 @@ playwriter -s 1 -e "console.log(await snapshot({ page, search: /dialog|Search… - Search for **MCP**, press Enter, then wait about 1 second for the plugin iframe to appear. - Verify the plugin iframe exists (should include `plugins.framercdn.com`): + ```bash playwriter -s 1 -e "const iframes = await page.locator('iframe').all(); for (const f of iframes) { console.log(await f.getAttribute('src')); }" ``` - Wait until the MCP iframe is present (verifies the action worked): + ```bash playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); await iframe.first().waitFor({ timeout: 10000 }); console.log('iframe ready');" ``` - Grab the iframe’s locator by URL: + ```bash playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await iframe.count());" ``` - Run the accessibility snapshot on that iframe using `contentFrame()` (FrameLocator is auto-resolved to Frame): + ```bash playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await snapshot({ page, frame }));" ``` - Alternative: use `page.frames()` to get the Frame directly: + ```bash playwriter -s 1 -e "const frame = page.frames().find(f => f.url().includes('plugins.framercdn.com')); console.log(await snapshot({ page, frame }));" ``` - Validate the snapshot contains MCP UI text (confirms the panel is actually loaded): + ```bash playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await snapshot({ page, frame, search: /Control Framer with MCP|Login With Google/ }));" ``` diff --git a/docs/remote-access.md b/docs/remote-access.md index 7bd8a16..9da8b7f 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -134,9 +134,7 @@ The env vars tell the MCP to skip starting a local relay and connect to the remo ```typescript import { chromium } from 'playwright-core' -const browser = await chromium.connectOverCDP( - 'wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN' -) +const browser = await chromium.connectOverCDP('wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN') const page = browser.contexts()[0].pages()[0] await page.goto('https://example.com') // Don't call browser.close() - it would close the user's Chrome @@ -176,11 +174,11 @@ done ### Environment variables -| Variable | Description | -|---|---| -| `PLAYWRITER_HOST` | Remote relay URL (e.g. `https://x-tunnel.traforo.dev`) or IP (e.g. `192.168.1.10`) | -| `PLAYWRITER_TOKEN` | Authentication token for the relay server | -| `PLAYWRITER_PORT` | Override relay port (default: `19988`, not needed with traforo) | +| Variable | Description | +| ------------------ | ---------------------------------------------------------------------------------- | +| `PLAYWRITER_HOST` | Remote relay URL (e.g. `https://x-tunnel.traforo.dev`) or IP (e.g. `192.168.1.10`) | +| `PLAYWRITER_TOKEN` | Authentication token for the relay server | +| `PLAYWRITER_PORT` | Override relay port (default: `19988`, not needed with traforo) | ### Recommendations diff --git a/extension/manifest.json b/extension/manifest.json index 9dcdeaf..5dd81f0 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -3,7 +3,16 @@ "name": "Playwriter", "version": "0.0.71", "description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", - "permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen", "identity", "identity.email"], + "permissions": [ + "debugger", + "tabGroups", + "contextMenus", + "tabs", + "tabCapture", + "offscreen", + "identity", + "identity.email" + ], "host_permissions": [""], "background": { "service_worker": "background.js", diff --git a/extension/package.json b/extension/package.json index f360b32..7770c3f 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,35 +1,35 @@ { - "name": "mcp-extension", - "version": "0.0.71", - "description": "Playwright MCP Browser Extension", - "private": true, - "repository": { - "type": "git", - "url": "git+https://github.com/remorses/playwriter.git" - }, - "homepage": "https://github.com/remorses/playwriter", - "engines": { - "node": ">=18" - }, - "author": { - "name": "Microsoft Corporation" - }, - "license": "Apache-2.0", - "scripts": { - "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\"'", - "watch": "vite build --watch --config vite.config.mts", - "clean": "rm -rf dist" - }, - "devDependencies": { - "@types/chrome": "^0.0.315", - "concurrently": "^8.2.2", - "typescript": "^5.8.2", - "vite": "^5.4.21", - "vite-plugin-static-copy": "^3.1.1" - }, - "dependencies": { - "playwriter": "workspace:*", - "zustand": "^5.0.8" - } + "name": "mcp-extension", + "version": "0.0.71", + "description": "Playwright MCP Browser Extension", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/remorses/playwriter.git" + }, + "homepage": "https://github.com/remorses/playwriter", + "engines": { + "node": ">=18" + }, + "author": { + "name": "Microsoft Corporation" + }, + "license": "Apache-2.0", + "scripts": { + "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\"'", + "watch": "vite build --watch --config vite.config.mts", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@types/chrome": "^0.0.315", + "concurrently": "^8.2.2", + "typescript": "^5.8.2", + "vite": "^5.4.21", + "vite-plugin-static-copy": "^3.1.1" + }, + "dependencies": { + "playwriter": "workspace:*", + "zustand": "^5.0.8" + } } diff --git a/extension/permissions.md b/extension/permissions.md index 2f7c26c..03ab8a0 100644 --- a/extension/permissions.md +++ b/extension/permissions.md @@ -19,10 +19,12 @@ Essential for core functionality. This permission allows the extension to attach **Note: This permission is automatically removed during production builds and is only included in test builds.** The tabs permission is only needed during development/testing to: + - Access the URL property of tabs for test identification (finding tabs by URL pattern) - Query all tabs with full information for test assertions In production, the extension functions perfectly without the tabs permission because: + - Tab event listeners (onRemoved, onActivated, onUpdated) work without it - chrome.tabs.create() and chrome.tabs.remove() work without it - chrome.tabs.query() for active tab works without it @@ -44,11 +46,13 @@ All extension code (JavaScript, HTML, CSS) is fully bundled within the extension The extension establishes a WebSocket connection to `ws://localhost:19988` - a local server running on the user's own machine. This connection is used exclusively for **message passing** (sending and receiving JSON data), NOT code execution. **What the WebSocket is used for:** + - Receiving CDP (Chrome DevTools Protocol) command messages in JSON format from local Playwright scripts - Forwarding these command messages to attached browser tabs via the `chrome.debugger` API - Sending CDP event messages back to the local Playwright scripts **What it is NOT used for:** + - Downloading or executing JavaScript, WebAssembly, or any other executable code - Connecting to external/remote servers (strictly localhost only) - Loading remote configurations that modify extension behavior @@ -66,6 +70,7 @@ This is functionally similar to Native Messaging but uses WebSockets for cross-p ## Screenshots Required Need to provide at least one screenshot showing: + - Extension icon in toolbar (gray when disconnected, green when connected) - Extension attached to a tab with Chrome's "debugging this browser" banner visible - Welcome page or usage demonstration diff --git a/extension/scripts/download-prism.ts b/extension/scripts/download-prism.ts index 5ee18a0..050bdc6 100644 --- a/extension/scripts/download-prism.ts +++ b/extension/scripts/download-prism.ts @@ -14,19 +14,23 @@ const files: [string, string][] = [ function download(url: string, dest: string): Promise { return new Promise((resolve, reject) => { - https.get(url, (res) => { - if (res.statusCode !== 200) { - reject(new Error(`Failed to download ${url}: ${res.statusCode}`)) - return - } - const chunks: Buffer[] = [] - res.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - res.on('end', () => { - fs.writeFileSync(dest, Buffer.concat(chunks)) - resolve() + https + .get(url, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to download ${url}: ${res.statusCode}`)) + return + } + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => { + chunks.push(chunk) + }) + res.on('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( files.map(([src, dest]) => { return download(BASE + src, path.join(DEST, dest)) - }) + }), ) console.log(`Downloaded ${files.length} Prism.js files to ${DEST}`) } diff --git a/extension/src/background.ts b/extension/src/background.ts index e876f5f..db75a20 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -32,7 +32,6 @@ type ExtensionIdentity = { id: string } - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -118,19 +117,21 @@ function flushRecordingChunkBuffer(ws: WebSocket): void { if (recordingChunkBuffer.length === 0) { return } - + logger.debug(`Flushing ${recordingChunkBuffer.length} buffered recording chunks`) - + while (recordingChunkBuffer.length > 0) { const chunk = recordingChunkBuffer.shift()! const { tabId, data, final } = chunk - + // Send metadata message first - ws.send(JSON.stringify({ - method: 'recordingData', - params: { tabId, final }, - })) - + ws.send( + JSON.stringify({ + method: 'recordingData', + params: { tabId, final }, + }), + ) + // Then send binary data if not final if (data && !final) { const buffer = new Uint8Array(data) @@ -168,7 +169,7 @@ class ConnectionManager { setTimeout(() => { reject(new Error('Connection timeout (global)')) }, GLOBAL_TIMEOUT_MS) - }) + }), ]) try { @@ -232,10 +233,10 @@ class ConnectionManager { settled = true logger.debug('WebSocket connected') clearTimeout(timeout) - + // Flush any buffered recording chunks now that WebSocket is ready flushRecordingChunkBuffer(socket) - + resolve() } @@ -417,7 +418,9 @@ class ConnectionManager { const mem = performance.memory if (mem) { const formatMB = (b: number) => (b / 1024 / 1024).toFixed(2) + 'MB' - logger.warn(`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`) + logger.warn( + `DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`, + ) } } catch {} logger.warn(`DISCONNECT: WS closed code=${code} reason=${reason || 'none'} stack=${getCallStack()}`) @@ -484,12 +487,21 @@ class ConnectionManager { // Slot is free when: no extension connected, OR connected but no active tabs. if (store.getState().connectionState === 'extension-replaced') { try { - const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) }) - const data = await response.json() as { connected: boolean; activeTargets: number } + const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { + method: 'GET', + signal: AbortSignal.timeout(2000), + }) + const data = (await response.json()) as { connected: boolean; activeTargets: number } const slotAvailable = !data.connected || data.activeTargets === 0 if (slotAvailable) { store.setState({ connectionState: 'idle', errorText: undefined }) - logger.debug('Extension slot is free (connected:', data.connected, 'activeTargets:', data.activeTargets, '), cleared error state') + logger.debug( + 'Extension slot is free (connected:', + data.connected, + 'activeTargets:', + data.activeTargets, + '), cleared error state', + ) } else { logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...') } @@ -500,26 +512,26 @@ class ConnectionManager { continue } - // 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 - const currentTabs = store.getState().tabs - const hasConnectedTabs = Array.from(currentTabs.values()).some((t) => t.state === 'connected') - if (hasConnectedTabs) { - store.setState((state) => { - const newTabs = new Map(state.tabs) - for (const [tabId, tab] of newTabs) { - if (tab.state === 'connected') { - newTabs.set(tabId, { ...tab, state: 'connecting' }) + // 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 + const currentTabs = store.getState().tabs + const hasConnectedTabs = Array.from(currentTabs.values()).some((t) => t.state === 'connected') + if (hasConnectedTabs) { + store.setState((state) => { + const newTabs = new Map(state.tabs) + for (const [tabId, tab] of newTabs) { + if (tab.state === 'connected') { + newTabs.set(tabId, { ...tab, state: 'connecting' }) + } } - } - return { tabs: newTabs } - }) - } + return { tabs: newTabs } + }) + } - // Try to connect silently in background - don't show 'connecting' badge - // Individual tab states will show 'connecting' when user explicitly clicks - try { - await this.ensureConnection() + // Try to connect silently in background - don't show 'connecting' badge + // Individual tab states will show 'connecting' when user explicitly clicks + try { + await this.ensureConnection() store.setState({ connectionState: 'connected' }) // 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 { - const childEntries = Array.from(childSessions.entries()) - .filter(([_, parentTab]) => parentTab.tabId === tabId) + const childEntries = Array.from(childSessions.entries()).filter(([_, parentTab]) => parentTab.tabId === tabId) childEntries.forEach(([childSessionId, parentTab]) => { const childDetachParams: Protocol.Target.DetachedFromTargetEvent = parentTab.targetId @@ -843,13 +854,15 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise { .filter(([_, info]) => info.state === 'connected') .map(([tabId]) => tabId) - await Promise.all(connectedTabIds.map(async (tabId) => { - try { - await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params) - } catch (error) { - logger.debug('Failed to set auto-attach for tab:', tabId, error) - } - })) + await Promise.all( + connectedTabIds.map(async (tabId) => { + try { + await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params) + } catch (error) { + logger.debug('Failed to set auto-attach for tab:', tabId, error) + } + }), + ) return {} } @@ -1007,7 +1020,10 @@ type AttachTabResult = { sessionId: string } -async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {}): Promise { +async function attachTab( + tabId: number, + { skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {}, +): Promise { const debuggee = { tabId } let debuggerAttached = false @@ -1045,7 +1061,12 @@ async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAtt // Log error if URL is empty - this causes Playwright to create broken pages if (!targetInfo.url || targetInfo.url === '' || targetInfo.url === ':') { - logger.error('WARNING: Target.attachedToTarget will be sent with empty URL! tabId:', tabId, 'targetInfo:', JSON.stringify(targetInfo)) + logger.error( + 'WARNING: Target.attachedToTarget will be sent with empty URL! tabId:', + tabId, + 'targetInfo:', + JSON.stringify(targetInfo), + ) } const attachOrder = nextSessionId @@ -1076,7 +1097,18 @@ async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAtt }) } - logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId, 'url:', targetInfo.url, 'skipAttachedEvent:', skipAttachedEvent) + logger.debug( + 'Tab attached successfully:', + tabId, + 'sessionId:', + sessionId, + 'targetId:', + targetInfo.targetId, + 'url:', + targetInfo.url, + 'skipAttachedEvent:', + skipAttachedEvent, + ) return { targetInfo, sessionId } } catch (error) { // Clean up debugger if we attached but failed later @@ -1127,8 +1159,6 @@ function detachTab(tabId: number, shouldDetachDebugger: boolean): void { } } - - async function connectTab(tabId: number): Promise { try { logger.debug(`Starting connection to tab ${tabId}`) @@ -1264,7 +1294,13 @@ function isRestrictedUrl(url: string | undefined): boolean { return !OUR_EXTENSION_IDS.includes(extensionId) } - const restrictedPrefixes = ['chrome://', 'devtools://', 'edge://', 'https://chrome.google.com/', 'https://chromewebstore.google.com/'] + const restrictedPrefixes = [ + 'chrome://', + 'devtools://', + 'edge://', + 'https://chrome.google.com/', + 'https://chromewebstore.google.com/', + ] return restrictedPrefixes.some((prefix) => url.startsWith(prefix)) } @@ -1434,14 +1470,17 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise { resetDebugger() connectionManager.maintainLoop() -chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => { - chrome.contextMenus.create({ - id: 'playwriter-pin-element', - title: 'Copy Playwriter Element Reference', - contexts: ['all'], - visible: false, +chrome.contextMenus + .remove('playwriter-pin-element') + .catch(() => {}) + .finally(() => { + chrome.contextMenus.create({ + id: 'playwriter-pin-element', + title: 'Copy Playwriter Element Reference', + contexts: ['all'], + visible: false, + }) }) -}) function updateContextMenuVisibility(): void { const { currentTabId, tabs } = store.getState() @@ -1501,11 +1540,17 @@ function checkMemory(): void { // Log if memory is high or growing rapidly if (used > MEMORY_CRITICAL_THRESHOLD) { - logger.error(`MEMORY CRITICAL: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`) + logger.error( + `MEMORY CRITICAL: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`, + ) } else if (used > MEMORY_WARNING_THRESHOLD) { - logger.warn(`MEMORY WARNING: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`) + logger.warn( + `MEMORY WARNING: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`, + ) } else if (memoryDelta > MEMORY_GROWTH_THRESHOLD && timeDelta < 60000) { - logger.warn(`MEMORY SPIKE: grew ${formatMB(memoryDelta)} in ${(timeDelta / 1000).toFixed(1)}s (used=${formatMB(used)})`) + logger.warn( + `MEMORY SPIKE: grew ${formatMB(memoryDelta)} in ${(timeDelta / 1000).toFixed(1)}s (used=${formatMB(used)})`, + ) } lastMemoryUsage = used @@ -1528,31 +1573,33 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { void updateIcons() if (changeInfo.groupId !== undefined) { // Queue tab group operations to serialize with syncTabGroup and disconnectEverything - tabGroupQueue = tabGroupQueue.then(async () => { - // Query for playwriter group by title - no stale cached ID - const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' }) - const groupId = existingGroups[0]?.id - if (groupId === undefined) { - return - } - 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) + tabGroupQueue = tabGroupQueue + .then(async () => { + // Query for playwriter group by title - no stale cached ID + const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' }) + const groupId = existingGroups[0]?.id + if (groupId === undefined) { return } - logger.debug('Tab manually removed from playwriter group:', tabId) - await disconnectTab(tabId) - } - }).catch((e) => { - logger.debug('onTabUpdated handler error:', e) - }) + 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 + } + logger.debug('Tab manually removed from playwriter group:', tabId) + await disconnectTab(tabId) + } + }) + .catch((e) => { + logger.debug('onTabUpdated handler error:', e) + }) } }) @@ -1634,14 +1681,14 @@ void updateIcons() chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => { if (message.action === 'recordingChunk') { const { tabId, data, final } = message - + if (connectionManager.ws?.readyState === WebSocket.OPEN) { // Send metadata message first sendMessage({ method: 'recordingData', params: { tabId, final }, }) - + // Then send binary data if not final if (data && !final) { const buffer = new Uint8Array(data) @@ -1653,13 +1700,13 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => { logger.debug(`Buffering recording chunk for tab ${tabId} (WebSocket not ready)`) recordingChunkBuffer.push({ tabId, data, final }) } - + return false // Sync response, no need to keep channel open } - + if (message.action === 'recordingCancelled') { const { tabId } = message - + getActiveRecordings().delete(tabId) store.setState((state) => { const newTabs = new Map(state.tabs) @@ -1669,16 +1716,16 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => { } return { tabs: newTabs } }) - + if (connectionManager.ws?.readyState === WebSocket.OPEN) { sendMessage({ method: 'recordingCancelled', params: { tabId }, }) } - + return false } - + return false }) diff --git a/extension/src/ghost-browser-api.d.ts b/extension/src/ghost-browser-api.d.ts index ac1e299..3a11246 100644 --- a/extension/src/ghost-browser-api.d.ts +++ b/extension/src/ghost-browser-api.d.ts @@ -93,10 +93,7 @@ declare namespace chrome { * identity: chrome.ghostPublicAPI.NEW_TEMPORARY_IDENTITY * }, (tabId) => console.log('Opened tab:', tabId)) */ - export function openTab( - params: OpenTabParams, - callback?: (tabId: number) => void - ): Promise + export function openTab(params: OpenTabParams, callback?: (tabId: number) => void): Promise } // ============================================================================ @@ -151,107 +148,73 @@ declare namespace chrome { } // Proxy CRUD operations - export function add( - proxy: AddProxyParams, - callback?: (proxy: GhostProxy) => void - ): Promise + export function add(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise - export function import_( - proxy: AddProxyParams, - callback?: (proxy: GhostProxy) => void - ): Promise + export function import_(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise - export function remove( - proxy_id: string, - callback?: (success: boolean) => void - ): Promise + export function remove(proxy_id: string, callback?: (success: boolean) => void): Promise export function removeAll(callback?: (success: boolean) => void): Promise export function getList(callback?: (proxies: GhostProxy[]) => void): Promise - export function get( - proxy_id: string, - callback?: (proxy: GhostProxy) => void - ): Promise + export function get(proxy_id: string, callback?: (proxy: GhostProxy) => void): Promise - export function move( - proxy_id: string, - new_index: number, - callback?: (success: boolean) => void - ): Promise + export function move(proxy_id: string, new_index: number, callback?: (success: boolean) => void): Promise export function modify( proxy_id: string, params: ModifyProxyParams, - callback?: (proxy: GhostProxy) => void + callback?: (proxy: GhostProxy) => void, ): Promise // Set proxy at different levels export function setProjectProxy( proxy_id: string, keep_overrides: boolean, - callback?: (success: boolean) => void + callback?: (success: boolean) => void, ): Promise export function setSessionProxy( session_id: string, proxy_id: string, keep_overrides: boolean, - callback?: (success: boolean) => void + callback?: (success: boolean) => void, ): Promise export function setIdentityProxy( identity_id: string, proxy_id: string, - callback?: (success: boolean) => void + callback?: (success: boolean) => void, ): Promise export function setTabProxy( tab_id: number, proxy_id: string, - callback?: (success: boolean) => void + callback?: (success: boolean) => void, ): Promise // Get proxy at different levels export function getProjectProxy(callback?: (proxy: GhostProxy) => void): Promise - export function getSessionProxy( - session_id: string, - callback?: (proxy: GhostProxy) => void - ): Promise + export function getSessionProxy(session_id: string, callback?: (proxy: GhostProxy) => void): Promise - export function getIdentityProxy( - identity_id: string, - callback?: (proxy: GhostProxy) => void - ): Promise + export function getIdentityProxy(identity_id: string, callback?: (proxy: GhostProxy) => void): Promise - export function getTabProxy( - tab_id: number, - callback?: (proxy: GhostProxy) => void - ): Promise + export function getTabProxy(tab_id: number, callback?: (proxy: GhostProxy) => void): Promise // Clear proxy at different levels - export function clearProjectProxy( - keep_overrides: boolean, - callback?: (success: boolean) => void - ): Promise + export function clearProjectProxy(keep_overrides: boolean, callback?: (success: boolean) => void): Promise export function clearSessionProxy( session_id: string, keep_overrides: boolean, - callback?: (success: boolean) => void + callback?: (success: boolean) => void, ): Promise - export function clearIdentityProxy( - identity_id: string, - callback?: (success: boolean) => void - ): Promise + export function clearIdentityProxy(identity_id: string, callback?: (success: boolean) => void): Promise - export function clearTabProxy( - tab_id: number, - callback?: (success: boolean) => void - ): Promise + export function clearTabProxy(tab_id: number, callback?: (success: boolean) => void): Promise // Events export const onAdded: chrome.events.Event<(proxy: GhostProxy) => void> @@ -259,15 +222,9 @@ declare namespace chrome { export const onChanged: chrome.events.Event<(proxy: GhostProxy) => void> export const onMoved: chrome.events.Event<(proxy: GhostProxy, old_index: number) => void> export const onProjectProxyChanged: chrome.events.Event<(proxy: GhostProxy) => void> - export const onSessionProxyChanged: chrome.events.Event< - (session_id: string, proxy: GhostProxy) => void - > - export const onTabProxyChanged: chrome.events.Event< - (tab_id: number, proxy: GhostProxy) => void - > - export const onIdentityProxyChanged: chrome.events.Event< - (identity_id: string, proxy: GhostProxy) => void - > + export const onSessionProxyChanged: chrome.events.Event<(session_id: string, proxy: GhostProxy) => void> + export const onTabProxyChanged: chrome.events.Event<(tab_id: number, proxy: GhostProxy) => void> + export const onIdentityProxyChanged: chrome.events.Event<(identity_id: string, proxy: GhostProxy) => void> } // ============================================================================ @@ -405,42 +362,24 @@ declare namespace chrome { } // Project functions - export function getProjectsList( - callback?: (projects: GhostProject[]) => void - ): Promise + export function getProjectsList(callback?: (projects: GhostProject[]) => void): Promise - export function getProject( - project_id: string, - callback?: (project: GhostProject) => void - ): Promise + export function getProject(project_id: string, callback?: (project: GhostProject) => void): Promise - export function getActiveProject( - callback?: (project: GhostProject) => void - ): Promise + export function getActiveProject(callback?: (project: GhostProject) => void): Promise - export function addProject( - project: AddProjectDetails, - callback?: () => void - ): Promise + export function addProject(project: AddProjectDetails, callback?: () => void): Promise export function removeProject(project_id: string, callback?: () => void): Promise - export function moveProject( - project_id: string, - new_index: number, - callback?: () => void - ): Promise + export function moveProject(project_id: string, new_index: number, callback?: () => void): Promise - export function renameProject( - project_id: string, - project_name: string, - callback?: () => void - ): Promise + export function renameProject(project_id: string, project_name: string, callback?: () => void): Promise export function setProjectDescription( project_id: string, project_description: string, - callback?: () => void + callback?: () => void, ): Promise export function lockProject(project_id: string, callback?: () => void): Promise @@ -448,141 +387,115 @@ declare namespace chrome { export function openProject(project_id: string, callback?: () => void): Promise // Archived projects - export function getArchivedProjects( - callback?: (projects: ArchivedProject[]) => void - ): Promise + export function getArchivedProjects(callback?: (projects: ArchivedProject[]) => void): Promise export function archiveProject(project_id: string, callback?: () => void): Promise - export function restoreArchivedProject( - project_id: string, - callback?: () => void - ): Promise + export function restoreArchivedProject(project_id: string, callback?: () => void): Promise - export function deleteArchivedProject( - project_id: string, - callback?: () => void - ): Promise + export function deleteArchivedProject(project_id: string, callback?: () => void): Promise // Session functions export function getSessionsList( project_id: string, - callback?: (sessions: GhostSession[]) => void + callback?: (sessions: GhostSession[]) => void, ): Promise export function getSession( project_id: string, session_id: string, - callback?: (session: GhostSession) => void + callback?: (session: GhostSession) => void, ): Promise export function renameSession( project_id: string, session_id: string, session_name: string, - callback?: () => void + callback?: () => void, ): Promise export function changeSessionColor( project_id: string, session_id: string, session_color: string, - callback?: () => void + callback?: () => void, ): Promise export function clearSessionData( project_id: string, session_id: string, type: ClearSessionDataType, - callback?: () => void + callback?: () => void, ): Promise // Identity functions - export function getIdentitiesList( - callback?: (identities: GhostIdentity[]) => void - ): Promise + export function getIdentitiesList(callback?: (identities: GhostIdentity[]) => void): Promise export function sortIdentitiesList( condition: IdentitySortCondition, desc: boolean, - callback?: (identities: GhostIdentity[]) => void + callback?: (identities: GhostIdentity[]) => void, ): Promise export function getIdentity( identity_id: string, - callback?: (identity: GhostIdentity) => void + callback?: (identity: GhostIdentity) => void, ): Promise export function addIdentity( identity: AddIdentityDetails, - callback?: (identity: GhostIdentity) => void + callback?: (identity: GhostIdentity) => void, ): Promise export function removeIdentity(identity_id: string, callback?: () => void): Promise - export function moveIdentity( - identity_id: string, - new_index: number, - callback?: () => void - ): Promise + export function moveIdentity(identity_id: string, new_index: number, callback?: () => void): Promise - export function renameIdentity( - identity_id: string, - identity_name: string, - callback?: () => void - ): Promise + export function renameIdentity(identity_id: string, identity_name: string, callback?: () => void): Promise export function changeIdentityColor( identity_id: string, identity_color: string, - callback?: () => void + callback?: () => void, ): Promise - export function setIdentityTag( - identity_id: string, - identity_tag: string, - callback?: () => void - ): Promise + export function setIdentityTag(identity_id: string, identity_tag: string, callback?: () => void): Promise export function setIdentityDescription( identity_id: string, identity_description: string, - callback?: () => void + callback?: () => void, ): Promise export function setIdentityDedication( identity_id: string, identity_dedication: string, - callback?: () => void + callback?: () => void, ): Promise export function setIdentityDedicationIsStrict( identity_id: string, identity_dedication_is_strict: boolean, - callback?: () => void + callback?: () => void, ): Promise - export function setIdentityUserAgent( - identity_id: string, - user_agent: string, - callback?: () => void - ): Promise + export function setIdentityUserAgent(identity_id: string, user_agent: string, callback?: () => void): Promise export function resetIdentity( identity_id: string, - callback?: (identity: GhostIdentity) => void + callback?: (identity: GhostIdentity) => void, ): Promise export function clearIdentityData( identity_id: string, type: ClearIdentityDataType, - callback?: () => void + callback?: () => void, ): Promise export function getIdentityTabsList( project_id: string, identity_id: string, - callback?: (tabs: GhostTab[]) => void + callback?: (tabs: GhostTab[]) => void, ): Promise /** Opens a new tab in a new identity */ @@ -594,110 +507,80 @@ declare namespace chrome { // Window functions export function getWindowsList( project_id: string, - callback?: (windows: GhostWindow[]) => void + callback?: (windows: GhostWindow[]) => void, ): Promise export function getWindowTabsList( project_id: string, window_id: number, - callback?: (tabs: GhostTab[]) => void + callback?: (tabs: GhostTab[]) => void, ): Promise export function addWindow(window: AddWindowDetails, callback?: () => void): Promise - export function removeWindow( - project_id: string, - window_id: number, - callback?: () => void - ): Promise + export function removeWindow(project_id: string, window_id: number, callback?: () => void): Promise // Tab functions export function getSessionTabsList( project_id: string, session_id: string, - callback?: (tabs: GhostTab[]) => void + callback?: (tabs: GhostTab[]) => void, ): Promise - export function getTab( - project_id: string, - tab_id: number, - callback?: (tab: GhostTab) => void - ): Promise + export function getTab(project_id: string, tab_id: number, callback?: (tab: GhostTab) => void): Promise export function addTab(tab: AddTabDetails, callback?: () => void): Promise - export function removeTab( - project_id: string, - tab_id: number, - callback?: () => void - ): Promise + export function removeTab(project_id: string, tab_id: number, callback?: () => void): Promise export function updateTab( project_id: string, tab_id: number, tab_info: TabInfo, - callback?: () => void + callback?: () => void, ): Promise // Multi-extension options - export function isMultiExtensionEnabled( - callback?: (enabled: boolean) => void - ): Promise + export function isMultiExtensionEnabled(callback?: (enabled: boolean) => void): Promise export function getMultiExtensionOptions( identity_id: string, - callback?: (options: GhostMultiExtensionOption[]) => void + callback?: (options: GhostMultiExtensionOption[]) => void, ): Promise export function setMultiExtensionOption( identity_id: string, id: string, value: number, - callback?: (success: boolean) => void + callback?: (success: boolean) => void, ): Promise export function clearMultiExtensionOptions( identity_id: string, - callback?: (success: boolean) => void + callback?: (success: boolean) => void, ): Promise // Events - export const onProjectWillOpen: chrome.events.Event< - (project_id: string, first_time: boolean) => void - > - export const onProjectOpened: chrome.events.Event< - (project_id: string, first_time: boolean) => void - > + export const onProjectWillOpen: chrome.events.Event<(project_id: string, first_time: boolean) => void> + export const onProjectOpened: chrome.events.Event<(project_id: string, first_time: boolean) => void> export const onProjectClosed: chrome.events.Event<(project_id: string) => void> export const onProjectAdded: chrome.events.Event<(project: GhostProject) => void> export const onProjectRemoved: chrome.events.Event<(project_id: string) => void> - export const onProjectNameChanged: chrome.events.Event< - (project_id: string, new_name: string) => void - > - export const onProjectDescriptionChanged: chrome.events.Event< - (project_id: string, description: string) => void - > - export const onProjectLockStateChanged: chrome.events.Event< - (project_id: string, locked: boolean) => void - > + export const onProjectNameChanged: chrome.events.Event<(project_id: string, new_name: string) => void> + export const onProjectDescriptionChanged: chrome.events.Event<(project_id: string, description: string) => void> + export const onProjectLockStateChanged: chrome.events.Event<(project_id: string, locked: boolean) => void> export const onIdentityAdded: chrome.events.Event<(identity: GhostIdentity) => void> export const onIdentityRemoved: chrome.events.Event<(identity_id: string) => void> - export const onIdentityNameChanged: chrome.events.Event< - (identity_id: string, identity_name: string) => void - > - export const onIdentityColorChanged: chrome.events.Event< - (identity_id: string, identity_color: string) => void - > + export const onIdentityNameChanged: chrome.events.Event<(identity_id: string, identity_name: string) => void> + export const onIdentityColorChanged: chrome.events.Event<(identity_id: string, identity_color: string) => void> export const onIdentityUserAgentChanged: chrome.events.Event< (identity_id: string, identity_user_agent: string) => void > export const onIdentitiesChanged: chrome.events.Event<() => void> export const onSessionAdded: chrome.events.Event<(session: GhostSession) => void> - export const onSessionRemoved: chrome.events.Event< - (project_id: string, session_id: string) => void - > + export const onSessionRemoved: chrome.events.Event<(project_id: string, session_id: string) => void> export const onSessionNameChanged: chrome.events.Event< (project_id: string, session_id: string, new_name: string) => void > @@ -712,9 +595,7 @@ declare namespace chrome { export const onTabUpdated: chrome.events.Event<(project_id: string, tab_id: number) => void> export const onWindowAdded: chrome.events.Event<(window: GhostWindow) => void> - export const onWindowRemoved: chrome.events.Event< - (project_id: string, window_id: number) => void - > + export const onWindowRemoved: chrome.events.Event<(project_id: string, window_id: number) => void> } // ============================================================================ diff --git a/extension/src/offscreen-types.ts b/extension/src/offscreen-types.ts index cfedb79..3e1170b 100644 --- a/extension/src/offscreen-types.ts +++ b/extension/src/offscreen-types.ts @@ -55,24 +55,28 @@ export type OffscreenMessage = | OffscreenCancelRecordingMessage // Offscreen document response types -export type OffscreenStartRecordingResult = { - success: true - tabId: number - startedAt: number - mimeType: string -} | { - success: false - error: string -} +export type OffscreenStartRecordingResult = + | { + success: true + tabId: number + startedAt: number + mimeType: string + } + | { + success: false + error: string + } -export type OffscreenStopRecordingResult = { - success: true - tabId: number - duration: number -} | { - success: false - error: string -} +export type OffscreenStopRecordingResult = + | { + success: true + tabId: number + duration: number + } + | { + success: false + error: string + } export interface OffscreenIsRecordingResult { isRecording: boolean @@ -80,13 +84,15 @@ export interface OffscreenIsRecordingResult { startedAt?: number } -export type OffscreenCancelRecordingResult = { - success: true - tabId: number -} | { - success: false - error: string -} +export type OffscreenCancelRecordingResult = + | { + success: true + tabId: number + } + | { + success: false + error: string + } // Messages sent FROM offscreen TO background export interface OffscreenRecordingChunkMessage { @@ -101,6 +107,4 @@ export interface OffscreenRecordingCancelledMessage { tabId: number } -export type OffscreenOutgoingMessage = - | OffscreenRecordingChunkMessage - | OffscreenRecordingCancelledMessage +export type OffscreenOutgoingMessage = OffscreenRecordingChunkMessage | OffscreenRecordingCancelledMessage diff --git a/extension/src/offscreen.html b/extension/src/offscreen.html index 313f3b9..2fe1fe6 100644 --- a/extension/src/offscreen.html +++ b/extension/src/offscreen.html @@ -1,9 +1,9 @@ - + - - Playwriter Offscreen - - - - + + Playwriter Offscreen + + + + diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index 243e195..4500eed 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -61,7 +61,11 @@ interface OffscreenRecordingState { // Map of tabId -> recording state for concurrent recording support const recordings = new Map() -type OffscreenResult = OffscreenStartRecordingResult | OffscreenStopRecordingResult | OffscreenIsRecordingResult | OffscreenCancelRecordingResult +type OffscreenResult = + | OffscreenStartRecordingResult + | OffscreenStopRecordingResult + | OffscreenIsRecordingResult + | OffscreenCancelRecordingResult chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => { handleMessage(message).then(sendResponse) @@ -93,12 +97,14 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro try { // Build Chrome-specific tabCapture constraints // These use Chrome's proprietary API that TypeScript doesn't have built-in types for - const audioConstraints: ChromeTabCaptureAudioConstraints | false = params.audio ? { - mandatory: { - chromeMediaSource: 'tab', - chromeMediaSourceId: params.streamId, - } - } : false + const audioConstraints: ChromeTabCaptureAudioConstraints | false = params.audio + ? { + mandatory: { + chromeMediaSource: 'tab', + chromeMediaSourceId: params.streamId, + }, + } + : false const videoConstraints: ChromeTabCaptureVideoConstraints = { mandatory: { @@ -106,7 +112,7 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro chromeMediaSourceId: params.streamId, minFrameRate: params.frameRate || 30, maxFrameRate: params.frameRate || 30, - } + }, } // Get media stream from the streamId provided by tabCapture.getMediaStreamId @@ -160,13 +166,13 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro const timeout = setTimeout(() => { reject(new Error('MediaRecorder failed to start within 5 seconds')) }, 5000) - + recorder.onstart = () => { clearTimeout(timeout) console.log(`MediaRecorder started for tab ${tabId}`) resolve() } - + // Start with 1 second chunks recorder.start(1000) }) @@ -206,7 +212,9 @@ async function handleStopRecording(params: OffscreenStopRecordingMessage): Promi }) // Stop all tracks - stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() }) + stream.getTracks().forEach((track: MediaStreamTrack) => { + track.stop() + }) const duration = Date.now() - startedAt @@ -260,7 +268,9 @@ function handleCancelRecordingForTab(tabId: number): OffscreenCancelRecordingRes if (recorder.state !== 'inactive') { recorder.stop() } - stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() }) + stream.getTracks().forEach((track: MediaStreamTrack) => { + track.stop() + }) chrome.runtime.sendMessage({ action: 'recordingCancelled', diff --git a/extension/src/recording.ts b/extension/src/recording.ts index fb59b7b..9b9ef03 100644 --- a/extension/src/recording.ts +++ b/extension/src/recording.ts @@ -92,7 +92,10 @@ function updateTabRecordingState(tabId: number, isRecording: boolean): void { export async function handleStartRecording(params: StartRecordingParams): Promise { const tabId = resolveTabIdFromSessionId(params.sessionId) if (!tabId) { - return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' } + return { + success: false, + error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.', + } } if (activeRecordings.has(tabId)) { @@ -133,7 +136,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...') // Send message to offscreen document to start recording - const result = await chrome.runtime.sendMessage({ + const result = (await chrome.runtime.sendMessage({ action: 'startRecording', tabId, streamId, @@ -141,7 +144,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000, audioBitsPerSecond: params.audioBitsPerSecond ?? 128000, audio: params.audio ?? false, - }) as OffscreenStartRecordingResult + })) as OffscreenStartRecordingResult if (!result.success) { return { success: false, error: result.error || 'Failed to start recording in offscreen document' } @@ -179,16 +182,16 @@ export async function handleStopRecording(params: StopRecordingParams): Promise< try { // Send message to offscreen document to stop recording - include tabId for concurrent support - const result = await chrome.runtime.sendMessage({ + const result = (await chrome.runtime.sendMessage({ action: 'stopRecording', tabId, - }) as OffscreenStopRecordingResult + })) as OffscreenStopRecordingResult if (!result.success) { return { success: false, error: result.error || 'Failed to stop recording in offscreen document' } } - const duration = result.duration || (Date.now() - recording.startedAt) + const duration = result.duration || Date.now() - recording.startedAt // Clean up activeRecordings.delete(tabId) @@ -216,10 +219,10 @@ export async function handleIsRecording(params: IsRecordingParams): Promise = { 'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'), - '__PLAYWRITER_VERSION__': JSON.stringify(playwriterPkg.version), -}; + __PLAYWRITER_VERSION__: JSON.stringify(playwriterPkg.version), +} if (process.env.TESTING) { - defineEnv['import.meta.env.TESTING'] = 'true'; + defineEnv['import.meta.env.TESTING'] = 'true' } // Allow tests to build per-port extension outputs to avoid parallel run conflicts. -const outDir = process.env.PLAYWRITER_EXTENSION_DIST || 'dist'; +const outDir = process.env.PLAYWRITER_EXTENSION_DIST || 'dist' export default defineConfig({ plugins: [ @@ -31,33 +29,34 @@ export default defineConfig({ targets: [ { src: resolve(__dirname, 'icons/*'), - dest: 'icons' + dest: 'icons', }, { src: resolve(__dirname, 'manifest.json'), dest: '.', transform: (content) => { - const manifest = JSON.parse(content); + const manifest = JSON.parse(content) // Only include tabs permission during testing if (process.env.TESTING) { if (!manifest.permissions.includes('tabs')) { - manifest.permissions.push('tabs'); + manifest.permissions.push('tabs') } } // Inject key for stable extension ID in dev/test builds (not production) // This ensures all developers get the same extension ID: pebbngnfojnignonigcnkdilknapkgid if (!process.env.PRODUCTION) { - manifest.key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwCJoq5UYhOo5x8s50pVBUHjQ8idyUHnZFDj1JspWJPe6kvM7RFIaE/y5WTAH05kuK0R7v/ipcGA4ywA5wKdPKHZzkl5xstlNPj0Ivu4CqLobU7eY5G3k3Gq7wql2pbwb/A8Nat4VLbfBjQLA6TGWd3LQOHS6M0B3AvrtEw7DLDUdGKh4SCLewCbdlDIzpXQwKOzrRPyLFBwj9eEeITy5aNwJ9r9JMNBvACVZiRCHsGI6DufU+OiIO232l/8OoNNt6kdTMyNgiqOogFApXPJwREUwZHGqjXD3s6bXiBIQtwkNyZfemHKkxj6g/fhCV2EMgTY6+ikQEY1gEJMrRVmcYQIDAQAB'; + manifest.key = + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwCJoq5UYhOo5x8s50pVBUHjQ8idyUHnZFDj1JspWJPe6kvM7RFIaE/y5WTAH05kuK0R7v/ipcGA4ywA5wKdPKHZzkl5xstlNPj0Ivu4CqLobU7eY5G3k3Gq7wql2pbwb/A8Nat4VLbfBjQLA6TGWd3LQOHS6M0B3AvrtEw7DLDUdGKh4SCLewCbdlDIzpXQwKOzrRPyLFBwj9eEeITy5aNwJ9r9JMNBvACVZiRCHsGI6DufU+OiIO232l/8OoNNt6kdTMyNgiqOogFApXPJwREUwZHGqjXD3s6bXiBIQtwkNyZfemHKkxj6g/fhCV2EMgTY6+ikQEY1gEJMrRVmcYQIDAQAB' } - return JSON.stringify(manifest, null, 2); - } + return JSON.stringify(manifest, null, 2) + }, }, - ] - }) + ], + }), ], build: { @@ -76,5 +75,5 @@ export default defineConfig({ }, }, }, - define: defineEnv -}); + define: defineEnv, +}) diff --git a/package.json b/package.json index cb92e88..24ea687 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,30 @@ { - "name": "root", - "private": true, - "scripts": { - "test": "pnpm --filter playwriter test", - "watch": "pnpm -r watch", - "cli": "pnpm --filter playwriter cli", - "build": "pnpm --filter playwriter --filter mcp-extension build", - "reload": "pnpm --filter playwriter build && pnpm --filter mcp-extension reload", - "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", - "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'", - "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", - "prettier": "^3.6.2", - "tsx": "^4.20.6", - "typescript": "^5.9.3", - "vite": "^7.2.2", - "vitest": "^4.0.8" - }, - "repository": "https://github.com/remorses/", - "author": "remorses ", - "license": "", - "dependencies": { - "mcp-extension": "workspace:*" - } + "name": "root", + "private": true, + "scripts": { + "test": "pnpm --filter playwriter test", + "watch": "pnpm -r watch", + "format": "prettier --write .", + "cli": "pnpm --filter playwriter cli", + "build": "pnpm --filter playwriter --filter mcp-extension build", + "reload": "pnpm --filter playwriter build && pnpm --filter mcp-extension reload", + "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", + "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'", + "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", + "prettier": "^3.6.2", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.8" + }, + "repository": "https://github.com/remorses/", + "author": "remorses ", + "license": "", + "dependencies": { + "mcp-extension": "workspace:*" + } } diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index 26d0433..fb8999f 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -270,7 +270,7 @@ - **`getCleanHTML` utility**: New function to get cleaned HTML from a locator or page - Removes script, style, svg, head tags - - Keeps only essential attributes (aria-*, data-*, href, role, title, alt, etc.) + - Keeps only essential attributes (aria-_, data-_, href, role, title, alt, etc.) - Supports `search` option to filter results (returns first 10 matching lines) - Supports `showDiffSinceLastCall` to see changes since last snapshot - Supports `includeStyles` to optionally keep style/class attributes @@ -342,9 +342,9 @@ ### Usage ```js -const { snapshot, labelCount } = await showAriaRefLabels({ page }); -await page.screenshot({ path: '/tmp/labeled-page.png' }); -await page.locator('aria-ref=e5').click(); +const { snapshot, labelCount } = await showAriaRefLabels({ page }) +await page.screenshot({ path: '/tmp/labeled-page.png' }) +await page.locator('aria-ref=e5').click() // Labels auto-hide after 30 seconds, or call hideAriaRefLabels({ page }) manually ``` diff --git a/playwriter/scripts/build-client-bundles.ts b/playwriter/scripts/build-client-bundles.ts index f0e9b78..7c34a5c 100644 --- a/playwriter/scripts/build-client-bundles.ts +++ b/playwriter/scripts/build-client-bundles.ts @@ -1,18 +1,18 @@ /** * Consolidated build script for all client-side JavaScript bundles. - * + * * These bundles are injected into pages via CDP Runtime.evaluate or page.evaluate(). * All bundles are built as browser-targeted IIFEs that expose their APIs on globalThis. - * + * * Injection flow (see react-source.ts, page-markdown.ts for examples): * 1. Bundle is read from dist/*.js via fs.readFileSync (cached after first read) * 2. Check if already injected: `await page.evaluate(() => !!globalThis.__name)` * 3. If not present, inject: `await page.evaluate(code)` or `cdp.send('Runtime.evaluate', { expression: code })` * 4. Use the exposed global in subsequent evaluate calls: `globalThis.__readability`, `globalThis.__bippy`, etc. - * + * * Each bundle uses a separate Bun.build() call (not multiple entrypoints in one call) * to ensure fully self-contained output with no shared chunks. - * + * * Two types of bundles: * 1. Source file bundles - directly bundle a TypeScript source file * 2. Wrapper bundles - create entry code that imports from npm packages and exposes on globalThis diff --git a/playwriter/scripts/build-resources.ts b/playwriter/scripts/build-resources.ts index edad936..54807eb 100644 --- a/playwriter/scripts/build-resources.ts +++ b/playwriter/scripts/build-resources.ts @@ -1,14 +1,14 @@ /** * Generates markdown resource files for the MCP at build time. - * + * * These files are written to: * - playwriter/dist/ - for the MCP to read at runtime * - website/public/ - for hosting on playwriter.dev - * + * * Source of truth: * - playwriter/src/skill.md - manually edited, contains full docs including CLI usage * - skills/playwriter/SKILL.md - stub with frontmatter for agent discovery - * + * * Generated files: * - playwriter/dist/prompt.md - MCP prompt (skill.md minus CLI sections) * - website/public/SKILL.md - full copy for playwriter.dev/SKILL.md @@ -40,26 +40,24 @@ function readFile(relativePath: string): string { function writeToDestinations(filename: string, content: string) { ensureDir(distDir) ensureDir(websitePublicDir) - + const distPath = path.join(distDir, filename) const websitePath = path.join(websitePublicDir, filename) - + fs.writeFileSync(distPath, content, 'utf-8') fs.writeFileSync(websitePath, content, 'utf-8') - + console.log(`Generated ${filename}`) } function cleanTypes(typesContent: string): string { - return typesContent - .replace(/\/\/# sourceMappingURL=.*$/gm, '') - .trim() + return typesContent.replace(/\/\/# sourceMappingURL=.*$/gm, '').trim() } function buildDebuggerApi() { const debuggerTypes = cleanTypes(readFile('dist/debugger.d.ts')) const debuggerExamples = readFile('src/debugger-examples.ts') - + const content = dedent` # Debugger API Reference @@ -75,14 +73,14 @@ function buildDebuggerApi() { ${debuggerExamples} \`\`\` ` - + writeToDestinations('debugger-api.md', content) } function buildEditorApi() { const editorTypes = cleanTypes(readFile('dist/editor.d.ts')) const editorExamples = readFile('src/editor-examples.ts') - + const content = dedent` # Editor API Reference @@ -100,14 +98,14 @@ function buildEditorApi() { ${editorExamples} \`\`\` ` - + writeToDestinations('editor-api.md', content) } function buildStylesApi() { const stylesTypes = cleanTypes(readFile('dist/styles.d.ts')) const stylesExamples = readFile('src/styles-examples.ts') - + const content = dedent` # Styles API Reference @@ -125,24 +123,24 @@ function buildStylesApi() { ${stylesExamples} \`\`\` ` - + writeToDestinations('styles-api.md', content) } /** * Removes CLI-related sections from skill.md to create prompt.md for the MCP. - * + * * Sections removed: * - "## CLI Usage" section and all its subsections */ function stripCliSectionsFromSkill(skillContent: string): string { // Parse markdown tokens const tokens = Lexer.lex(skillContent) - + // Filter out CLI Usage section and its subsections const filteredTokens: Token[] = [] let skipUntilLevel: number | null = null - + for (const token of tokens) { if (token.type === 'heading') { const heading = token as Tokens.Heading @@ -156,27 +154,34 @@ function stripCliSectionsFromSkill(skillContent: string): string { skipUntilLevel = null } } - + if (skipUntilLevel === null) { filteredTokens.push(token) } } - + // Reconstruct markdown from tokens - return filteredTokens.map((token) => { return token.raw }).join('').trim() + '\n' + return ( + filteredTokens + .map((token) => { + return token.raw + }) + .join('') + .trim() + '\n' + ) } function buildPromptFromSkill() { // Read skill.md as source of truth const skillPath = path.join(playwriterDir, 'src', 'skill.md') const skillContent = fs.readFileSync(skillPath, 'utf-8') - + // Generate prompt.md for MCP (without CLI sections) const promptContent = stripCliSectionsFromSkill(skillContent) const distPromptPath = path.join(distDir, 'prompt.md') fs.writeFileSync(distPromptPath, promptContent, 'utf-8') console.log('Generated playwriter/dist/prompt.md (from skill.md)') - + // Copy full skill.md to website/public/ for hosting at playwriter.dev/SKILL.md const websitePublicRoot = path.join(playwriterDir, '..', 'website', 'public') ensureDir(websitePublicRoot) @@ -193,10 +198,10 @@ function parseFrontmatter(content: string): { frontmatter: Record = {} for (const line of yamlContent.split('\n')) { @@ -207,17 +212,17 @@ function parseFrontmatter(content: string): { frontmatter: Record setTimeout(resolve, 200)) + for (const context of contexts) { + const pages = context.pages() + console.log(`Context has ${pages.length} page(s):`) - // Sleep 200 ms - await new Promise((resolve) => setTimeout(resolve, 200)) - for (const context of contexts) { - const pages = context.pages() - console.log(`Context has ${pages.length} page(s):`) + for (const page of pages) { + await page.emulateMedia({ colorScheme: null }) + const url = page.url() + console.log(`\nPage URL: ${url}`) - for (const page of pages) { - await page.emulateMedia({colorScheme: null, }) - const url = page.url() - console.log(`\nPage URL: ${url}`) + const html = await page.content() + const lines = html.split('\n').slice(0, 3) + console.log('First 3 lines of HTML:') + lines.forEach((line, i) => { + console.log(` ${i + 1}: ${line}`) + }) + // Watch for browser console logs and log them in Node.js + page.on('console', (msg) => { + console.log(`Browser log: [${msg.type()}] ${msg.text()}`) + }) - const html = await page.content() - const lines = html.split('\n').slice(0, 3) - console.log('First 3 lines of HTML:') - lines.forEach((line, i) => { - console.log(` ${i + 1}: ${line}`) - }) - // Watch for browser console logs and log them in Node.js - page.on('console', (msg) => { - console.log(`Browser log: [${msg.type()}] ${msg.text()}`) - }) - - console.log(`running eval`) - // Evaluate a sum in the browser and log something from inside the browser context - const sumResult = await page.evaluate(() => { - console.log('Logging from inside browser context!') - return 1 + 2 + 3 - }) - console.log(`Sum result evaluated in browser: ${sumResult}`) - if ((page as any)._snapshotForAI) { - const snapshot = await (page as any)._snapshotForAI() - const snapshotStr = - typeof snapshot === 'string' - ? snapshot - : JSON.stringify(snapshot) - console.log( - 'First 100 chars of _snapshotForAI():', - snapshotStr.slice(0, 100), - ) - } else { - console.log('_snapshotForAI is not available on this page.') - } - } + console.log(`running eval`) + // Evaluate a sum in the browser and log something from inside the browser context + const sumResult = await page.evaluate(() => { + console.log('Logging from inside browser context!') + return 1 + 2 + 3 + }) + console.log(`Sum result evaluated in browser: ${sumResult}`) + if ((page as any)._snapshotForAI) { + const snapshot = await (page as any)._snapshotForAI() + const snapshotStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot) + console.log('First 100 chars of _snapshotForAI():', snapshotStr.slice(0, 100)) + } else { + console.log('_snapshotForAI is not available on this page.') + } } + } } main() diff --git a/playwriter/scripts/extension-current-pages.ts b/playwriter/scripts/extension-current-pages.ts index d499ad1..c2c5f5f 100644 --- a/playwriter/scripts/extension-current-pages.ts +++ b/playwriter/scripts/extension-current-pages.ts @@ -1,24 +1,22 @@ import playwright from 'playwright-core' async function main() { - const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}` - const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, { + const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}` + const browser = await playwright.chromium.connectOverCDP(cdpEndpoint, {}) + const contexts = browser.contexts() + console.log(`Found ${contexts.length} browser context(s)`) + + // Sleep 200 ms + await new Promise((resolve) => setTimeout(resolve, 1000)) + for (const context of contexts) { + const pages = context.pages() + console.log(`Context has ${pages.length} page(s):`) + // Log urls of current pages + pages.forEach((page, idx) => { + console.log(` Page ${idx + 1} URL: ${page.url()}`) }) - - const contexts = browser.contexts() - console.log(`Found ${contexts.length} browser context(s)`) - - // Sleep 200 ms - await new Promise((resolve) => setTimeout(resolve, 1000)) - for (const context of contexts) { - const pages = context.pages() - console.log(`Context has ${pages.length} page(s):`) - // Log urls of current pages - pages.forEach((page, idx) => { - console.log(` Page ${idx + 1} URL: ${page.url()}`) - }) - } + } } main() diff --git a/playwriter/scripts/extension-new-page.ts b/playwriter/scripts/extension-new-page.ts index 61e8ba9..7014c75 100644 --- a/playwriter/scripts/extension-new-page.ts +++ b/playwriter/scripts/extension-new-page.ts @@ -1,28 +1,28 @@ import playwright from 'playwright-core' async function main() { - const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}` - const browser = await playwright.chromium.connectOverCDP(cdpEndpoint) + const cdpEndpoint = `ws://localhost:19988/cdp/${Date.now()}` + const browser = await playwright.chromium.connectOverCDP(cdpEndpoint) - const contexts = browser.contexts() - console.log(`Found ${contexts.length} browser context(s)`) + const contexts = browser.contexts() + console.log(`Found ${contexts.length} browser context(s)`) - // Sleep 200 ms - await new Promise((resolve) => setTimeout(resolve, 200)) - for (const context of contexts) { - const pages = context.pages() - console.log(`Context has ${pages.length} page(s):`) - // Create a new page - const newPage = await context.newPage() - // Evaluate a sum (e.g., 2 + 3) and log the result - const sumResult = await newPage.evaluate(() => 2 + 3) - console.log(`Evaluated sum 2 + 3 = ${sumResult}`) + // Sleep 200 ms + await new Promise((resolve) => setTimeout(resolve, 200)) + for (const context of contexts) { + const pages = context.pages() + console.log(`Context has ${pages.length} page(s):`) + // Create a new page + const newPage = await context.newPage() + // Evaluate a sum (e.g., 2 + 3) and log the result + const sumResult = await newPage.evaluate(() => 2 + 3) + console.log(`Evaluated sum 2 + 3 = ${sumResult}`) - // Sleep 1 second - await new Promise((resolve) => setTimeout(resolve, 1000)) - // Close the page - await newPage.close() - } + // Sleep 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)) + // Close the page + await newPage.close() + } } main() diff --git a/playwriter/scripts/extension-server.ts b/playwriter/scripts/extension-server.ts index fc417ba..091f273 100644 --- a/playwriter/scripts/extension-server.ts +++ b/playwriter/scripts/extension-server.ts @@ -4,7 +4,6 @@ async function main() { const server = await startPlayWriterCDPRelayServer({ port: 19988 }) console.log('Server running. Press Ctrl+C to stop.') - } main().catch(console.error) diff --git a/playwriter/src/__snapshots__/x.com.processed.html b/playwriter/src/__snapshots__/x.com.processed.html index 68cab15..ba8902e 100644 --- a/playwriter/src/__snapshots__/x.com.processed.html +++ b/playwriter/src/__snapshots__/x.com.processed.html @@ -1,1003 +1,1508 @@ - -
-

- Per visualizzare i tasti di scelta rapida, premi il pulsante ? - - Visualizza i tasti di scelta rapida - -

-
- - -
-
-
-

- -

- - -
- -
-
-
-
-
- -
- -
-
-
-
-
Che c'è di nuovo?
-
-
-
-
- - -
-
-
-
-

La tua cronologia principale

-
-
-
- -
-
- -
-
- -
-
-
-
- -
- This is revisionist history bruhh. Everyone shat on them for - weeks until 2 days ago lmaooo People will say anything for money - 😆 -
-
- Citazione -
-
-
- 0xSero -
- @0xSero -
- +
-
-
- - - - - - 36 - - - - -
-
-
+
-
- -
-
-
-
- -
- Starmer literally has a lower approval rating than Maduro - 😂 -
-
- Citazione -
-
-
- Basil the Great -
- @BasilTheGreat -
- -
-
-
-
- 🚨 - ASMONGOLD SAYS AMERICANS SHOULD CAPTURE KEIR STARMER This - comes after Keir Starmer says he may ban X in the UK "We - should just go and arrest him, he has a lower approval - rating than Maduro and we arrested him" -
-
-
-
- Video incorporato -
- 0:53 - -
-
-
-
- Valuta le note della collettività suggerite -
-
-
-
- - - - - - 6 Mln - - - - -
-
-
-
-
- -
-
-
-
- -
-
-
- Copertina dell'articolo -
-
-
- In software, the code documents the app. In AI, the traces - do. - TL;DR In traditional software, you read the code to understand - what the app does - the decision logic lives in your codebase In - AI agents, the code is just scaffolding - the actual - decision-making... -
-
-
- - - - - - 3.299 - - - - -
-
-
-
-
-
-
- -
- Or use a state machine from the beginning - 😉 - This is the next best thing, though! -
-
- Citazione -
-
-
- mert | helius -
- @mert -
- -
-
-
-
- a quick opus 4.5 vibecoding tip ask claude to make state - machine diagrams of existing components this causes it to - map out all paths (which, it will default to being lazy - otherwise) but also helps you verify if it's doing things - correctly at a systems ...5 more characters -
- -
- Immagine -
-
-
-
-
-
- - - - - - 590 - - - - -
-
-
-
-
- -
-
-
-
- -
- with - @cursor_ai -
-
- Citazione -
-
+ +
+
+
+
-
-
-
-
-
- -
- Reason #42 for open platforms: to shut down every politician’s - incessant demands to all gatekeepers to censor all of their - political opponents. -
-
- Citazione -
-
+
-
-
-
-
- Immagine del profilo quadrata +
+
+
+
Che c'è di nuovo?
+
-
-
-
- MacRumors.com +
+ + +
+
+
+
+

La tua cronologia principale

+
- @MacRumors -
- -
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+ This is revisionist history bruhh. Everyone shat on them for weeks until 2 days ago + lmaooo People will say anything for money + 😆 +
+
+ Citazione +
+
+
+ 0xSero +
+ @0xSero +
+ +
+
+
+
+ It’s strange how OAI has continuously been a good faith actor in regards to how + they treat their users and ecosystems x.com/thdxr/status/2… +
+
+
+
+
+ + + + + + 36 + + + + +
+
+
+
+
+ +
+
+
+
+ +
+ Starmer literally has a lower approval rating than Maduro + 😂 +
+
+ Citazione +
+
+
+ Basil the Great +
+ @BasilTheGreat +
+ +
+
+
+
+ 🚨 + ASMONGOLD SAYS AMERICANS SHOULD CAPTURE KEIR STARMER This comes after Keir Starmer + says he may ban X in the UK "We should just go and arrest him, he has a lower + approval rating than Maduro and we arrested him" +
+
+
+
+ Video incorporato +
+ 0:53 + +
+
+
+
+ Valuta le note della collettività suggerite +
+
+
+
+ + + + + + 6 Mln + + + + +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ Copertina dell'articolo +
+
+
+ In software, the code documents the app. In AI, the traces do. + TL;DR In traditional software, you read the code to understand what the app does - the + decision logic lives in your codebase In AI agents, the code is just scaffolding - the + actual decision-making... +
+
+
+ + + + + + 3.299 + + + + +
+
+
+
+
+
+
+ +
+ Or use a state machine from the beginning + 😉 + This is the next best thing, though! +
+
+ Citazione +
+
+
+ mert | helius +
+ @mert +
+ +
+
+
+
+ a quick opus 4.5 vibecoding tip ask claude to make state machine diagrams of + existing components this causes it to map out all paths (which, it will default to + being lazy otherwise) but also helps you verify if it's doing things correctly at a + systems ...5 more characters +
+ +
+ Immagine +
+
+
+
+
+
+ + + + + + 590 + + + + +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+ Reason #42 for open platforms: to shut down every politician’s incessant demands to all + gatekeepers to censor all of their political opponents. +
+
+ Citazione +
+
+
+
+
+
+
+ Immagine del profilo quadrata +
+
+
+
+
+ MacRumors.com +
+ @MacRumors +
+ +
+
+
+
+
+ U.S. Senators Ask Apple and Google to Remove X and Grok Apps Over Sexualized Image + Generation + macrumors.com/2026/01/09/sen +
+ +
+ Immagine +
+
+
+
+
+
+ + + + + + 228.388 + + + + +
+
+
+
+
+
+
+ +
+ All major AIs have documented instances of going off the rails; all major AI companies + make their best efforts to combat this; none are perfect. Politicians demanding + gatekeepers selectively crush the one that's their political opponent's company is basic + crony capitalism. +
+
+ + + + + + 22.717 + + + + +
+
+
+
+
+
+
+ +
+ I seriously doubt any of these companies makes "their best efforts" to do anything other + than ship AI versions as rapidly as possible. Multiple have already admitted in court to + mass IP theft. Their concern for the law is at best an afterthought when it isn't ignored + entirely. +
+
+ + + + + + 30 + + + + +
+
+
+
-
-
- U.S. Senators Ask Apple and Google to Remove X and Grok - Apps Over Sexualized Image Generation - macrumors.com/2026/01/09/sen -
- -
- Immagine -
-
-
-
-
-
- - - - - - 228.388 - - - - -
+
-
-
-
-
- -
- All major AIs have documented instances of going off the rails; - all major AI companies make their best efforts to combat this; - none are perfect. Politicians demanding gatekeepers selectively - crush the one that's their political opponent's company is basic - crony capitalism. -
-
- - - - - - 22.717 - - - - -
-
-
-
-
-
-
- -
- I seriously doubt any of these companies makes "their best - efforts" to do anything other than ship AI versions as rapidly as - possible. Multiple have already admitted in court to mass IP - theft. Their concern for the law is at best an afterthought when - it isn't ignored entirely. -
-
- - - - - - 30 - - - - -
-
-
-
-
-
- + - - - - -
Press space bar to start a drag. When dragging you can use the arrow keys to move the item around and escape to - cancel. Some screen readers may require you to be in focus mode or to use your pass through key
- + +
+ Press space bar to start a drag. When dragging you can use the arrow keys to move the item around and escape to + cancel. Some screen readers may require you to be in focus mode or to use your pass through key +
+ diff --git a/playwriter/src/__snapshots__/x.com.processed.withStyles.html b/playwriter/src/__snapshots__/x.com.processed.withStyles.html index 78b4b44..5720765 100644 --- a/playwriter/src/__snapshots__/x.com.processed.withStyles.html +++ b/playwriter/src/__snapshots__/x.com.processed.withStyles.html @@ -1,2720 +1,5443 @@ - -
-
-
-

-
- Per visualizzare i tasti di scelta rapida, premi il pulsante ? -
- - Visualizza i tasti di scelta rapida - -

-
- - - -
-
-
-
-
-
-
-
-
- -
-
+ +
+
+
+

+
+ Per visualizzare i tasti di scelta rapida, premi il pulsante ?
-

+
+ + + +
+ - +
+
+
+
+
-
- -
-
- +
+
+
+
+
+
-
- -
-
-
-
-
-
- +
-
- -
-
- - - - - -
-
-
-
- -
-
- +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ i'm running my own +
+ + @MrBeast + +
+ games with opencode zen AI models +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+ -
-
- -
- +
+ +
+
-
- -
-
+ + 267 + + +
+
+ +
+
+ +
+
-
- -
-
-
- - - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-
-
- -
-
-
-
-
-
-
-
-
- This is revisionist history bruhh. Everyone shat on them for - weeks until 2 days ago lmaooo People will say anything for money - 😆 -
-
-
-
-
- Citazione -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - 0xSero - -
-
+ ...140 more characters" + > +
+ +
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- @0xSero -
-
-
-
-
-
- -
-
-
-
-
- - - - - -
-
- It’s strange how OAI has continuously been a good faith +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ This is revisionist history bruhh. Everyone shat on them for + weeks until 2 days ago lmaooo People will say anything for + money + 😆 +
+
+
+
+
+ Citazione +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + 0xSero + +
+
+
+
+
+
+
+
+
+
+ @0xSero +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ It’s strange how OAI has continuously been a good faith actor in regards to how they treat their users and - ecosystems x.com/thdxr/status/2… -
-
-
-
-
-
-
-
-
-
- -
-
- -
-
- +
+
+ +
+
+ -
- + -
- -
-
+ + 36 + + +
+
+ +
+
+ +
+
-
- -
-
-
-
-
-
-
-
-
- -
-
-
- - - - - - - - -
-
-
- -
-
-
-
- -
-
-
-
-
- - -
-
- Not true -
-
-
-
-
-
- -
-
- -
-
- +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ Not true +
+
+
+
+
+
+ +
+
+ +
+
+ -
- + -
- -
-
+ + 18 + + +
+
+ +
+
+ +
+
-
- -
-
-
- - - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
-
-
-
-
-
- Starmer literally has a lower approval rating than Maduro - 😂 -
-
-
-
-
- Citazione -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - Basil the Great - -
-
+ ...140 more characters" + > +
+ +
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- @BasilTheGreat -
-
-
-
-
-
- -
-
-
-
-
- - - - - -
-
- 🚨 - ASMONGOLD SAYS AMERICANS SHOULD CAPTURE KEIR STARMER This +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ Starmer literally has a lower approval rating than Maduro + 😂 +
+
+
+
+
+ Citazione +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Basil the Great + +
+
+
+
+
+
+
+
+
+
+ @BasilTheGreat +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ 🚨 + ASMONGOLD SAYS AMERICANS SHOULD CAPTURE KEIR STARMER This comes after Keir Starmer says he may ban X in the UK "We should just go and arrest him, he has a lower approval - rating than Maduro and we arrested him" -
-
-
-
-
-
-
-
-
-
-
-
-
- Video incorporato -
-
-
-
-
- 0:53 -
-
-
- + rating than Maduro and we arrested him" +
+
+
+
+
+
+
+
+
+
+
+
+
+ Video incorporato +
+
+
+
+
+ 0:53 +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Valuta le note della collettività suggerite +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
-
-
- - - - - -
-
-
-
- Valuta le note della collettività suggerite -
-
-
-
- - - -
-
-
-
- +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ I’m watching designers at Notion ship code that is + indistinguishable from senior engineers. +
+
+
+
+
+
+ +
+
+ -
-
- -
-
- -
- -
- -
-
+ + 14 + + +
+
+ +
+
+ +
+ +
+ +
+
-
- -
-
-
- - - - - - - - - - - - - - - - - - -
-
-
- -
-
-
-
- -
-
-
-
-
- - -
-
- I’m watching designers at Notion ship code that is - indistinguishable from senior engineers. -
-
-
-
-
-
- -
-
- -
-
- -
- -
- -
-
-
- -
-
-
-
-
- - - - - - - - -
-
-
-
-
-
-
-
-
-
- -
-
-
- -
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- Copertina dell'articolo -
-
-
-
-
-
- In software, the code documents the app. In AI, the traces - do. -
-
- TL;DR In traditional software, you read the code to understand +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ Copertina dell'articolo +
+
+
+
+
+
+ In software, the code documents the app. In AI, the traces + do. +
+
+ TL;DR In traditional software, you read the code to understand what the app does - the decision logic lives in your codebase In AI agents, the code is just scaffolding - the actual - decision-making... -
-
-
-
-
-
-
- -
-
- +
+
+ -
-
- +
+
+ -
- -
- -
-
+ + 29 + + +
+
+ +
+ +
+ +
+
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- -
-
-
-
- -
-
-
-
-
-
-
-
-
- Or use a state machine from the beginning - 😉 - This is the next best thing, though! -
-
-
-
-
- Citazione -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - mert | helius - -
-
+ ...140 more characters" + > +
+ +
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- @mert -
-
-
-
-
-
- -
-
-
-
-
- - - - - -
-
- a quick opus 4.5 vibecoding tip ask claude to make state +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ Or use a state machine from the beginning + 😉 + This is the next best thing, though! +
+
+
+
+
+ Citazione +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + mert | helius + +
+
+
+
+
+
+
+
+
+
+ @mert +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ a quick opus 4.5 vibecoding tip ask claude to make state machine diagrams of existing components this causes it to map out all paths (which, it will default to being lazy otherwise) but also helps you verify if it's doing things - correctly at a systems ...5 more characters -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ -
-
- -
-
- +
+
+ +
+
+ -
- -
- -
-
+ + 6 + + +
+
+ +
+ +
+ +
+
-
- -
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - a16z - -
-
-
-
-
-
-
-
-
-
- @a16z -
-
-
-
-
-
- -
-
-
-
-
-
-
- - - - -
-
- It's time to build. -
-
-
-
-
-
-
-
-
-
-
-
-
- Video incorporato -
-
-
-
-
- 1:45 -
-
-
- -
-
-
-
-
-
-
-
-
- - - - -
-
-
-
- +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ ▲ Taste: Coming soon +
+
+
+
+
+
+ -
-
- -
-
- +
+
+ +
+
+ -
- -
- -
-
+ + 40 + + +
+
+ +
+ +
+ +
+
-
- -
-
-
- - - - - - - - - - - - - - - - - - -
-
-
- -
-
-
-
- -
-
-
-
-
- - -
-
- Reason #42 for open platforms: to shut down every politician’s +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ with +
+ + @cursor_ai + +
+
+
+
+
+
+ Citazione +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + a16z + +
+
+
+
+
+
+
+
+
+
+ @a16z +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ It's time to build. +
+
+
+
+
+
+
+
+
+
+
+
+
+ Video incorporato +
+
+
+
+
+ 1:45 +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ Reason #42 for open platforms: to shut down every politician’s incessant demands to all gatekeepers to censor all of their - political opponents. -
-
-
-
-
- Citazione -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - MacRumors.com - -
-
-
-
-
-
-
-
-
-
- @MacRumors -
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
- U.S. Senators Ask Apple and Google to Remove X and Grok - Apps Over Sexualized Image Generation - macrumors.com/2026/01/09/sen -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
- -
-
- +
+
+ -
-
- -
- -
- -
-
+ + 203 + + +
+
+ +
+
+ +
+ +
+ +
+
-
- -
-
-
-
-
-
-
-
-
- -
-
-
- - - - - - - - -
-
-
- -
-
-
-
- -
-
-
-
-
- - -
-
- All major AIs have documented instances of going off the rails; +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ All major AIs have documented instances of going off the rails; all major AI companies make their best efforts to combat this; none are perfect. Politicians demanding gatekeepers selectively crush the one that's their political opponent's company is basic - crony capitalism. -
-
-
-
-
-
- -
-
- +
+
+ -
-
- +
+
+ -
- -
- -
-
+ + 318 + + +
+
+ +
+ +
+ +
+
-
- -
-
-
-
-
-
-
-
-
- -
-
-
- - - - - - - - -
-
-
- -
-
-
-
- -
-
-
-
-
- - -
-
- I seriously doubt any of these companies makes "their best +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ I seriously doubt any of these companies makes "their best efforts" to do anything other than ship AI versions as rapidly as possible. Multiple have already admitted in court to mass IP theft. Their concern for the law is at best an afterthought when - it isn't ignored entirely. -
-
-
-
-
-
- -
-
- -
-
- +
+
+ +
+
+ -
- + -
- -
-
+ + 30 + + +
+
+ +
+
+ +
+
-
- + ...140 more characters" + > +
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
- - - + - - - - - - - - + - - - - + - - -
Press space bar to start a drag. When dragging you can use the arrow keys to move the item around and escape to - cancel. Some screen readers may require you to be in focus mode or to use your pass through key
- +
+ Press space bar to start a drag. When dragging you can use the arrow keys to move the item around and escape to + cancel. Some screen readers may require you to be in focus mode or to use your pass through key +
+ diff --git a/playwriter/src/a11y-client.ts b/playwriter/src/a11y-client.ts index 22724af..8e187c1 100644 --- a/playwriter/src/a11y-client.ts +++ b/playwriter/src/a11y-client.ts @@ -234,7 +234,8 @@ export function hideA11yLabels(): void { // Expose on globalThis for injection // ============================================================================ -;(globalThis as { __a11y?: { renderA11yLabels: (labels: A11yLabel[]) => number; hideA11yLabels: () => void } }).__a11y = { - renderA11yLabels, - hideA11yLabels, -} +;(globalThis as { __a11y?: { renderA11yLabels: (labels: A11yLabel[]) => number; hideA11yLabels: () => void } }).__a11y = + { + renderA11yLabels, + hideA11yLabels, + } diff --git a/playwriter/src/aria-snapshot.test.ts b/playwriter/src/aria-snapshot.test.ts index 6970cfb..ce54a55 100644 --- a/playwriter/src/aria-snapshot.test.ts +++ b/playwriter/src/aria-snapshot.test.ts @@ -64,7 +64,7 @@ async function createHtmlServer({ htmlByPath }: { htmlByPath: Record { try { await page.goto(outerServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 10000 }) await page.locator('[data-testid="external-iframe"]').waitFor({ timeout: 5000 }) - await page.frameLocator('[data-testid="external-iframe"]').locator('[data-testid="iframe-button"]').waitFor({ timeout: 5000 }) + await page + .frameLocator('[data-testid="external-iframe"]') + .locator('[data-testid="iframe-button"]') + .waitFor({ timeout: 5000 }) // Convert iframe Locator to Frame const iframeHandle = await page.locator('[data-testid="external-iframe"]').elementHandle() diff --git a/playwriter/src/aria-snapshot.ts b/playwriter/src/aria-snapshot.ts index f22ece5..fc760cb 100644 --- a/playwriter/src/aria-snapshot.ts +++ b/playwriter/src/aria-snapshot.ts @@ -11,11 +11,14 @@ import { Sema } from 'async-sema' import type { ICDPSession } from './cdp-session.js' import { getCDPSessionForPage } from './cdp-session.js' - // Import sharp at module level - resolves to null if not available const sharpPromise = import('sharp') - .then((m) => { return m.default }) - .catch(() => { return null }) + .then((m) => { + return m.default + }) + .catch(() => { + return null + }) // ============================================================================ // Snapshot Format Types @@ -142,9 +145,7 @@ const INTERACTIVE_ROLES = new Set([ 'audio', ]) -const LABEL_ROLES = new Set([ - 'labeltext', -]) +const LABEL_ROLES = new Set(['labeltext']) const MAX_LABEL_POSITION_CONCURRENCY = 24 const BOX_MODEL_TIMEOUT_MS = 5000 @@ -165,12 +166,7 @@ const CONTEXT_ROLES = new Set([ 'cell', ]) -const SKIP_WRAPPER_ROLES = new Set([ - 'generic', - 'group', - 'none', - 'presentation', -]) +const SKIP_WRAPPER_ROLES = new Set(['generic', 'group', 'none', 'presentation']) const TEST_ID_ATTRS = [ 'data-testid', @@ -231,7 +227,15 @@ function buildLocatorFromStable(stable: { value: string; attr: string }): string return `[${stable.attr}="${escaped}"]` } -function buildBaseLocator({ role, name, stable }: { role: string; name: string; stable: { value: string; attr: string } | null }): string { +function buildBaseLocator({ + role, + name, + stable, +}: { + role: string + name: string + stable: { value: string; attr: string } | null +}): string { if (stable) { return buildLocatorFromStable(stable) } @@ -243,7 +247,6 @@ function buildBaseLocator({ role, name, stable }: { role: string; name: string; return `role=${role}` } - function getAxValueString(value?: Protocol.Accessibility.AXValue): string { if (!value) { return '' @@ -283,7 +286,13 @@ export type SnapshotNode = { children: SnapshotNode[] } -function buildSnapshotLine({ role, name, baseLocator, indent, hasChildren }: { +function buildSnapshotLine({ + role, + name, + baseLocator, + indent, + hasChildren, +}: { role: string name: string baseLocator?: string @@ -308,15 +317,16 @@ function buildTextLine(text: string, indent: number): SnapshotLine { export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] { return nodes.flatMap((node) => { const nodeIndent = indent + (node.indentOffset ?? 0) - const line = node.role === 'text' - ? buildTextLine(node.name, nodeIndent) - : buildSnapshotLine({ - role: node.role, - name: node.name, - baseLocator: node.baseLocator, - indent: nodeIndent, - hasChildren: node.children.length > 0, - }) + const line = + node.role === 'text' + ? buildTextLine(node.name, nodeIndent) + : buildSnapshotLine({ + role: node.role, + name: node.name, + baseLocator: node.baseLocator, + indent: nodeIndent, + hasChildren: node.children.length > 0, + }) return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)] }) } @@ -339,13 +349,15 @@ export function buildRawSnapshotTree(options: { const role = getAxRole(node) const name = getAxValueString(node.name).trim() - const children = (node.childIds ?? []).map((childId) => { - return buildRawSnapshotTree({ - nodeId: childId, - axById: options.axById, - isNodeInScope: options.isNodeInScope, + const children = (node.childIds ?? []) + .map((childId) => { + return buildRawSnapshotTree({ + nodeId: childId, + axById: options.axById, + isNodeInScope: options.isNodeInScope, + }) }) - }).filter(isTruthy) + .filter(isTruthy) const inScope = options.isNodeInScope(node) || children.length > 0 if (!inScope) { @@ -367,7 +379,11 @@ export function filterInteractiveSnapshotTree(options: { labelContext: boolean refFilter?: (entry: { role: string; name: string }) => boolean domByBackendId: Map - 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 } { const role = options.node.role const name = options.node.name @@ -476,7 +492,11 @@ export function filterFullSnapshotTree(options: { ancestorNames: string[] refFilter?: (entry: { role: string; name: string }) => boolean domByBackendId: Map - 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 } { const role = options.node.role const name = options.node.name @@ -613,18 +633,20 @@ export function finalizeSnapshotOutput( }, []) let lineLocatorIndex = 0 - const snapshot = lines.map((line) => { - let text = line.text - if (line.baseLocator) { - const locator = locatorSequence[lineLocatorIndex] - lineLocatorIndex += 1 - text = buildLocatorLineText({ line, locator }) - } - if (line.hasChildren) { - text += ':' - } - return text - }).join('\n') + const snapshot = lines + .map((line) => { + let text = line.text + if (line.baseLocator) { + const locator = locatorSequence[lineLocatorIndex] + lineLocatorIndex += 1 + text = buildLocatorLineText({ line, locator }) + } + if (line.hasChildren) { + text += ':' + } + return text + }) + .join('\n') let nodeLocatorIndex = 0 const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => { @@ -676,7 +698,11 @@ function buildDomIndex(nodes: Protocol.DOM.Node[]): { return { domById, domByBackendId, childrenByParent } } -function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrValue: string): Protocol.DOM.NodeId | null { +function findScopeRootNodeId( + nodes: Protocol.DOM.Node[], + attrName: string, + attrValue: string, +): Protocol.DOM.NodeId | null { for (const node of nodes) { if (!node.attributes) { continue @@ -692,7 +718,11 @@ function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrV return null } -function buildBackendIdSet(rootNodeId: Protocol.DOM.NodeId, childrenByParent: Map, domById: Map): Set { +function buildBackendIdSet( + rootNodeId: Protocol.DOM.NodeId, + childrenByParent: Map, + domById: Map, +): Set { const result = new Set() const stack: Protocol.DOM.NodeId[] = [rootNodeId] while (stack.length > 0) { @@ -786,7 +816,14 @@ async function resolveFrame({ frame, page }: { frame?: Frame | FrameLocator; pag * await page.locator(selector).click() * ``` */ -export async function getAriaSnapshot({ page, frame, locator, refFilter, interactiveOnly = false, cdp }: { +export async function getAriaSnapshot({ + page, + frame, + locator, + refFilter, + interactiveOnly = false, + cdp, +}: { page: Page frame?: Frame | FrameLocator locator?: Locator @@ -794,29 +831,29 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac interactiveOnly?: boolean cdp?: ICDPSession }): Promise { - const session = cdp || await getCDPSessionForPage({ page }) + const session = cdp || (await getCDPSessionForPage({ page })) // Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame()) // is a scoping helper without CDP access. We need the real Frame from page.frames() // which has frameId() for OOPIF session attachment. const resolvedFrame = await resolveFrame({ frame, page }) - + // For cross-origin iframes (OOPIFs), we need to attach to the iframe's target // to get a separate CDP session. Same-origin iframes can use frameId directly. let oopifSessionId: string | null = null const frameId = resolvedFrame?.frameId() ?? null - + if (frameId) { - const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse + const { targetInfos } = (await session.send('Target.getTargets')) as Protocol.Target.GetTargetsResponse const frameUrl = resolvedFrame!.url() const iframeTarget = targetInfos.find((t) => { return t.type === 'iframe' && t.url === frameUrl }) if (iframeTarget) { - const { sessionId } = await session.send('Target.attachToTarget', { + const { sessionId } = (await session.send('Target.attachToTarget', { targetId: iframeTarget.targetId, flatten: true, - }) as Protocol.Target.AttachToTargetResponse + })) as Protocol.Target.AttachToTargetResponse oopifSessionId = sessionId await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId) } @@ -831,13 +868,20 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac try { if (scopeLocator) { - await scopeLocator.evaluate((element, data) => { - element.setAttribute(data.attr, data.value) - }, { attr: scopeAttr, value: scopeValue }) + await scopeLocator.evaluate( + (element, data) => { + element.setAttribute(data.attr, data.value) + }, + { attr: scopeAttr, value: scopeValue }, + ) scopeApplied = true } - const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }, oopifSessionId) as Protocol.DOM.GetFlattenedDocumentResponse + const { nodes: domNodes } = (await session.send( + 'DOM.getFlattenedDocument', + { depth: -1, pierce: true }, + oopifSessionId, + )) as Protocol.DOM.GetFlattenedDocumentResponse const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes) let scopeRootNodeId: Protocol.DOM.NodeId | null = null @@ -852,12 +896,14 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac } } - const allowedBackendIds = scopeRootNodeId - ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) - : null + const allowedBackendIds = scopeRootNodeId ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) : null const axParams = !oopifSessionId && frameId ? { frameId } : undefined - const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree', axParams, oopifSessionId) as Protocol.Accessibility.GetFullAXTreeResponse + const { nodes: axNodes } = (await session.send( + 'Accessibility.getFullAXTree', + axParams, + oopifSessionId, + )) as Protocol.Accessibility.GetFullAXTreeResponse const axById = new Map() for (const node of axNodes) { @@ -937,16 +983,18 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac return allowedBackendIds.has(node.backendDOMNodeId) } - let snapshotNodes: SnapshotNode[] = [] if (rootAxNodeId) { const rootNode = axById.get(rootAxNodeId) const rootRole = rootNode ? getAxRole(rootNode) : '' - const rawRoots = rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds - ? rootNode.childIds.map((childId) => { - return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope }) - }).filter(isTruthy) - : [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy) + const rawRoots = + rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds + ? rootNode.childIds + .map((childId) => { + return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope }) + }) + .filter(isTruthy) + : [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy) const filtered = rawRoots.flatMap((rawNode) => { if (interactiveOnly) { @@ -1027,7 +1075,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac } catch { return null } - }) + }), ) const matchingRefs = await page.evaluate( @@ -1037,7 +1085,16 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac return null } - const testIdAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-pw', 'data-qa', 'data-e2e', 'data-automation-id'] + const testIdAttrs = [ + 'data-testid', + 'data-test-id', + 'data-test', + 'data-cy', + 'data-pw', + 'data-qa', + 'data-e2e', + 'data-automation-id', + ] for (const attr of testIdAttrs) { const value = target.getAttribute(attr) if (value) { @@ -1066,7 +1123,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac { targets: targetHandles, refData: result.refs, - } + }, ) return matchingRefs.map((ref) => { @@ -1140,7 +1197,7 @@ async function getLabelBoxesForRefs({ cdp?: ICDPSession }): Promise { const log = logger?.info ?? logger?.error ?? console.error - const session = cdp || await getCDPSessionForPage({ page }) + const session = cdp || (await getCDPSessionForPage({ page })) const sema = new Sema(maxConcurrency) const labelRefs = refs.filter((ref) => { return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role) @@ -1161,14 +1218,20 @@ async function getLabelBoxesForRefs({ await sema.acquire() try { const response = await Promise.race([ - session.send('DOM.getBoxModel', { backendNodeId: ref.backendNodeId }) as Promise, + session.send('DOM.getBoxModel', { + backendNodeId: ref.backendNodeId, + }) as Promise, new Promise((resolve) => { - setTimeout(() => { resolve(null) }, BOX_MODEL_TIMEOUT_MS) + setTimeout(() => { + resolve(null) + }, BOX_MODEL_TIMEOUT_MS) }), ]) completed++ if (completed % 50 === 0 || completed === labelRefs.length) { - log(`[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`) + log( + `[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`, + ) } if (!response) { timedOut++ @@ -1186,9 +1249,11 @@ async function getLabelBoxesForRefs({ } finally { sema.release() } - }) + }), + ) + log( + `[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`, ) - log(`[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`) return labels.filter(isTruthy) } finally { if (!cdp) { @@ -1217,7 +1282,12 @@ async function getLabelBoxesForRefs({ * await page.locator('[data-testid="submit-btn"]').click() * ``` */ -export async function showAriaRefLabels({ page, locator, interactiveOnly = true, logger }: { +export async function showAriaRefLabels({ + page, + locator, + interactiveOnly = true, + logger, +}: { page: Page locator?: Locator interactiveOnly?: boolean @@ -1240,11 +1310,15 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true, try { const snapshotStart = Date.now() const { snapshot, refs } = await getAriaSnapshot({ page, locator, interactiveOnly, cdp }) - const shortRefMap = new Map(refs.map((entry) => { - return [entry.ref, entry.shortRef] - })) + const shortRefMap = new Map( + refs.map((entry) => { + return [entry.ref, entry.shortRef] + }), + ) const interactiveRefs = refs.filter((ref) => Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)) - log(`[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`) + log( + `[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`, + ) const rootHandle = locator ? await locator.elementHandle() : null @@ -1259,22 +1333,30 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true, log(`[showAriaRefLabels] getLabelBoxesForRefs: ${Date.now() - labelsStart}ms (${labels.length} boxes)`) const renderStart = Date.now() - const labelCount = await page.evaluate(({ entries, root, interactiveOnly: intOnly }) => { - const a11y = (globalThis as { - __a11y?: { - renderA11yLabels?: (labels: typeof entries) => number - computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => { labelCount: number } + const labelCount = await page.evaluate( + ({ entries, root, interactiveOnly: intOnly }) => { + const a11y = ( + globalThis as { + __a11y?: { + renderA11yLabels?: (labels: typeof entries) => number + computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => { + labelCount: number + } + } + } + ).__a11y + if (a11y?.renderA11yLabels) { + return a11y.renderA11yLabels(entries) } - }).__a11y - if (a11y?.renderA11yLabels) { - return a11y.renderA11yLabels(entries) - } - if (a11y?.computeA11ySnapshot) { - const rootElement = root || document.body - return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true }).labelCount - } - throw new Error('a11y client not loaded') - }, { entries: shortLabels, root: rootHandle, interactiveOnly }) + if (a11y?.computeA11ySnapshot) { + const rootElement = root || document.body + return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true }) + .labelCount + } + throw new Error('a11y client not loaded') + }, + { entries: shortLabels, root: rootHandle, interactiveOnly }, + ) log(`[showAriaRefLabels] renderA11yLabels: ${Date.now() - renderStart}ms (${labelCount} labels)`) log(`[showAriaRefLabels] total: ${Date.now() - startTime}ms`) @@ -1324,7 +1406,13 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise * await page.locator('[data-testid="submit-btn"]').click() * ``` */ -export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, collector, logger }: { +export async function screenshotWithAccessibilityLabels({ + page, + locator, + interactiveOnly = true, + collector, + logger, +}: { page: Page locator?: Locator interactiveOnly?: boolean @@ -1351,7 +1439,10 @@ export async function screenshotWithAccessibilityLabels({ page, locator, interac const screenshotPath = path.join(tmpDir, filename) // Get viewport size to clip screenshot to visible area - const viewport = await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })') as { width: number; height: number } + const viewport = (await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })')) as { + width: number + height: number + } // Max 1568px on any edge (larger gets auto-resized by Claude, adding latency) // Token formula: tokens = (width * height) / 750 diff --git a/playwriter/src/aria-snapshot.unit.test.ts b/playwriter/src/aria-snapshot.unit.test.ts index 31127d8..f08f8bc 100644 --- a/playwriter/src/aria-snapshot.unit.test.ts +++ b/playwriter/src/aria-snapshot.unit.test.ts @@ -29,61 +29,85 @@ describe('aria-snapshot tree filters', () => { const buttonId = '8' as Protocol.Accessibility.AXNodeId const axById = new Map([ - [rootId, { - nodeId: rootId, - ignored: false, - role: roleValue('rootwebarea'), - childIds: [mainId, navId], - }], - [mainId, { - nodeId: mainId, - ignored: false, - role: roleValue('main'), - childIds: [headingId, buttonId], - backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId, - }], - [navId, { - nodeId: navId, - ignored: false, - role: roleValue('navigation'), - childIds: [listId], - backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId, - }], - [listId, { - nodeId: listId, - ignored: false, - role: roleValue('list'), - childIds: [listItemId], - backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId, - }], - [listItemId, { - nodeId: listItemId, - ignored: false, - role: roleValue('listitem'), - childIds: [linkId], - backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId, - }], - [linkId, { - nodeId: linkId, - ignored: false, - role: roleValue('link'), - name: nameValue('Docs'), - backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId, - }], - [headingId, { - nodeId: headingId, - ignored: false, - role: roleValue('heading'), - name: nameValue('Title'), - backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId, - }], - [buttonId, { - nodeId: buttonId, - ignored: false, - role: roleValue('button'), - name: nameValue('Submit'), - backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId, - }], + [ + rootId, + { + nodeId: rootId, + ignored: false, + role: roleValue('rootwebarea'), + childIds: [mainId, navId], + }, + ], + [ + mainId, + { + nodeId: mainId, + ignored: false, + role: roleValue('main'), + childIds: [headingId, buttonId], + backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId, + }, + ], + [ + navId, + { + nodeId: navId, + ignored: false, + role: roleValue('navigation'), + childIds: [listId], + backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId, + }, + ], + [ + listId, + { + nodeId: listId, + ignored: false, + role: roleValue('list'), + childIds: [listItemId], + backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId, + }, + ], + [ + listItemId, + { + nodeId: listItemId, + ignored: false, + role: roleValue('listitem'), + childIds: [linkId], + backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId, + }, + ], + [ + linkId, + { + nodeId: linkId, + ignored: false, + role: roleValue('link'), + name: nameValue('Docs'), + backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId, + }, + ], + [ + headingId, + { + nodeId: headingId, + ignored: false, + role: roleValue('heading'), + name: nameValue('Title'), + backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId, + }, + ], + [ + buttonId, + { + nodeId: buttonId, + ignored: false, + role: roleValue('button'), + name: nameValue('Submit'), + backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId, + }, + ], ]) const allowed = new Set([204 as Protocol.DOM.BackendNodeId]) @@ -145,25 +169,19 @@ describe('aria-snapshot tree filters', () => { role: 'navigation', name: '', ignored: false, - children: [ - { role: 'link', name: 'Home', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] }, - ], + children: [{ role: 'link', name: 'Home', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] }], }, { role: 'labeltext', name: '', ignored: false, - children: [ - { role: 'statictext', name: 'Email', ignored: false, children: [] }, - ], + children: [{ role: 'statictext', name: 'Email', ignored: false, children: [] }], }, { role: 'generic', name: '', ignored: false, - children: [ - { role: 'button', name: 'Save', backendNodeId: 1 as Protocol.DOM.BackendNodeId, children: [] }, - ], + children: [{ role: 'button', name: 'Save', backendNodeId: 1 as Protocol.DOM.BackendNodeId, children: [] }], }, { role: 'generic', @@ -186,35 +204,51 @@ describe('aria-snapshot tree filters', () => { ], } - const domByBackendId = new Map - }>([ - [1 as Protocol.DOM.BackendNodeId, { - nodeId: 10 as Protocol.DOM.NodeId, - backendNodeId: 1 as Protocol.DOM.BackendNodeId, - nodeName: 'BUTTON', - attributes: new Map([['id', 'save-btn']]), - }], - [2 as Protocol.DOM.BackendNodeId, { - nodeId: 11 as Protocol.DOM.NodeId, - backendNodeId: 2 as Protocol.DOM.BackendNodeId, - nodeName: 'A', - attributes: new Map([['data-testid', 'nav-home']]), - }], - [3 as Protocol.DOM.BackendNodeId, { - nodeId: 12 as Protocol.DOM.NodeId, - backendNodeId: 3 as Protocol.DOM.BackendNodeId, - nodeName: 'BUTTON', - attributes: new Map([['id', 'ignored-action']]), - }], + const domByBackendId = new Map< + Protocol.DOM.BackendNodeId, + { + nodeId: Protocol.DOM.NodeId + parentId?: Protocol.DOM.NodeId + backendNodeId: Protocol.DOM.BackendNodeId + nodeName: string + attributes: Map + } + >([ + [ + 1 as Protocol.DOM.BackendNodeId, + { + nodeId: 10 as Protocol.DOM.NodeId, + backendNodeId: 1 as Protocol.DOM.BackendNodeId, + nodeName: 'BUTTON', + attributes: new Map([['id', 'save-btn']]), + }, + ], + [ + 2 as Protocol.DOM.BackendNodeId, + { + nodeId: 11 as Protocol.DOM.NodeId, + backendNodeId: 2 as Protocol.DOM.BackendNodeId, + nodeName: 'A', + attributes: new Map([['data-testid', 'nav-home']]), + }, + ], + [ + 3 as Protocol.DOM.BackendNodeId, + { + nodeId: 12 as Protocol.DOM.NodeId, + backendNodeId: 3 as Protocol.DOM.BackendNodeId, + nodeName: 'BUTTON', + attributes: new Map([['id', 'ignored-action']]), + }, + ], ]) let refCounter = 0 - const createRefForNode = (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }): string => { + const createRefForNode = (options: { + backendNodeId?: Protocol.DOM.BackendNodeId + role: string + name: string + }): string => { refCounter += 1 return `${options.role}-${options.name}-${refCounter}` } @@ -317,31 +351,43 @@ describe('aria-snapshot tree filters', () => { ], } - const domByBackendId = new Map - }>([ - [2 as Protocol.DOM.BackendNodeId, { - nodeId: 20 as Protocol.DOM.NodeId, - backendNodeId: 2 as Protocol.DOM.BackendNodeId, - nodeName: 'INPUT', - attributes: new Map([['data-testid', 'email-input']]), - }], - [3 as Protocol.DOM.BackendNodeId, { - nodeId: 21 as Protocol.DOM.NodeId, - backendNodeId: 3 as Protocol.DOM.BackendNodeId, - nodeName: 'BUTTON', - attributes: new Map([['id', 'save-primary']]), - }], - [4 as Protocol.DOM.BackendNodeId, { - nodeId: 22 as Protocol.DOM.NodeId, - backendNodeId: 4 as Protocol.DOM.BackendNodeId, - nodeName: 'BUTTON', - attributes: new Map([['id', 'save-secondary']]), - }], + const domByBackendId = new Map< + Protocol.DOM.BackendNodeId, + { + nodeId: Protocol.DOM.NodeId + parentId?: Protocol.DOM.NodeId + backendNodeId: Protocol.DOM.BackendNodeId + nodeName: string + attributes: Map + } + >([ + [ + 2 as Protocol.DOM.BackendNodeId, + { + nodeId: 20 as Protocol.DOM.NodeId, + backendNodeId: 2 as Protocol.DOM.BackendNodeId, + nodeName: 'INPUT', + attributes: new Map([['data-testid', 'email-input']]), + }, + ], + [ + 3 as Protocol.DOM.BackendNodeId, + { + nodeId: 21 as Protocol.DOM.NodeId, + backendNodeId: 3 as Protocol.DOM.BackendNodeId, + nodeName: 'BUTTON', + attributes: new Map([['id', 'save-primary']]), + }, + ], + [ + 4 as Protocol.DOM.BackendNodeId, + { + nodeId: 22 as Protocol.DOM.NodeId, + backendNodeId: 4 as Protocol.DOM.BackendNodeId, + nodeName: 'BUTTON', + attributes: new Map([['id', 'save-secondary']]), + }, + ], ]) let refCounter = 0 @@ -432,13 +478,16 @@ describe('aria-snapshot tree filters', () => { ], } - const domByBackendId = new Map - }>() + const domByBackendId = new Map< + Protocol.DOM.BackendNodeId, + { + nodeId: Protocol.DOM.NodeId + parentId?: Protocol.DOM.NodeId + backendNodeId: Protocol.DOM.BackendNodeId + nodeName: string + attributes: Map + } + >() const createRefForNode = (): string | null => { return null @@ -491,25 +540,34 @@ describe('aria-snapshot tree filters', () => { ], } - const domByBackendId = new Map - }>([ - [5 as Protocol.DOM.BackendNodeId, { - nodeId: 30 as Protocol.DOM.NodeId, - backendNodeId: 5 as Protocol.DOM.BackendNodeId, - nodeName: 'BUTTON', - attributes: new Map([['id', 'delete']]), - }], - [6 as Protocol.DOM.BackendNodeId, { - nodeId: 31 as Protocol.DOM.NodeId, - backendNodeId: 6 as Protocol.DOM.BackendNodeId, - nodeName: 'BUTTON', - attributes: new Map([['id', 'save']]), - }], + const domByBackendId = new Map< + Protocol.DOM.BackendNodeId, + { + nodeId: Protocol.DOM.NodeId + parentId?: Protocol.DOM.NodeId + backendNodeId: Protocol.DOM.BackendNodeId + nodeName: string + attributes: Map + } + >([ + [ + 5 as Protocol.DOM.BackendNodeId, + { + nodeId: 30 as Protocol.DOM.NodeId, + backendNodeId: 5 as Protocol.DOM.BackendNodeId, + nodeName: 'BUTTON', + attributes: new Map([['id', 'delete']]), + }, + ], + [ + 6 as Protocol.DOM.BackendNodeId, + { + nodeId: 31 as Protocol.DOM.NodeId, + backendNodeId: 6 as Protocol.DOM.BackendNodeId, + nodeName: 'BUTTON', + attributes: new Map([['id', 'save']]), + }, + ], ]) let refCounter = 0 diff --git a/playwriter/src/cdp-log.ts b/playwriter/src/cdp-log.ts index f7eb9c6..da05cb9 100644 --- a/playwriter/src/cdp-log.ts +++ b/playwriter/src/cdp-log.ts @@ -41,7 +41,10 @@ function createTruncatingReplacer({ maxStringLength }: { maxStringLength: number } } -export function createCdpLogger({ logFilePath, maxStringLength }: { logFilePath?: string; maxStringLength?: number } = {}): CdpLogger { +export function createCdpLogger({ + logFilePath, + maxStringLength, +}: { logFilePath?: string; maxStringLength?: number } = {}): CdpLogger { const resolvedLogFilePath = logFilePath || LOG_CDP_FILE_PATH const logDir = path.dirname(resolvedLogFilePath) if (!fs.existsSync(logDir)) { diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index 84872a4..aef6fad 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -93,7 +93,6 @@ type ExtensionConnection = { pingInterval: ReturnType | null } - export type RelayServer = { close(): void on(event: K, listener: RelayServerEvents[K]): void @@ -129,7 +128,7 @@ export async function startPlayWriterCDPRelayServer({ const getExtensionConnection = ( extensionId?: string | null, - options: { allowFallback?: boolean } = {} + options: { allowFallback?: boolean } = {}, ): ExtensionConnection | null => { if (extensionId) { const direct = extensionConnections.get(extensionId) @@ -177,7 +176,7 @@ export async function startPlayWriterCDPRelayServer({ const getPageTargetForFrameId = ({ connection, - frameId + frameId, }: { connection: ExtensionConnection frameId: string @@ -216,7 +215,7 @@ export async function startPlayWriterCDPRelayServer({ sessionId, params, id, - source + source, }: { direction: 'to-playwright' | 'from-playwright' | 'from-extension' clientId?: string @@ -232,7 +231,7 @@ export async function startPlayWriterCDPRelayServer({ 'Network.responseReceivedExtraInfo', 'Network.dataReceived', 'Network.requestWillBeSent', - 'Network.loadingFinished' + 'Network.loadingFinished', ] if (noisyEvents.includes(method)) { @@ -287,9 +286,7 @@ export async function startPlayWriterCDPRelayServer({ source?: 'extension' | 'server' extensionId?: string | null }) { - const messageToSend = source === 'server' && 'method' in message - ? { ...message, __serverGenerated: true } - : message + const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message logCdpJson({ timestamp: new Date().toISOString(), @@ -306,7 +303,7 @@ export async function startPlayWriterCDPRelayServer({ method: message.method, sessionId: 'sessionId' in message ? message.sessionId : undefined, params: 'params' in message ? message.params : undefined, - source + source, }) } @@ -409,7 +406,7 @@ export async function startPlayWriterCDPRelayServer({ reject: (error) => { clearTimeout(timeoutId) reject(error) - } + }, }) }) } @@ -429,7 +426,7 @@ export async function startPlayWriterCDPRelayServer({ (params) => sendToExtension({ extensionId: connection.id, ...params }), () => extensionConnections.has(connection.id), logger, - ) + ), ) } return recordingRelays.get(connection.id) || null @@ -451,7 +448,7 @@ export async function startPlayWriterCDPRelayServer({ try { logger?.log(pc.blue('Auto-creating initial tab for Playwright client')) - const result = await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 }) as { + const result = (await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 })) as { success: boolean tabId: number sessionId: string @@ -462,9 +459,13 @@ export async function startPlayWriterCDPRelayServer({ sessionId: result.sessionId, targetId: result.targetInfo.targetId, targetInfo: result.targetInfo, - frameIds: new Set() + frameIds: new Set(), }) - logger?.log(pc.blue(`Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`)) + logger?.log( + pc.blue( + `Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`, + ), + ) } } catch (e) { logger?.error('Failed to auto-create initial tab:', e) @@ -493,7 +494,7 @@ export async function startPlayWriterCDPRelayServer({ product: 'Chrome/Extension-Bridge', revision: '1.0.0', userAgent: 'CDP-Bridge-Server/1.0.0', - jsVersion: 'V8' + jsVersion: 'V8', } satisfies Protocol.Browser.GetVersionResponse } @@ -516,7 +517,7 @@ export async function startPlayWriterCDPRelayServer({ await sendToExtension({ extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', - params: { method, params, source } + params: { method, params, source }, }) return {} } @@ -568,8 +569,8 @@ export async function startPlayWriterCDPRelayServer({ .filter((t) => !isRestrictedTarget(t.targetInfo)) .map((t) => ({ ...t.targetInfo, - attached: true - })) + attached: true, + })), } } @@ -577,7 +578,7 @@ export async function startPlayWriterCDPRelayServer({ return await sendToExtension({ extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', - params: { method, params, source } + params: { method, params, source }, }) } @@ -585,7 +586,7 @@ export async function startPlayWriterCDPRelayServer({ return await sendToExtension({ extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', - params: { method, params, source } + params: { method, params, source }, }) } @@ -594,7 +595,7 @@ export async function startPlayWriterCDPRelayServer({ return await sendToExtension({ extensionId: extension?.id || extensionId, method: 'ghost-browser', - params + params, }) } @@ -616,7 +617,11 @@ export async function startPlayWriterCDPRelayServer({ } const timeout = setTimeout(() => { emitter.off('cdp:event', handler) - logger?.log(pc.yellow(`IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`)) + logger?.log( + pc.yellow( + `IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`, + ), + ) resolve() }, 3000) emitter.on('cdp:event', handler) @@ -625,7 +630,7 @@ export async function startPlayWriterCDPRelayServer({ const result = await sendToExtension({ extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', - params: { sessionId, method, params, source } + params: { sessionId, method, params, source }, }) await contextCreatedPromise @@ -637,7 +642,7 @@ export async function startPlayWriterCDPRelayServer({ return await sendToExtension({ extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', - params: { sessionId, method, params, source } + params: { sessionId, method, params, source }, }) } @@ -645,19 +650,22 @@ export async function startPlayWriterCDPRelayServer({ // CORS middleware for HTTP endpoints - only allows our specific extension IDs. // This prevents other extensions from reading responses via fetch/XHR. // WebSocket connections have their own separate origin validation. - app.use('*', cors({ - origin: (origin) => { - if (!origin.startsWith('chrome-extension://')) { - return null - } - const extensionId = origin.replace('chrome-extension://', '') - if (!EXTENSION_IDS.includes(extensionId)) { - return null - } - return origin - }, - allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'], - })) + app.use( + '*', + cors({ + origin: (origin) => { + if (!origin.startsWith('chrome-extension://')) { + return null + } + const extensionId = origin.replace('chrome-extension://', '') + if (!EXTENSION_IDS.includes(extensionId)) { + return null + } + return origin + }, + allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'], + }), + ) const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) const getCdpWsUrl = (c: { req: { header: (name: string) => string | undefined } }) => { @@ -709,76 +717,76 @@ export async function startPlayWriterCDPRelayServer({ app .on(['GET', 'PUT'], '/json/version', (c) => { return c.json({ - 'Browser': `Playwriter/${VERSION}`, + Browser: `Playwriter/${VERSION}`, 'Protocol-Version': '1.3', - 'webSocketDebuggerUrl': getCdpWsUrl(c) + webSocketDebuggerUrl: getCdpWsUrl(c), }) }) .on(['GET', 'PUT'], '/json/version/', (c) => { return c.json({ - 'Browser': `Playwriter/${VERSION}`, + Browser: `Playwriter/${VERSION}`, 'Protocol-Version': '1.3', - 'webSocketDebuggerUrl': getCdpWsUrl(c) + webSocketDebuggerUrl: getCdpWsUrl(c), }) }) .on(['GET', 'PUT'], '/json/list', (c) => { const wsUrl = getCdpWsUrl(c) const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() return c.json( - Array.from(defaultTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map((t) => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, description: t.targetInfo.title, url: t.targetInfo.url, webSocketDebuggerUrl: wsUrl, - devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}` - })) + devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`, + })), ) }) .on(['GET', 'PUT'], '/json/list/', (c) => { const wsUrl = getCdpWsUrl(c) const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() return c.json( - Array.from(defaultTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map((t) => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, description: t.targetInfo.title, url: t.targetInfo.url, webSocketDebuggerUrl: wsUrl, - devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}` - })) + devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`, + })), ) }) .on(['GET', 'PUT'], '/json', (c) => { const wsUrl = getCdpWsUrl(c) const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() return c.json( - Array.from(defaultTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map((t) => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, description: t.targetInfo.title, url: t.targetInfo.url, webSocketDebuggerUrl: wsUrl, - devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}` - })) + devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`, + })), ) }) .on(['GET', 'PUT'], '/json/', (c) => { const wsUrl = getCdpWsUrl(c) const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map() return c.json( - Array.from(defaultTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map((t) => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, description: t.targetInfo.title, url: t.targetInfo.url, webSocketDebuggerUrl: wsUrl, - devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}` - })) + devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`, + })), ) }) @@ -798,216 +806,250 @@ export async function startPlayWriterCDPRelayServer({ // Browsers always send Origin header for WebSocket connections, but Node.js clients don't. // We only allow our specific extension IDs to prevent malicious websites or extensions // from connecting to the local WebSocket server. - app.get('/cdp/:clientId?', (c, next) => { - const origin = c.req.header('origin') + app.get( + '/cdp/:clientId?', + (c, next) => { + const origin = c.req.header('origin') - // Validate Origin header if present (Node.js clients don't send it) - if (origin) { - if (origin.startsWith('chrome-extension://')) { - const extensionId = origin.replace('chrome-extension://', '') - if (!EXTENSION_IDS.includes(extensionId)) { - logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`)) + // Validate Origin header if present (Node.js clients don't send it) + if (origin) { + if (origin.startsWith('chrome-extension://')) { + const extensionId = origin.replace('chrome-extension://', '') + if (!EXTENSION_IDS.includes(extensionId)) { + logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`)) + return c.text('Forbidden', 403) + } + } else { + logger?.log(pc.red(`Rejecting /cdp WebSocket from origin: ${origin}`)) return c.text('Forbidden', 403) } - } else { - logger?.log(pc.red(`Rejecting /cdp WebSocket from origin: ${origin}`)) - return c.text('Forbidden', 403) } - } - if (token) { + if (token) { + const url = new URL(c.req.url, 'http://localhost') + const providedToken = url.searchParams.get('token') + if (providedToken !== token) { + return c.text('Unauthorized', 401) + } + } + return next() + }, + upgradeWebSocket((c) => { + const clientId = c.req.param('clientId') || 'default' const url = new URL(c.req.url, 'http://localhost') - const providedToken = url.searchParams.get('token') - if (providedToken !== token) { - return c.text('Unauthorized', 401) - } - } - return next() - }, upgradeWebSocket((c) => { - const clientId = c.req.param('clientId') || 'default' - const url = new URL(c.req.url, 'http://localhost') - const requestedExtensionId = url.searchParams.get('extensionId') - const resolvedExtension = getExtensionConnection(requestedExtensionId) - const allowDefault = !requestedExtensionId && extensionConnections.size === 1 - const defaultExtension = allowDefault ? getExtensionConnection(null, { allowFallback: true }) : null - const clientExtensionId = resolvedExtension?.id || defaultExtension?.id || null + const requestedExtensionId = url.searchParams.get('extensionId') + const resolvedExtension = getExtensionConnection(requestedExtensionId) + const allowDefault = !requestedExtensionId && extensionConnections.size === 1 + const defaultExtension = allowDefault ? getExtensionConnection(null, { allowFallback: true }) : null + const clientExtensionId = resolvedExtension?.id || defaultExtension?.id || null - return { - async onOpen(_event, ws) { - if (playwrightClients.has(clientId)) { - logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`)) - ws.close(4004, 'Duplicate Playwright clientId') - return - } + return { + async onOpen(_event, ws) { + if (playwrightClients.has(clientId)) { + logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`)) + ws.close(4004, 'Duplicate Playwright clientId') + return + } - if (!clientExtensionId) { - const reason = requestedExtensionId - ? `Unknown extensionId: ${requestedExtensionId}` - : 'Multiple extensions connected. Specify extensionId.' - logger?.log(pc.yellow(`Rejecting Playwright client ${clientId}: ${reason}`)) - ws.close(4003, reason) - return - } + if (!clientExtensionId) { + const reason = requestedExtensionId + ? `Unknown extensionId: ${requestedExtensionId}` + : 'Multiple extensions connected. Specify extensionId.' + logger?.log(pc.yellow(`Rejecting Playwright client ${clientId}: ${reason}`)) + ws.close(4003, reason) + return + } - // Add client first so it can receive Target.attachedToTarget events - playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId }) - const extensionConnection = getExtensionConnection(clientExtensionId) - const targetCount = extensionConnection?.connectedTargets.size || 0 - logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`)) - }, + // Add client first so it can receive Target.attachedToTarget events + playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId }) + const extensionConnection = getExtensionConnection(clientExtensionId) + const targetCount = extensionConnection?.connectedTargets.size || 0 + logger?.log( + pc.green( + `Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`, + ), + ) + }, - async onMessage(event, ws) { - let message: CDPCommand + async onMessage(event, ws) { + let message: CDPCommand - try { - message = JSON.parse(event.data.toString()) - } catch { - return - } + try { + message = JSON.parse(event.data.toString()) + } catch { + return + } - const { id, sessionId, method, params, source } = message + const { id, sessionId, method, params, source } = message - logCdpJson({ - timestamp: new Date().toISOString(), - direction: 'from-playwright', - clientId, - message, - }) + logCdpJson({ + timestamp: new Date().toISOString(), + direction: 'from-playwright', + clientId, + message, + }) - logCdpMessage({ - direction: 'from-playwright', - clientId, - method, - sessionId, - id - }) + logCdpMessage({ + direction: 'from-playwright', + clientId, + method, + sessionId, + id, + }) - emitter.emit('cdp:command', { clientId, command: message }) + emitter.emit('cdp:command', { clientId, command: message }) - const extensionConnection = getExtensionConnection(clientExtensionId) - if (!extensionConnection) { - sendToPlaywright({ - message: { + const extensionConnection = getExtensionConnection(clientExtensionId) + if (!extensionConnection) { + sendToPlaywright({ + message: { + id, + sessionId, + error: { message: 'Extension not connected' }, + }, + clientId, + }) + return + } + + try { + const result: any = await routeCdpCommand({ + extensionId: extensionConnection.id, + method, + params, + sessionId, + source, + }) + + if (method === 'Target.setAutoAttach' && !sessionId) { + for (const target of extensionConnection.connectedTargets.values()) { + // Skip restricted targets (extensions, chrome:// URLs, non-page types) + if (isRestrictedTarget(target.targetInfo)) { + continue + } + const attachedPayload = { + method: 'Target.attachedToTarget', + params: { + sessionId: target.sessionId, + targetInfo: { + ...target.targetInfo, + attached: true, + }, + waitingForDebugger: false, + }, + } satisfies CDPEventFor<'Target.attachedToTarget'> + if (!target.targetInfo.url) { + logger?.error( + pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), + JSON.stringify(attachedPayload), + ) + } + logger?.log( + pc.magenta('[Server] Target.attachedToTarget full payload:'), + JSON.stringify(attachedPayload), + ) + sendToPlaywright({ + message: attachedPayload, + clientId, + source: 'server', + }) + } + } + + if (method === 'Target.setDiscoverTargets' && (params as any)?.discover) { + for (const target of extensionConnection.connectedTargets.values()) { + // Skip restricted targets (extensions, chrome:// URLs, non-page types) + if (isRestrictedTarget(target.targetInfo)) { + continue + } + const targetCreatedPayload = { + method: 'Target.targetCreated', + params: { + targetInfo: { + ...target.targetInfo, + attached: true, + }, + }, + } satisfies CDPEventFor<'Target.targetCreated'> + if (!target.targetInfo.url) { + logger?.error( + pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), + JSON.stringify(targetCreatedPayload), + ) + } + logger?.log( + pc.magenta('[Server] Target.targetCreated full payload:'), + JSON.stringify(targetCreatedPayload), + ) + sendToPlaywright({ + message: targetCreatedPayload, + clientId, + source: 'server', + }) + } + } + + if (method === 'Target.attachToTarget' && result?.sessionId) { + const targetId = params?.targetId + const target = Array.from(extensionConnection.connectedTargets.values()).find( + (t) => t.targetId === targetId, + ) + if (target) { + const attachedPayload = { + method: 'Target.attachedToTarget', + params: { + sessionId: result.sessionId, + targetInfo: { + ...target.targetInfo, + attached: true, + }, + waitingForDebugger: false, + }, + } satisfies CDPEventFor<'Target.attachedToTarget'> + if (!target.targetInfo.url) { + logger?.error( + pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), + JSON.stringify(attachedPayload), + ) + } + logger?.log( + pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), + JSON.stringify(attachedPayload), + ) + sendToPlaywright({ + message: attachedPayload, + clientId, + source: 'server', + }) + } + } + + const response: CDPResponseBase = { id, sessionId, result } + sendToPlaywright({ message: response, clientId }) + emitter.emit('cdp:response', { clientId, response, command: message }) + } catch (e) { + logger?.error('Error handling CDP command:', method, params, e) + const errorResponse: CDPResponseBase = { id, sessionId, - error: { message: 'Extension not connected' } - }, - clientId - }) - return - } - - try { - const result: any = await routeCdpCommand({ extensionId: extensionConnection.id, method, params, sessionId, source }) - - if (method === 'Target.setAutoAttach' && !sessionId) { - for (const target of extensionConnection.connectedTargets.values()) { - // Skip restricted targets (extensions, chrome:// URLs, non-page types) - if (isRestrictedTarget(target.targetInfo)) { - continue - } - const attachedPayload = { - method: 'Target.attachedToTarget', - params: { - sessionId: target.sessionId, - targetInfo: { - ...target.targetInfo, - attached: true - }, - waitingForDebugger: false - } - } satisfies CDPEventFor<'Target.attachedToTarget'> - if (!target.targetInfo.url) { - logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload)) - } - logger?.log(pc.magenta('[Server] Target.attachedToTarget full payload:'), JSON.stringify(attachedPayload)) - sendToPlaywright({ - message: attachedPayload, - clientId, - source: 'server' - }) + error: { message: (e as Error).message }, } + sendToPlaywright({ message: errorResponse, clientId }) + emitter.emit('cdp:response', { clientId, response: errorResponse, command: message }) } + }, - if (method === 'Target.setDiscoverTargets' && (params as any)?.discover) { - for (const target of extensionConnection.connectedTargets.values()) { - // Skip restricted targets (extensions, chrome:// URLs, non-page types) - if (isRestrictedTarget(target.targetInfo)) { - continue - } - const targetCreatedPayload = { - method: 'Target.targetCreated', - params: { - targetInfo: { - ...target.targetInfo, - attached: true - } - } - } satisfies CDPEventFor<'Target.targetCreated'> - if (!target.targetInfo.url) { - logger?.error(pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload)) - } - logger?.log(pc.magenta('[Server] Target.targetCreated full payload:'), JSON.stringify(targetCreatedPayload)) - sendToPlaywright({ - message: targetCreatedPayload, - clientId, - source: 'server' - }) - } - } + onClose() { + playwrightClients.delete(clientId) + logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${playwrightClients.size} remaining)`)) + }, - if (method === 'Target.attachToTarget' && result?.sessionId) { - const targetId = params?.targetId - const target = Array.from(extensionConnection.connectedTargets.values()).find(t => t.targetId === targetId) - if (target) { - const attachedPayload = { - method: 'Target.attachedToTarget', - params: { - sessionId: result.sessionId, - targetInfo: { - ...target.targetInfo, - attached: true - }, - waitingForDebugger: false - } - } satisfies CDPEventFor<'Target.attachedToTarget'> - if (!target.targetInfo.url) { - logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload)) - } - logger?.log(pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload)) - sendToPlaywright({ - message: attachedPayload, - clientId, - source: 'server' - }) - } - } - - const response: CDPResponseBase = { id, sessionId, result } - sendToPlaywright({ message: response, clientId }) - emitter.emit('cdp:response', { clientId, response, command: message }) - } catch (e) { - logger?.error('Error handling CDP command:', method, params, e) - const errorResponse: CDPResponseBase = { - id, - sessionId, - error: { message: (e as Error).message } - } - sendToPlaywright({ message: errorResponse, clientId }) - emitter.emit('cdp:response', { clientId, response: errorResponse, command: message }) - } - }, - - onClose() { - playwrightClients.delete(clientId) - logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${playwrightClients.size} remaining)`)) - }, - - onError(event) { - logger?.error(`Playwright WebSocket error [${clientId}]:`, event) + onError(event) { + logger?.error(`Playwright WebSocket error [${clientId}]:`, event) + }, } - } - })) + }), + ) const getExtensionInfoFromRequest = (c: { req: { query: (name: string) => string | undefined } }): ExtensionInfo => { const browser = c.req.query('browser') @@ -1022,402 +1064,425 @@ export async function startPlayWriterCDPRelayServer({ } } - app.get('/extension', (c, next) => { - // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost. - // This prevents attackers on the network from hijacking the browser session - // even if the server is exposed via 0.0.0.0. - const info = getConnInfo(c) - const remoteAddress = info.remote.address - const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1' + app.get( + '/extension', + (c, next) => { + // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost. + // This prevents attackers on the network from hijacking the browser session + // even if the server is exposed via 0.0.0.0. + const info = getConnInfo(c) + const remoteAddress = info.remote.address + const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1' - if (!isLocalhost) { - logger?.log(pc.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`)) - return c.text('Forbidden - Extension must be local', 403) - } + if (!isLocalhost) { + logger?.log(pc.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`)) + return c.text('Forbidden - Extension must be local', 403) + } - // 2. Origin Validation: Prevent browser-based attacks (CSRF). - // Browsers cannot spoof the Origin header, so this ensures the connection - // is coming from our specific Chrome Extension, not a malicious website. - const origin = c.req.header('origin') - if (!origin || !origin.startsWith('chrome-extension://')) { - logger?.log(pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`)) - return c.text('Forbidden', 403) - } + // 2. Origin Validation: Prevent browser-based attacks (CSRF). + // Browsers cannot spoof the Origin header, so this ensures the connection + // is coming from our specific Chrome Extension, not a malicious website. + const origin = c.req.header('origin') + if (!origin || !origin.startsWith('chrome-extension://')) { + logger?.log( + pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`), + ) + return c.text('Forbidden', 403) + } - const extensionId = origin.replace('chrome-extension://', '') - if (!EXTENSION_IDS.includes(extensionId)) { - logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`)) - return c.text('Forbidden', 403) - } + const extensionId = origin.replace('chrome-extension://', '') + if (!EXTENSION_IDS.includes(extensionId)) { + logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`)) + return c.text('Forbidden', 403) + } - return next() - }, upgradeWebSocket((c) => { - const incomingExtensionInfo = getExtensionInfoFromRequest(c) - const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` - return { - onOpen(_event, ws) { - const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId) - const existingId = extensionKeyIndex.get(stableKey) - if (existingId && existingId !== connectionId) { - logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingId} -> ${connectionId})`)) - const existingConnection = extensionConnections.get(existingId) - if (existingConnection) { - existingConnection.ws.close(4001, 'Extension Replaced') + return next() + }, + upgradeWebSocket((c) => { + const incomingExtensionInfo = getExtensionInfoFromRequest(c) + const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + return { + onOpen(_event, ws) { + const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId) + const existingId = extensionKeyIndex.get(stableKey) + if (existingId && existingId !== connectionId) { + logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingId} -> ${connectionId})`)) + const existingConnection = extensionConnections.get(existingId) + if (existingConnection) { + existingConnection.ws.close(4001, 'Extension Replaced') + } } - } - const connection: ExtensionConnection = { - id: connectionId, - ws, - info: incomingExtensionInfo, - stableKey, - connectedTargets: new Map(), - pendingRequests: new Map(), - messageId: 0, - pingInterval: null, - } - extensionConnections.set(connectionId, connection) - extensionKeyIndex.set(stableKey, connectionId) - startExtensionPing(connectionId) - logger?.log(`Extension connected (${connectionId})`) - }, - - async onMessage(event, ws) { - const connection = extensionConnections.get(connectionId) - if (!connection) { - ws.close(1000, 'Extension not registered') - return - } - // Handle binary data (recording chunks) - if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) { - const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data) - const relay = getRecordingRelay(connectionId) - if (relay) { - relay.handleBinaryData(buffer) + const connection: ExtensionConnection = { + id: connectionId, + ws, + info: incomingExtensionInfo, + stableKey, + connectedTargets: new Map(), + pendingRequests: new Map(), + messageId: 0, + pingInterval: null, } - return - } + extensionConnections.set(connectionId, connection) + extensionKeyIndex.set(stableKey, connectionId) + startExtensionPing(connectionId) + logger?.log(`Extension connected (${connectionId})`) + }, - let message: ExtensionMessage - - try { - message = JSON.parse(event.data.toString()) - } catch { - ws.close(1000, 'Invalid JSON') - return - } - - if (message.id !== undefined) { - const pending = connection.pendingRequests.get(message.id) - if (!pending) { - logger?.log('Unexpected response with id:', message.id) + async onMessage(event, ws) { + const connection = extensionConnections.get(connectionId) + if (!connection) { + ws.close(1000, 'Extension not registered') + return + } + // Handle binary data (recording chunks) + if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) { + const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data) + const relay = getRecordingRelay(connectionId) + if (relay) { + relay.handleBinaryData(buffer) + } return } - connection.pendingRequests.delete(message.id) + let message: ExtensionMessage - if (message.error) { - pending.reject(new Error(message.error)) - } else { - pending.resolve(message.result) - } - } else if (message.method === 'pong') { - // Keep-alive response, nothing to do - } else if (message.method === 'log') { - const { level, args } = message.params - const logFn = (logger as Record)?.[level] as ((...args: unknown[]) => void) | undefined - const logFunc = logFn || logger?.log - const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`) - logFunc?.(prefix, ...args) - } else if (message.method === 'recordingData') { - const relay = getRecordingRelay(connectionId) - if (relay) { - relay.handleRecordingData(message as RecordingDataMessage) - } - } else if (message.method === 'recordingCancelled') { - const relay = getRecordingRelay(connectionId) - if (relay) { - relay.handleRecordingCancelled(message as RecordingCancelledMessage) - } - } else { - const extensionEvent = message as ExtensionEventMessage - - if (extensionEvent.method !== 'forwardCDPEvent') { + try { + message = JSON.parse(event.data.toString()) + } catch { + ws.close(1000, 'Invalid JSON') return } - const { method, params, sessionId } = extensionEvent.params - - logCdpJson({ - timestamp: new Date().toISOString(), - direction: 'from-extension', - message: { method, params, sessionId }, - }) - - logCdpMessage({ - direction: 'from-extension', - method, - sessionId, - params - }) - - const cdpEvent: CDPEventBase = { method, sessionId, params } - emitter.emit('cdp:event', { event: cdpEvent, sessionId }) - - if (method === 'Target.attachedToTarget') { - const targetParams = params as Protocol.Target.AttachedToTargetEvent - const incomingSessionId = sessionId - const iframeParentFrameId = targetParams.targetInfo.parentFrameId - const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId - ? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId - : undefined - - // Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.) - if (isRestrictedTarget(targetParams.targetInfo)) { - if (targetParams.waitingForDebugger && targetParams.sessionId) { - void sendToExtension({ - extensionId: connectionId, - method: 'forwardCDPCommand', - params: { - sessionId: targetParams.sessionId, - method: 'Runtime.runIfWaitingForDebugger', - params: {}, - source: 'server', - }, - }).catch((error) => { - const message = error instanceof Error ? error.message : String(error) - logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), message) - }) - } - logger?.log(pc.gray(`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`)) + if (message.id !== undefined) { + const pending = connection.pendingRequests.get(message.id) + if (!pending) { + logger?.log('Unexpected response with id:', message.id) return } - if (!targetParams.targetInfo.url) { - logger?.error(pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'), JSON.stringify({ method, params: targetParams, sessionId })) + connection.pendingRequests.delete(message.id) + + if (message.error) { + pending.reject(new Error(message.error)) + } else { + pending.resolve(message.result) } - logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId })) + } else if (message.method === 'pong') { + // Keep-alive response, nothing to do + } else if (message.method === 'log') { + const { level, args } = message.params + const logFn = (logger as Record)?.[level] as ((...args: unknown[]) => void) | undefined + const logFunc = logFn || logger?.log + const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`) + logFunc?.(prefix, ...args) + } else if (message.method === 'recordingData') { + const relay = getRecordingRelay(connectionId) + if (relay) { + relay.handleRecordingData(message as RecordingDataMessage) + } + } else if (message.method === 'recordingCancelled') { + const relay = getRecordingRelay(connectionId) + if (relay) { + relay.handleRecordingCancelled(message as RecordingCancelledMessage) + } + } else { + const extensionEvent = message as ExtensionEventMessage - // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response) - const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId) - const existingTarget = connection.connectedTargets.get(targetParams.sessionId) + if (extensionEvent.method !== 'forwardCDPEvent') { + return + } - // Always update our local state with latest target info - connection.connectedTargets.set(targetParams.sessionId, { - sessionId: targetParams.sessionId, - targetId: targetParams.targetInfo.targetId, - targetInfo: targetParams.targetInfo, - frameIds: existingTarget?.frameIds ?? new Set() + const { method, params, sessionId } = extensionEvent.params + + logCdpJson({ + timestamp: new Date().toISOString(), + direction: 'from-extension', + message: { method, params, sessionId }, }) - // Only forward to Playwright if this is a new target to avoid duplicates - if (!alreadyConnected) { + logCdpMessage({ + direction: 'from-extension', + method, + sessionId, + params, + }) + + const cdpEvent: CDPEventBase = { method, sessionId, params } + emitter.emit('cdp:event', { event: cdpEvent, sessionId }) + + if (method === 'Target.attachedToTarget') { + const targetParams = params as Protocol.Target.AttachedToTargetEvent + const incomingSessionId = sessionId + const iframeParentFrameId = targetParams.targetInfo.parentFrameId + const iframeOwnerSessionId = + targetParams.targetInfo.type === 'iframe' && iframeParentFrameId + ? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId + : undefined + + // Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.) + if (isRestrictedTarget(targetParams.targetInfo)) { + if (targetParams.waitingForDebugger && targetParams.sessionId) { + void sendToExtension({ + extensionId: connectionId, + method: 'forwardCDPCommand', + params: { + sessionId: targetParams.sessionId, + method: 'Runtime.runIfWaitingForDebugger', + params: {}, + source: 'server', + }, + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error) + logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), message) + }) + } + logger?.log( + pc.gray( + `[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`, + ), + ) + return + } + + if (!targetParams.targetInfo.url) { + logger?.error( + pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'), + JSON.stringify({ method, params: targetParams, sessionId }), + ) + } + logger?.log( + pc.yellow('[Extension] Target.attachedToTarget full payload:'), + JSON.stringify({ method, params: targetParams, sessionId }), + ) + + // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response) + const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId) + const existingTarget = connection.connectedTargets.get(targetParams.sessionId) + + // Always update our local state with latest target info + connection.connectedTargets.set(targetParams.sessionId, { + sessionId: targetParams.sessionId, + targetId: targetParams.targetInfo.targetId, + targetInfo: targetParams.targetInfo, + frameIds: existingTarget?.frameIds ?? new Set(), + }) + + // Only forward to Playwright if this is a new target to avoid duplicates + if (!alreadyConnected) { + sendToPlaywright({ + message: { + // Iframe targets must be routed to the parent page sessionId so Playwright attaches them under the right page. + // - iframeOwnerSessionId: derived parent session via parentFrameId -> page sessionId (frameId tracking). + // - incomingSessionId: extension event sessionId for the parent tab. + // The frameId mapping is racy: Target.attachedToTarget can arrive before Page.frameAttached/Page.frameNavigated populate frameIds. + // When iframeOwnerSessionId is missing we must fall back to incomingSessionId, otherwise Playwright receives the attach on the root + // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations. + sessionId: iframeOwnerSessionId ?? incomingSessionId, + method: 'Target.attachedToTarget', + params: targetParams, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } + } else if (method === 'Target.detachedFromTarget') { + const detachParams = params as Protocol.Target.DetachedFromTargetEvent + connection.connectedTargets.delete(detachParams.sessionId) + sendToPlaywright({ message: { - // Iframe targets must be routed to the parent page sessionId so Playwright attaches them under the right page. - // - iframeOwnerSessionId: derived parent session via parentFrameId -> page sessionId (frameId tracking). - // - incomingSessionId: extension event sessionId for the parent tab. - // The frameId mapping is racy: Target.attachedToTarget can arrive before Page.frameAttached/Page.frameNavigated populate frameIds. - // When iframeOwnerSessionId is missing we must fall back to incomingSessionId, otherwise Playwright receives the attach on the root - // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations. - sessionId: iframeOwnerSessionId ?? incomingSessionId, - method: 'Target.attachedToTarget', - params: targetParams + method: 'Target.detachedFromTarget', + params: detachParams, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } else if (method === 'Target.targetCrashed') { + const crashParams = params as Protocol.Target.TargetCrashedEvent + for (const [sid, target] of connection.connectedTargets.entries()) { + if (target.targetId === crashParams.targetId) { + connection.connectedTargets.delete(sid) + logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId) + break + } + } + + sendToPlaywright({ + message: { + method: 'Target.targetCrashed', + params: crashParams, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } else if (method === 'Target.targetInfoChanged') { + const infoParams = params as Protocol.Target.TargetInfoChangedEvent + for (const target of connection.connectedTargets.values()) { + if (target.targetId === infoParams.targetInfo.targetId) { + target.targetInfo = infoParams.targetInfo + break + } + } + + sendToPlaywright({ + message: { + method: 'Target.targetInfoChanged', + params: infoParams, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } else if (method === 'Page.frameAttached') { + const frameParams = params as Protocol.Page.FrameAttachedEvent + if (sessionId) { + const target = connection.connectedTargets.get(sessionId) + if (target) { + target.frameIds.add(frameParams.frameId) + } + } + + sendToPlaywright({ + message: { + sessionId, + method, + params, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } else if (method === 'Page.frameDetached') { + const frameParams = params as Protocol.Page.FrameDetachedEvent + const ownerTarget = getPageTargetForFrameId({ connection, frameId: frameParams.frameId }) + if (ownerTarget) { + ownerTarget.frameIds.delete(frameParams.frameId) + } + + sendToPlaywright({ + message: { + sessionId, + method, + params, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } else if (method === 'Page.frameNavigated') { + const frameParams = params as Protocol.Page.FrameNavigatedEvent + if (sessionId) { + const target = connection.connectedTargets.get(sessionId) + if (target) { + target.frameIds.add(frameParams.frame.id) + } + } + if (!frameParams.frame.parentId && sessionId) { + const target = connection.connectedTargets.get(sessionId) + if (target) { + target.targetInfo = { + ...target.targetInfo, + url: frameParams.frame.url, + title: frameParams.frame.name || target.targetInfo.title, + } + logger?.log( + pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), + frameParams.frame.url, + ) + } + } + + sendToPlaywright({ + message: { + sessionId, + method, + params, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } else if (method === 'Page.navigatedWithinDocument') { + const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent + if (sessionId) { + const target = connection.connectedTargets.get(sessionId) + if (target) { + target.targetInfo = { + ...target.targetInfo, + url: navParams.url, + } + logger?.log( + pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), + navParams.url, + ) + } + } + + sendToPlaywright({ + message: { + sessionId, + method, + params, + } as CDPEventBase, + source: 'extension', + extensionId: connectionId, + }) + } else { + sendToPlaywright({ + message: { + sessionId, + method, + params, } as CDPEventBase, source: 'extension', extensionId: connectionId, }) } - } else if (method === 'Target.detachedFromTarget') { - const detachParams = params as Protocol.Target.DetachedFromTargetEvent - connection.connectedTargets.delete(detachParams.sessionId) + } + }, - sendToPlaywright({ - message: { - method: 'Target.detachedFromTarget', - params: detachParams - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, - }) - } else if (method === 'Target.targetCrashed') { - const crashParams = params as Protocol.Target.TargetCrashedEvent - for (const [sid, target] of connection.connectedTargets.entries()) { - if (target.targetId === crashParams.targetId) { - connection.connectedTargets.delete(sid) - logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId) - break - } - } + onClose(event, ws) { + logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`) + stopExtensionPing(connectionId) - sendToPlaywright({ - message: { - method: 'Target.targetCrashed', - params: crashParams - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, - }) - } else if (method === 'Target.targetInfoChanged') { - const infoParams = params as Protocol.Target.TargetInfoChangedEvent - for (const target of connection.connectedTargets.values()) { - if (target.targetId === infoParams.targetInfo.targetId) { - target.targetInfo = infoParams.targetInfo - break - } - } - - sendToPlaywright({ - message: { - method: 'Target.targetInfoChanged', - params: infoParams - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, - }) - } else if (method === 'Page.frameAttached') { - const frameParams = params as Protocol.Page.FrameAttachedEvent - if (sessionId) { - const target = connection.connectedTargets.get(sessionId) - if (target) { - target.frameIds.add(frameParams.frameId) - } - } - - sendToPlaywright({ - message: { - sessionId, - method, - params - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, - }) - } else if (method === 'Page.frameDetached') { - const frameParams = params as Protocol.Page.FrameDetachedEvent - const ownerTarget = getPageTargetForFrameId({ connection, frameId: frameParams.frameId }) - if (ownerTarget) { - ownerTarget.frameIds.delete(frameParams.frameId) - } - - sendToPlaywright({ - message: { - sessionId, - method, - params - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, - }) - } else if (method === 'Page.frameNavigated') { - const frameParams = params as Protocol.Page.FrameNavigatedEvent - if (sessionId) { - const target = connection.connectedTargets.get(sessionId) - if (target) { - target.frameIds.add(frameParams.frame.id) - } - } - if (!frameParams.frame.parentId && sessionId) { - const target = connection.connectedTargets.get(sessionId) - if (target) { - target.targetInfo = { - ...target.targetInfo, - url: frameParams.frame.url, - title: frameParams.frame.name || target.targetInfo.title, - } - logger?.log(pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url) - } - } - - sendToPlaywright({ - message: { - sessionId, - method, - params - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, - }) - } else if (method === 'Page.navigatedWithinDocument') { - const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent - if (sessionId) { - const target = connection.connectedTargets.get(sessionId) - if (target) { - target.targetInfo = { - ...target.targetInfo, - url: navParams.url, - } - logger?.log(pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url) - } - } - - sendToPlaywright({ - message: { - sessionId, - method, - params - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, - }) - } else { - sendToPlaywright({ - message: { - sessionId, - method, - params - } as CDPEventBase, - source: 'extension', - extensionId: connectionId, + // Cancel any active recordings BEFORE removing connection (cancelRecording checks isExtensionConnected) + const recordingRelay = recordingRelays.get(connectionId) + if (recordingRelay) { + recordingRelay.cancelRecording({}).catch(() => { + // Ignore errors during cleanup }) } - } - }, + recordingRelays.delete(connectionId) - onClose(event, ws) { - logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`) - stopExtensionPing(connectionId) - - // Cancel any active recordings BEFORE removing connection (cancelRecording checks isExtensionConnected) - const recordingRelay = recordingRelays.get(connectionId) - if (recordingRelay) { - recordingRelay.cancelRecording({}).catch(() => { - // Ignore errors during cleanup - }) - } - recordingRelays.delete(connectionId) - - const connection = extensionConnections.get(connectionId) - if (connection) { - for (const pending of connection.pendingRequests.values()) { - pending.reject(new Error('Extension connection closed')) + const connection = extensionConnections.get(connectionId) + if (connection) { + for (const pending of connection.pendingRequests.values()) { + pending.reject(new Error('Extension connection closed')) + } + connection.pendingRequests.clear() + connection.connectedTargets.clear() } - connection.pendingRequests.clear() - connection.connectedTargets.clear() - } - if (connection) { - const mappedId = extensionKeyIndex.get(connection.stableKey) - if (mappedId === connectionId) { - extensionKeyIndex.delete(connection.stableKey) + if (connection) { + const mappedId = extensionKeyIndex.get(connection.stableKey) + if (mappedId === connectionId) { + extensionKeyIndex.delete(connection.stableKey) + } } - } - extensionConnections.delete(connectionId) + extensionConnections.delete(connectionId) - for (const [clientId, client] of playwrightClients.entries()) { - if (client.extensionId !== connectionId) { - continue + for (const [clientId, client] of playwrightClients.entries()) { + if (client.extensionId !== connectionId) { + continue + } + client.ws.close(1000, 'Extension disconnected') + playwrightClients.delete(clientId) } - client.ws.close(1000, 'Extension disconnected') - playwrightClients.delete(clientId) - } - }, + }, - onError(event) { - logger?.error('Extension WebSocket error:', event) + onError(event) { + logger?.error('Extension WebSocket error:', event) + }, } - } - })) + }), + ) // ============================================================================ // CLI Execute Endpoints - For stateful code execution via CLI @@ -1457,7 +1522,10 @@ export async function startPlayWriterCDPRelayServer({ // preflight as a fallback, which our CORS policy already blocks. // 3. When token mode is enabled (remote access), require the token. // ============================================================================ - const privilegedRouteMiddleware = async (c: Parameters[1]>[0], next: () => Promise) => { + const privilegedRouteMiddleware = async ( + c: Parameters[1]>[0], + next: () => Promise, + ) => { // Block cross-origin browser requests via Sec-Fetch-Site header. // Browsers always set this forbidden header; it cannot be spoofed. // Non-browser clients (Node.js, curl, MCP) don't send it. @@ -1497,7 +1565,7 @@ export async function startPlayWriterCDPRelayServer({ app.post('/cli/execute', async (c) => { try { - const body = await c.req.json() as { sessionId: string | number; code: string; timeout?: number } + const body = (await c.req.json()) as { sessionId: string | number; code: string; timeout?: number } const sessionId = normalizeSessionId(body.sessionId) const { code, timeout = 10000 } = body @@ -1508,7 +1576,10 @@ export async function startPlayWriterCDPRelayServer({ const manager = await getExecutorManager() const existingExecutor = manager.getSession(sessionId) if (!existingExecutor) { - return c.json({ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], isError: true }, 404) + return c.json( + { text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], isError: true }, + 404, + ) } const result = await existingExecutor.execute(code, timeout) @@ -1521,7 +1592,7 @@ export async function startPlayWriterCDPRelayServer({ app.post('/cli/reset', async (c) => { try { - const body = await c.req.json() as { sessionId: string | number } + const body = (await c.req.json()) as { sessionId: string | number } const sessionId = normalizeSessionId(body.sessionId) if (!sessionId) { @@ -1556,7 +1627,7 @@ export async function startPlayWriterCDPRelayServer({ }) app.post('/cli/session/new', async (c) => { - const body = await c.req.json().catch(() => ({})) as { extensionId?: string | null; cwd?: string } + const body = (await c.req.json().catch(() => ({}))) as { extensionId?: string | null; cwd?: string } const sessionId = String(nextSessionNumber++) const extensionId = body.extensionId || null const cwd = body.cwd @@ -1605,7 +1676,7 @@ export async function startPlayWriterCDPRelayServer({ app.post('/cli/session/delete', async (c) => { try { - const body = await c.req.json() as { sessionId: string | number } + const body = (await c.req.json()) as { sessionId: string | number } const sessionId = normalizeSessionId(body.sessionId) if (!sessionId) { @@ -1630,7 +1701,14 @@ export async function startPlayWriterCDPRelayServer({ // ============================================================================ app.post('/recording/start', async (c) => { - const body = await c.req.json() as { outputPath?: string; sessionId?: string | number; frameRate?: number; audio?: boolean; videoBitsPerSecond?: number; audioBitsPerSecond?: number } + const body = (await c.req.json()) as { + outputPath?: string + sessionId?: string | number + frameRate?: number + audio?: boolean + videoBitsPerSecond?: number + audioBitsPerSecond?: number + } const sessionId = normalizeSessionId(body.sessionId) const { sessionId: _sessionId, ...recordingOptions } = body const manager = await getExecutorManager() @@ -1645,12 +1723,12 @@ export async function startPlayWriterCDPRelayServer({ } const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions) as StartRecordingBody const result = await relay.startRecording(recordingParams) - const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500) + const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500 return c.json(result, status) }) app.post('/recording/stop', async (c) => { - const body = await c.req.json() as { sessionId?: string | number } + const body = (await c.req.json()) as { sessionId?: string | number } const sessionId = normalizeSessionId(body.sessionId) const manager = await getExecutorManager() const executor = sessionId ? manager.getSession(sessionId) : null @@ -1664,7 +1742,7 @@ export async function startPlayWriterCDPRelayServer({ } const stopParams: StopRecordingParams = sessionId ? { sessionId } : {} const result = await relay.stopRecording(stopParams) - const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500) + const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500 return c.json(result, status) }) @@ -1684,7 +1762,7 @@ export async function startPlayWriterCDPRelayServer({ }) app.post('/recording/cancel', async (c) => { - const body = await c.req.json() as { sessionId?: string | number } + const body = (await c.req.json()) as { sessionId?: string | number } const sessionId = normalizeSessionId(body.sessionId) const manager = await getExecutorManager() const executor = sessionId ? manager.getSession(sessionId) : null @@ -1732,6 +1810,6 @@ export async function startPlayWriterCDPRelayServer({ }, off(event: K, listener: RelayServerEvents[K]) { emitter.off(event, listener as (...args: unknown[]) => void) - } + }, } } diff --git a/playwriter/src/cdp-session.ts b/playwriter/src/cdp-session.ts index 40c7870..23074a6 100644 --- a/playwriter/src/cdp-session.ts +++ b/playwriter/src/cdp-session.ts @@ -14,9 +14,15 @@ export interface ICDPSession { sessionId?: string | null, ): Promise - on(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): unknown + on( + event: K, + callback: (params: ProtocolMapping.Events[K][0]) => void, + ): unknown - off(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): unknown + off( + event: K, + callback: (params: ProtocolMapping.Events[K][0]) => void, + ): unknown detach(): Promise getSessionId?(): string | null @@ -46,7 +52,10 @@ export class PlaywrightCDPSessionAdapter implements ICDPSession { return this } - off(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): this { + off( + event: K, + callback: (params: ProtocolMapping.Events[K][0]) => void, + ): this { this._playwrightSession.off(event as never, callback as never) return this } diff --git a/playwriter/src/cdp-types.ts b/playwriter/src/cdp-types.ts index 3b5b2a8..fd67a75 100644 --- a/playwriter/src/cdp-types.ts +++ b/playwriter/src/cdp-types.ts @@ -1,55 +1,55 @@ -import type { Protocol } from 'devtools-protocol'; -import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; +import type { Protocol } from 'devtools-protocol' +import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js' -export type CDPCommandSource = 'playwriter'; +export type CDPCommandSource = 'playwriter' export type CDPCommandFor = { - id: number; - sessionId?: string; - method: T; - params?: ProtocolMapping.Commands[T]['paramsType'][0]; - source?: CDPCommandSource; -}; + id: number + sessionId?: string + method: T + params?: ProtocolMapping.Commands[T]['paramsType'][0] + source?: CDPCommandSource +} export type CDPCommand = { - [K in keyof ProtocolMapping.Commands]: CDPCommandFor; -}[keyof ProtocolMapping.Commands]; + [K in keyof ProtocolMapping.Commands]: CDPCommandFor +}[keyof ProtocolMapping.Commands] export type CDPResponseFor = { - id: number; - sessionId?: string; - result?: ProtocolMapping.Commands[T]['returnType']; - error?: { code?: number; message: string }; -}; + id: number + sessionId?: string + result?: ProtocolMapping.Commands[T]['returnType'] + error?: { code?: number; message: string } +} export type CDPResponse = { - [K in keyof ProtocolMapping.Commands]: CDPResponseFor; -}[keyof ProtocolMapping.Commands]; + [K in keyof ProtocolMapping.Commands]: CDPResponseFor +}[keyof ProtocolMapping.Commands] export type CDPEventFor = { - method: T; - sessionId?: string; - params?: ProtocolMapping.Events[T][0]; -}; + method: T + sessionId?: string + params?: ProtocolMapping.Events[T][0] +} export type CDPEvent = { - [K in keyof ProtocolMapping.Events]: CDPEventFor; -}[keyof ProtocolMapping.Events]; + [K in keyof ProtocolMapping.Events]: CDPEventFor +}[keyof ProtocolMapping.Events] export type CDPResponseBase = { - id: number; - sessionId?: string; - result?: unknown; - error?: { code?: number; message: string }; -}; + id: number + sessionId?: string + result?: unknown + error?: { code?: number; message: string } +} export type CDPEventBase = { - method: string; - sessionId?: string; - params?: unknown; -}; + method: string + sessionId?: string + params?: unknown +} -export type CDPMessage = CDPCommand | CDPResponse | CDPEvent; +export type CDPMessage = CDPCommand | CDPResponse | CDPEvent export type RelayServerEvents = { 'cdp:command': (data: { clientId: string; command: CDPCommand }) => void @@ -57,14 +57,14 @@ export type RelayServerEvents = { 'cdp:response': (data: { clientId: string; response: CDPResponseBase; command: CDPCommand }) => void } -export { Protocol, ProtocolMapping }; +export { Protocol, ProtocolMapping } // types tests. to see if types are right with some simple examples if (false as any) { const browserVersionCommand = { id: 1, method: 'Browser.getVersion', - } satisfies CDPCommand; + } satisfies CDPCommand const browserVersionResponse = { id: 1, @@ -74,8 +74,8 @@ if (false as any) { revision: '123', userAgent: 'Mozilla/5.0', jsVersion: 'V8', - } - } satisfies CDPResponse; + }, + } satisfies CDPResponse const targetAttachCommand = { id: 2, @@ -83,13 +83,13 @@ if (false as any) { params: { autoAttach: true, waitForDebuggerOnStart: false, - } - } satisfies CDPCommand; + }, + } satisfies CDPCommand const targetAttachResponse = { id: 2, result: undefined, - } satisfies CDPResponse; + } satisfies CDPResponse const attachedToTargetEvent = { method: 'Target.attachedToTarget', @@ -104,8 +104,8 @@ if (false as any) { canAccessOpener: false, }, waitingForDebugger: false, - } - } satisfies CDPEvent; + }, + } satisfies CDPEvent const consoleMessageEvent = { method: 'Runtime.consoleAPICalled', @@ -114,23 +114,23 @@ if (false as any) { args: [], executionContextId: 1, timestamp: 123456789, - } - } satisfies CDPEvent; + }, + } satisfies CDPEvent const pageNavigateCommand = { id: 3, method: 'Page.navigate', params: { url: 'https://example.com', - } - } satisfies CDPCommand; + }, + } satisfies CDPCommand const pageNavigateResponse = { id: 3, result: { frameId: 'frame-1', - } - } satisfies CDPResponse; + }, + } satisfies CDPResponse const networkRequestEvent = { method: 'Network.requestWillBeSent', @@ -153,6 +153,6 @@ if (false as any) { }, redirectHasExtraInfo: false, type: 'XHR', - } - } satisfies CDPEvent; + }, + } satisfies CDPEvent } diff --git a/playwriter/src/cli.ts b/playwriter/src/cli.ts index ce2e52b..ebbfb8a 100644 --- a/playwriter/src/cli.ts +++ b/playwriter/src/cli.ts @@ -12,7 +12,13 @@ Buffer.prototype[util.inspect.custom] = function () { } import { killPortProcess } from './kill-port.js' import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js' -import { ensureRelayServer, RELAY_PORT, waitForExtension, getExtensionOutdatedWarning, getExtensionStatus } from './relay-client.js' +import { + ensureRelayServer, + RELAY_PORT, + waitForExtension, + getExtensionOutdatedWarning, + getExtensionStatus, +} from './relay-client.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -76,7 +82,7 @@ async function fetchExtensionsStatus(host?: string): Promise if (!fallback.ok) { return [] } - const fallbackData = await fallback.json() as { + const fallbackData = (await fallback.json()) as { connected: boolean activeTargets: number browser: string | null @@ -86,16 +92,18 @@ async function fetchExtensionsStatus(host?: string): Promise if (!fallbackData?.connected) { return [] } - return [{ - extensionId: 'default', - stableKey: undefined, - browser: fallbackData?.browser, - profile: fallbackData?.profile, - activeTargets: fallbackData?.activeTargets, - playwriterVersion: fallbackData?.playwriterVersion || null, - }] + return [ + { + extensionId: 'default', + stableKey: undefined, + browser: fallbackData?.browser, + profile: fallbackData?.profile, + activeTargets: fallbackData?.activeTargets, + playwriterVersion: fallbackData?.playwriterVersion || null, + }, + ] } - const data = await response.json() as { + const data = (await response.json()) as { extensions: ExtensionStatus[] } return data?.extensions || [] @@ -150,7 +158,9 @@ async function executeCode(options: { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(token || process.env.PLAYWRITER_TOKEN ? { 'Authorization': `Bearer ${token || process.env.PLAYWRITER_TOKEN}` } : {}), + ...(token || process.env.PLAYWRITER_TOKEN + ? { Authorization: `Bearer ${token || process.env.PLAYWRITER_TOKEN}` } + : {}), }, body: JSON.stringify({ sessionId, code, timeout, cwd }), }) @@ -161,7 +171,11 @@ async function executeCode(options: { process.exit(1) } - const result = await response.json() as { text: string; images: Array<{ data: string; mimeType: string }>; isError: boolean } + const result = (await response.json()) as { + text: string + images: Array<{ data: string; mimeType: string }> + isError: boolean + } // Print output if (result.text) { @@ -206,7 +220,6 @@ cli }) } - const extensions = await fetchExtensionsStatus(options.host) if (extensions.length === 0) { console.error('No connected browsers detected. Click the Playwriter extension icon.') @@ -253,9 +266,10 @@ cli try { const serverUrl = await getServerUrl(options.host) - const extensionId = selectedExtension.extensionId === 'default' - ? null - : (selectedExtension.stableKey || selectedExtension.extensionId) + const extensionId = + selectedExtension.extensionId === 'default' + ? null + : selectedExtension.stableKey || selectedExtension.extensionId const cwd = process.cwd() const response = await fetch(`${serverUrl}/cli/session/new`, { method: 'POST', @@ -267,7 +281,7 @@ cli console.error(`Error: ${response.status} ${text}`) process.exit(1) } - const result = await response.json() as { id: string; extensionId: string | null } + const result = (await response.json()) as { id: string; extensionId: string | null } console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`) } catch (error: any) { console.error(`Error: ${error.message}`) @@ -300,7 +314,7 @@ cli console.error(`Error: ${response.status} ${await response.text()}`) process.exit(1) } - const result = await response.json() as { + const result = (await response.json()) as { sessions: Array<{ id: string stateKeys: string[] @@ -335,7 +349,7 @@ cli ' ' + 'EXT'.padEnd(extensionWidth) + ' ' + - 'STATE KEYS' + 'STATE KEYS', ) console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8)) @@ -351,7 +365,7 @@ cli ' ' + (session.extensionId || '-').padEnd(extensionWidth) + ' ' + - stateStr + stateStr, ) } }) @@ -374,7 +388,7 @@ cli }) if (!response.ok) { - const result = await response.json() as { error: string } + const result = (await response.json()) as { error: string } console.error(`Error: ${result.error}`) process.exit(1) } @@ -410,8 +424,10 @@ cli process.exit(1) } - const result = await response.json() as { success: boolean; pageUrl: string; pagesCount: number } - console.log(`Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`) + const result = (await response.json()) as { success: boolean; pageUrl: string; pagesCount: number } + console.log( + `Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`, + ) } catch (error: any) { console.error(`Error: ${error.message}`) process.exit(1) @@ -512,20 +528,16 @@ cli }) }) -cli - .command('logfile', 'Print the path to the relay server log file') - .action(() => { - console.log(`relay: ${LOG_FILE_PATH}`) - console.log(`cdp: ${LOG_CDP_FILE_PATH}`) - }) +cli.command('logfile', 'Print the path to the relay server log file').action(() => { + console.log(`relay: ${LOG_FILE_PATH}`) + console.log(`cdp: ${LOG_CDP_FILE_PATH}`) +}) -cli - .command('skill', 'Print the full playwriter usage instructions') - .action(() => { - const skillPath = path.join(__dirname, '..', 'src', 'skill.md') - const content = fs.readFileSync(skillPath, 'utf-8') - console.log(content) - }) +cli.command('skill', 'Print the full playwriter usage instructions').action(() => { + const skillPath = path.join(__dirname, '..', 'src', 'skill.md') + const content = fs.readFileSync(skillPath, 'utf-8') + console.log(content) +}) cli.help() cli.version(VERSION) diff --git a/playwriter/src/create-logger.ts b/playwriter/src/create-logger.ts index 675be18..1592b89 100644 --- a/playwriter/src/create-logger.ts +++ b/playwriter/src/create-logger.ts @@ -21,9 +21,11 @@ export function createFileLogger({ logFilePath }: { logFilePath?: string } = {}) let queue: Promise = Promise.resolve() const log = (...args: unknown[]): Promise => { - const message = args.map(arg => - typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false, maxStringLength: 1000 }) - ).join(' ') + const message = args + .map((arg) => + typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false, maxStringLength: 1000 }), + ) + .join(' ') queue = queue.then(() => fs.promises.appendFile(resolvedLogFilePath, stripAnsi(message) + '\n')) return queue } diff --git a/playwriter/src/debugger-examples-types.ts b/playwriter/src/debugger-examples-types.ts index d8c25f6..16804ae 100644 --- a/playwriter/src/debugger-examples-types.ts +++ b/playwriter/src/debugger-examples-types.ts @@ -8,6 +8,9 @@ export declare const page: Page export declare const getCDPSession: (options: { page: Page }) => Promise export declare const createDebugger: (options: { cdp: ICDPSession }) => Debugger export declare const createEditor: (options: { cdp: ICDPSession }) => Editor -export declare const getStylesForLocator: (options: { locator: Locator; includeUserAgentStyles?: boolean }) => Promise +export declare const getStylesForLocator: (options: { + locator: Locator + includeUserAgentStyles?: boolean +}) => Promise export declare const formatStylesAsText: (styles: StylesResult) => string export declare const console: { log: (...args: unknown[]) => void } diff --git a/playwriter/src/debugger.ts b/playwriter/src/debugger.ts index 3f98a63..7752fb6 100644 --- a/playwriter/src/debugger.ts +++ b/playwriter/src/debugger.ts @@ -24,8 +24,6 @@ export interface EvaluateResult { value: unknown } - - export interface ScriptInfo { scriptId: string url: string @@ -533,9 +531,7 @@ export class Debugger { async listScripts({ search }: { search?: string } = {}): Promise { await this.enable() const scripts = Array.from(this.scripts.values()) - const filtered = search - ? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase())) - : scripts + const filtered = search ? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase())) : scripts return filtered.slice(0, 20) } diff --git a/playwriter/src/diff-utils.ts b/playwriter/src/diff-utils.ts index 10d9acc..0f0217f 100644 --- a/playwriter/src/diff-utils.ts +++ b/playwriter/src/diff-utils.ts @@ -16,7 +16,7 @@ export interface CreateSmartDiffOptions { /** * Creates a smart diff that returns full content when changes exceed threshold. - * + * * When more than `threshold` (default 50%) of lines have changed, showing a diff * is not useful - we return the full new content instead. */ @@ -54,10 +54,7 @@ export function createSmartDiff(options: CreateSmartDiffOptions): SmartDiffResul const changeRatio = Math.min(changedLines / maxLines, 1) // Cap at 100% // Build unified diff string from structured patch - const diffLines: string[] = [ - `--- ${label} (previous)`, - `+++ ${label} (current)`, - ] + const diffLines: string[] = [`--- ${label} (previous)`, `+++ ${label} (current)`] for (const hunk of patch.hunks) { diffLines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`) diffLines.push(...hunk.lines) diff --git a/playwriter/src/editor-examples.ts b/playwriter/src/editor-examples.ts index e3002ef..4bfc268 100644 --- a/playwriter/src/editor-examples.ts +++ b/playwriter/src/editor-examples.ts @@ -145,4 +145,14 @@ async function searchStyles() { console.log(matches) } -export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript, readStylesheet, editStylesheet, searchStyles } +export { + listScripts, + readScript, + editScript, + searchScripts, + writeScript, + editInlineScript, + readStylesheet, + editStylesheet, + searchStyles, +} diff --git a/playwriter/src/editor.ts b/playwriter/src/editor.ts index baf4d55..5108ad7 100644 --- a/playwriter/src/editor.ts +++ b/playwriter/src/editor.ts @@ -294,7 +294,7 @@ export class Editor { private async setSource( id: { scriptId: string } | { styleSheetId: string }, content: string, - dryRun = false + dryRun = false, ): Promise { if ('styleSheetId' in id) { await this.cdp.send('CSS.setStyleSheetText', { styleSheetId: id.styleSheetId, text: content }) @@ -410,7 +410,15 @@ export class Editor { * @param options.content - New content * @param options.dryRun - If true, validate without applying (default false, only works for JS) */ - async write({ url, content, dryRun = false }: { url: string; content: string; dryRun?: boolean }): Promise { + async write({ + url, + content, + dryRun = false, + }: { + url: string + content: string + dryRun?: boolean + }): Promise { await this.enable() const id = this.getIdByUrl(url) return this.setSource(id, content, dryRun) diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index b9013a2..c989c93 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -452,7 +452,7 @@ export class PlaywrightExecutor { private setupPageConsoleListener(page: Page) { // Use targetId() if available, fallback to internal _guid for CDP connections - const targetId = page.targetId() || (page as any)._guid as string | undefined + const targetId = page.targetId() || ((page as any)._guid as string | undefined) if (!targetId) { return } @@ -488,7 +488,11 @@ export class PlaywrightExecutor { }) } - private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null }> { + private async checkExtensionStatus(): Promise<{ + connected: boolean + activeTargets: number + playwriterVersion: string | null + }> { const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig const { httpBaseUrl } = parseRelayHost(host, port) const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null } @@ -504,10 +508,19 @@ export class PlaywrightExecutor { if (!fallback.ok) { return notConnected } - return (await fallback.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null } + return (await fallback.json()) as { + connected: boolean + activeTargets: number + playwriterVersion: string | null + } } - const data = await response.json() as { - extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number; playwriterVersion?: string | null }> + const data = (await response.json()) as { + extensions: Array<{ + extensionId: string + stableKey?: string + activeTargets: number + playwriterVersion?: string | null + }> } const extension = data.extensions.find((item) => { return item.extensionId === extensionId || item.stableKey === extensionId @@ -515,7 +528,11 @@ export class PlaywrightExecutor { if (!extension) { return notConnected } - return { connected: true, activeTargets: extension.activeTargets, playwriterVersion: extension?.playwriterVersion || null } + return { + connected: true, + activeTargets: extension.activeTargets, + playwriterVersion: extension?.playwriterVersion || null, + } } const response = await fetch(`${httpBaseUrl}/extension/status`, { @@ -663,7 +680,13 @@ export class PlaywrightExecutor { const formattedArgs = args .map((arg) => { if (typeof arg === 'string') return arg - return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 }) + return util.inspect(arg, { + depth: 4, + colors: false, + maxArrayLength: 100, + maxStringLength: 1000, + breakLength: 80, + }) }) .join(' ') text += `[${method}] ${formattedArgs}\n` @@ -710,14 +733,25 @@ export class PlaywrightExecutor { /** Only include interactive elements (default: true) */ interactiveOnly?: boolean }) => { - const { page: targetPage, frame, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options + const { + page: targetPage, + frame, + locator, + search, + showDiffSinceLastCall = true, + interactiveOnly = false, + } = options const resolvedPage = targetPage || page if (!resolvedPage) { throw new Error('snapshot requires a page') } // Use new in-page implementation via getAriaSnapshot - const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({ + const { + snapshot: rawSnapshot, + refs, + getSelectorForRef, + } = await getAriaSnapshot({ page: resolvedPage, frame, locator, @@ -829,7 +863,7 @@ export class PlaywrightExecutor { if (filterPage) { // Use targetId() if available, fallback to internal _guid for CDP connections - const targetId = filterPage.targetId() || (filterPage as any)._guid as string | undefined + const targetId = filterPage.targetId() || ((filterPage as any)._guid as string | undefined) if (!targetId) { throw new Error('Could not get page targetId') } @@ -984,9 +1018,7 @@ export class PlaywrightExecutor { const vmContext = vm.createContext(vmContextObj) const autoReturn = shouldAutoReturn(code) - const wrappedCode = autoReturn - ? `(async () => { return await (${code}) })()` - : `(async () => { ${code} })()` + const wrappedCode = autoReturn ? `(async () => { return await (${code}) })()` : `(async () => { ${code} })()` const hasExplicitReturn = autoReturn || /\breturn\b/.test(code) const result = await Promise.race([ @@ -1003,7 +1035,13 @@ export class PlaywrightExecutor { const formatted = typeof resolvedResult === 'string' ? resolvedResult - : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 }) + : util.inspect(resolvedResult, { + depth: 4, + colors: false, + maxArrayLength: 100, + maxStringLength: 1000, + breakLength: 80, + }) if (formatted.trim()) { responseText += `[return value] ${formatted}\n` } diff --git a/playwriter/src/extension-connection.test.ts b/playwriter/src/extension-connection.test.ts index 57411ba..559dedd 100644 --- a/playwriter/src/extension-connection.test.ts +++ b/playwriter/src/extension-connection.test.ts @@ -8,273 +8,281 @@ import './test-declarations.js' const TEST_PORT = 19990 describe('Extension Connection Tests', () => { - let client: Awaited>['client'] - let cleanup: (() => Promise) | null = null - let testCtx: TestContext | null = null + let client: Awaited>['client'] + let cleanup: (() => Promise) | null = null + let testCtx: TestContext | null = null - beforeAll(async () => { - testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-conn-test-', toggleExtension: true }) + beforeAll(async () => { + testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-conn-test-', toggleExtension: true }) - const result = await createMCPClient({ port: TEST_PORT }) - client = result.client - cleanup = result.cleanup - }, 600000) + const result = await createMCPClient({ port: TEST_PORT }) + client = result.client + cleanup = result.cleanup + }, 600000) - afterAll(async () => { - await cleanupTestContext(testCtx, cleanup) - cleanup = null - testCtx = null + afterAll(async () => { + await cleanupTestContext(testCtx, cleanup) + cleanup = null + testCtx = null + }) + + const getBrowserContext = () => { + if (!testCtx?.browserContext) throw new Error('Browser not initialized') + return testCtx.browserContext + } + + it('should handle new pages and toggling with new connections', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + // 1. Create a new page + const page = await browserContext.newPage() + const testUrl = 'https://example.com/' + await page.goto(testUrl) + + await page.bringToFront() + + // 2. Enable extension on this new tab + const result = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() }) + expect(result.isConnected).toBe(true) - const getBrowserContext = () => { - if (!testCtx?.browserContext) throw new Error('Browser not initialized') - return testCtx.browserContext + // 3. Verify we can connect via direct CDP and see the page + let directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + let contexts = directBrowser.contexts() + let pages = contexts[0].pages() + + let foundPage = pages.find((p) => p.url() === testUrl) + expect(foundPage).toBeDefined() + expect(foundPage?.url()).toBe(testUrl) + + const sum1 = await foundPage?.evaluate(() => 1 + 1) + expect(sum1).toBe(2) + + await directBrowser.close() + + // 4. Disable extension on this tab + const resultDisabled = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() + }) + expect(resultDisabled.isConnected).toBe(false) + + // 5. Connect again - page should NOT be visible + directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + contexts = directBrowser.contexts() + pages = contexts[0].pages() + + foundPage = pages.find((p) => p.url() === testUrl) + expect(foundPage).toBeUndefined() + + await directBrowser.close() + + // 6. Re-enable extension + const resultEnabled = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() + }) + expect(resultEnabled.isConnected).toBe(true) + + // 7. Verify page is back + directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + await new Promise((r) => setTimeout(r, 100)) + + contexts = directBrowser.contexts() + if (contexts[0].pages().length === 0) { + await new Promise((r) => setTimeout(r, 100)) } + pages = contexts[0].pages() - it('should handle new pages and toggling with new connections', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + foundPage = pages.find((p) => p.url() === testUrl) + expect(foundPage).toBeDefined() + expect(foundPage?.url()).toBe(testUrl) - // 1. Create a new page - const page = await browserContext.newPage() - const testUrl = 'https://example.com/' - await page.goto(testUrl) + const sum2 = await foundPage?.evaluate(() => 2 + 2) + expect(sum2).toBe(4) - await page.bringToFront() + await directBrowser.close() + await page.close() + }, 120000) - // 2. Enable extension on this new tab - const result = await serviceWorker.evaluate(async () => { - return await globalThis.toggleExtensionForActiveTab() - }) - expect(result.isConnected).toBe(true) + it('should handle new pages and toggling with persistent connection', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - // 3. Verify we can connect via direct CDP and see the page - let directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - let contexts = directBrowser.contexts() - let pages = contexts[0].pages() + const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + await new Promise((r) => setTimeout(r, 100)) - let foundPage = pages.find(p => p.url() === testUrl) - expect(foundPage).toBeDefined() - expect(foundPage?.url()).toBe(testUrl) + // 1. Create a new page + const page = await browserContext.newPage() + const testUrl = 'https://example.com/persistent' + await page.goto(testUrl) + await page.bringToFront() - const sum1 = await foundPage?.evaluate(() => 1 + 1) - expect(sum1).toBe(2) - - await directBrowser.close() - - // 4. Disable extension on this tab - const resultDisabled = await serviceWorker.evaluate(async () => { - return await globalThis.toggleExtensionForActiveTab() - }) - expect(resultDisabled.isConnected).toBe(false) - - // 5. Connect again - page should NOT be visible - directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - contexts = directBrowser.contexts() - pages = contexts[0].pages() - - foundPage = pages.find(p => p.url() === testUrl) - expect(foundPage).toBeUndefined() - - await directBrowser.close() - - // 6. Re-enable extension - const resultEnabled = await serviceWorker.evaluate(async () => { - return await globalThis.toggleExtensionForActiveTab() - }) - expect(resultEnabled.isConnected).toBe(true) - - // 7. Verify page is back - directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - await new Promise(r => setTimeout(r, 100)) - - contexts = directBrowser.contexts() - if (contexts[0].pages().length === 0) { - await new Promise(r => setTimeout(r, 100)) - } - pages = contexts[0].pages() - - foundPage = pages.find(p => p.url() === testUrl) - expect(foundPage).toBeDefined() - expect(foundPage?.url()).toBe(testUrl) - - const sum2 = await foundPage?.evaluate(() => 2 + 2) - expect(sum2).toBe(4) - - await directBrowser.close() - await page.close() - }, 120000) - - it('should handle new pages and toggling with persistent connection', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - - const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - await new Promise(r => setTimeout(r, 100)) - - // 1. Create a new page - const page = await browserContext.newPage() - const testUrl = 'https://example.com/persistent' - await page.goto(testUrl) - await page.bringToFront() - - // 2. Enable extension - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - // 3. Verify page appears (polling) - let foundPage - for (let i = 0; i < 50; i++) { - const pages = directBrowser.contexts()[0].pages() - foundPage = pages.find(p => p.url() === testUrl) - if (foundPage) break - await new Promise(r => setTimeout(r, 100)) - } - expect(foundPage).toBeDefined() - expect(foundPage?.url()).toBe(testUrl) - - const sum1 = await foundPage?.evaluate(() => 10 + 20) - expect(sum1).toBe(30) - - // 4. Disable extension - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - // 5. Verify page disappears (polling) - for (let i = 0; i < 50; i++) { - const pages = directBrowser.contexts()[0].pages() - foundPage = pages.find(p => p.url() === testUrl) - if (!foundPage) break - await new Promise(r => setTimeout(r, 100)) - } - expect(foundPage).toBeUndefined() - - // 6. Re-enable extension - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - // 7. Verify page reappears (polling) - for (let i = 0; i < 50; i++) { - const pages = directBrowser.contexts()[0].pages() - foundPage = pages.find(p => p.url() === testUrl) - if (foundPage) break - await new Promise(r => setTimeout(r, 100)) - } - expect(foundPage).toBeDefined() - expect(foundPage?.url()).toBe(testUrl) - - const sum2 = await foundPage?.evaluate(() => 30 + 40) - expect(sum2).toBe(70) - - await page.close() - await directBrowser.close() + // 2. Enable extension + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() }) - it('should maintain connection across reloads and navigation', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + // 3. Verify page appears (polling) + let foundPage + for (let i = 0; i < 50; i++) { + const pages = directBrowser.contexts()[0].pages() + foundPage = pages.find((p) => p.url() === testUrl) + if (foundPage) break + await new Promise((r) => setTimeout(r, 100)) + } + expect(foundPage).toBeDefined() + expect(foundPage?.url()).toBe(testUrl) - // 1. Setup page - const page = await browserContext.newPage() - const initialUrl = 'https://example.com/' - await page.goto(initialUrl) - await page.bringToFront() + const sum1 = await foundPage?.evaluate(() => 10 + 20) + expect(sum1).toBe(30) - // 2. Enable extension - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - // 3. Connect via CDP - const cdpUrl = getCdpUrl({ port: TEST_PORT }) - const directBrowser = await chromium.connectOverCDP(cdpUrl) - const connectedPage = directBrowser.contexts()[0].pages().find(p => p.url() === initialUrl) - expect(connectedPage).toBeDefined() - - expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2) - - // 4. Reload - await connectedPage?.reload() - await connectedPage?.waitForLoadState('domcontentloaded') - expect(await connectedPage?.title()).toBe('Example Domain') - - expect(await connectedPage?.evaluate(() => 2 + 2)).toBe(4) - - // 5. Navigate to new URL - const newUrl = 'https://example.org/' - await connectedPage?.goto(newUrl) - await connectedPage?.waitForLoadState('domcontentloaded') - - expect(connectedPage?.url()).toBe(newUrl) - expect(await connectedPage?.title()).toContain('Example Domain') - - expect(await connectedPage?.evaluate(() => 3 + 3)).toBe(6) - - await directBrowser.close() - await page.close() + // 4. Disable extension + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() }) - it('should support multiple concurrent tabs', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - await new Promise(resolve => setTimeout(resolve, 100)) + // 5. Verify page disappears (polling) + for (let i = 0; i < 50; i++) { + const pages = directBrowser.contexts()[0].pages() + foundPage = pages.find((p) => p.url() === testUrl) + if (!foundPage) break + await new Promise((r) => setTimeout(r, 100)) + } + expect(foundPage).toBeUndefined() - // Tab A - const pageA = await browserContext.newPage() - await pageA.goto('https://example.com/tab-a') - await pageA.bringToFront() - await new Promise(resolve => setTimeout(resolve, 100)) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + // 6. Re-enable extension + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - // Tab B - const pageB = await browserContext.newPage() - await pageB.goto('https://example.com/tab-b') - await pageB.bringToFront() - await new Promise(resolve => setTimeout(resolve, 100)) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + // 7. Verify page reappears (polling) + for (let i = 0; i < 50; i++) { + const pages = directBrowser.contexts()[0].pages() + foundPage = pages.find((p) => p.url() === testUrl) + if (foundPage) break + await new Promise((r) => setTimeout(r, 100)) + } + expect(foundPage).toBeDefined() + expect(foundPage?.url()).toBe(testUrl) - // Get target IDs for both - const targetIds = await serviceWorker.evaluate(async () => { - const state = globalThis.getExtensionState() - const chrome = globalThis.chrome - const tabs = await chrome.tabs.query({}) - const tabA = tabs.find((t: any) => t.url?.includes('tab-a')) - const tabB = tabs.find((t: any) => t.url?.includes('tab-b')) - return { - idA: state.tabs.get(tabA?.id ?? -1)?.targetId, - idB: state.tabs.get(tabB?.id ?? -1)?.targetId - } - }) + const sum2 = await foundPage?.evaluate(() => 30 + 40) + expect(sum2).toBe(70) - expect(targetIds).toMatchInlineSnapshot({ - idA: expect.any(String), - idB: expect.any(String) - }, ` + await page.close() + await directBrowser.close() + }) + + it('should maintain connection across reloads and navigation', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + // 1. Setup page + const page = await browserContext.newPage() + const initialUrl = 'https://example.com/' + await page.goto(initialUrl) + await page.bringToFront() + + // 2. Enable extension + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + // 3. Connect via CDP + const cdpUrl = getCdpUrl({ port: TEST_PORT }) + const directBrowser = await chromium.connectOverCDP(cdpUrl) + const connectedPage = directBrowser + .contexts()[0] + .pages() + .find((p) => p.url() === initialUrl) + expect(connectedPage).toBeDefined() + + expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2) + + // 4. Reload + await connectedPage?.reload() + await connectedPage?.waitForLoadState('domcontentloaded') + expect(await connectedPage?.title()).toBe('Example Domain') + + expect(await connectedPage?.evaluate(() => 2 + 2)).toBe(4) + + // 5. Navigate to new URL + const newUrl = 'https://example.org/' + await connectedPage?.goto(newUrl) + await connectedPage?.waitForLoadState('domcontentloaded') + + expect(connectedPage?.url()).toBe(newUrl) + expect(await connectedPage?.title()).toContain('Example Domain') + + expect(await connectedPage?.evaluate(() => 3 + 3)).toBe(6) + + await directBrowser.close() + await page.close() + }) + + it('should support multiple concurrent tabs', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Tab A + const pageA = await browserContext.newPage() + await pageA.goto('https://example.com/tab-a') + await pageA.bringToFront() + await new Promise((resolve) => setTimeout(resolve, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + // Tab B + const pageB = await browserContext.newPage() + await pageB.goto('https://example.com/tab-b') + await pageB.bringToFront() + await new Promise((resolve) => setTimeout(resolve, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + // Get target IDs for both + const targetIds = await serviceWorker.evaluate(async () => { + const state = globalThis.getExtensionState() + const chrome = globalThis.chrome + const tabs = await chrome.tabs.query({}) + const tabA = tabs.find((t: any) => t.url?.includes('tab-a')) + const tabB = tabs.find((t: any) => t.url?.includes('tab-b')) + return { + idA: state.tabs.get(tabA?.id ?? -1)?.targetId, + idB: state.tabs.get(tabB?.id ?? -1)?.targetId, + } + }) + + expect(targetIds).toMatchInlineSnapshot( + { + idA: expect.any(String), + idB: expect.any(String), + }, + ` { "idA": Any, "idB": Any, } - `) - expect(targetIds.idA).not.toBe(targetIds.idB) + `, + ) + expect(targetIds.idA).not.toBe(targetIds.idB) - // Verify independent connections - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + // Verify independent connections + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const pages = browser.contexts()[0].pages() + const pages = browser.contexts()[0].pages() - const results = await Promise.all(pages.map(async (p) => ({ - url: p.url(), - title: await p.title() - }))) + const results = await Promise.all( + pages.map(async (p) => ({ + url: p.url(), + title: await p.title(), + })), + ) - expect(results).toMatchInlineSnapshot(` + expect(results).toMatchInlineSnapshot(` [ { "title": "", @@ -291,253 +299,256 @@ describe('Extension Connection Tests', () => { ] `) - // Verify execution on both pages - const pageA_CDP = pages.find(p => p.url().includes('tab-a')) - const pageB_CDP = pages.find(p => p.url().includes('tab-b')) + // Verify execution on both pages + const pageA_CDP = pages.find((p) => p.url().includes('tab-a')) + const pageB_CDP = pages.find((p) => p.url().includes('tab-b')) - expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20) - expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40) + expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20) + expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40) - await browser.close() - await pageA.close() - await pageB.close() + await browser.close() + await pageA.close() + await pageB.close() + }) + + it('should warn and switch page when the active page closes', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const pageA = await browserContext.newPage() + await pageA.goto('https://example.com/close-warning-a') + await pageA.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() }) - it('should warn and switch page when the active page closes', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + const pageB = await browserContext.newPage() + await pageB.goto('https://example.com/close-warning-b') + await pageB.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - const pageA = await browserContext.newPage() - await pageA.goto('https://example.com/close-warning-a') - await pageA.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - const pageB = await browserContext.newPage() - await pageB.goto('https://example.com/close-warning-b') - await pageB.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - const closeResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + const closeResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` state.page = page; const closedUrl = state.page.url(); await state.page.close(); return { closedUrl, remainingPages: context.pages().length }; `, - }, - }) - - const closeOutput = (closeResult as any).content[0].text - expect(closeOutput).toContain('[WARNING] The current page in state.page was closed') - expect(closeOutput).toContain('Switched active page to index') - expect((closeResult as any).isError).not.toBe(true) - - const nextResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` - return { pageUrl: page.url(), pagesCount: context.pages().length }; - `, - }, - }) - - const nextOutput = (nextResult as any).content[0].text - expect(nextOutput).toContain('pagesCount') - expect(nextOutput).not.toContain('No Playwright pages are available') - expect(nextOutput).not.toContain('[WARNING] The current page was closed') - expect((nextResult as any).isError).not.toBe(true) - - if (!pageA.isClosed()) { - await pageA.close() - } - if (!pageB.isClosed()) { - await pageB.close() - } + }, }) - it('should switch page without warning when closed page is not stored in state', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + const closeOutput = (closeResult as any).content[0].text + expect(closeOutput).toContain('[WARNING] The current page in state.page was closed') + expect(closeOutput).toContain('Switched active page to index') + expect((closeResult as any).isError).not.toBe(true) - const pageA = await browserContext.newPage() - await pageA.goto('https://example.com/close-no-state-warning-a') - await pageA.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + const nextResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` + return { pageUrl: page.url(), pagesCount: context.pages().length }; + `, + }, + }) - const pageB = await browserContext.newPage() - await pageB.goto('https://example.com/close-no-state-warning-b') - await pageB.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + const nextOutput = (nextResult as any).content[0].text + expect(nextOutput).toContain('pagesCount') + expect(nextOutput).not.toContain('No Playwright pages are available') + expect(nextOutput).not.toContain('[WARNING] The current page was closed') + expect((nextResult as any).isError).not.toBe(true) - const closeResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + if (!pageA.isClosed()) { + await pageA.close() + } + if (!pageB.isClosed()) { + await pageB.close() + } + }) + + it('should switch page without warning when closed page is not stored in state', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const pageA = await browserContext.newPage() + await pageA.goto('https://example.com/close-no-state-warning-a') + await pageA.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + const pageB = await browserContext.newPage() + await pageB.goto('https://example.com/close-no-state-warning-b') + await pageB.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + const closeResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const closedUrl = page.url(); await page.close(); return { closedUrl, remainingPages: context.pages().length }; `, - }, - }) - - const closeOutput = (closeResult as any).content[0].text - expect(closeOutput).not.toContain('[WARNING] The current page in state.page was closed') - expect(closeOutput).not.toContain('Switched active page to index') - expect((closeResult as any).isError).not.toBe(true) - - const nextResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` - return { pageUrl: page.url(), pagesCount: context.pages().length }; - `, - }, - }) - - const nextOutput = (nextResult as any).content[0].text - expect(nextOutput).toContain('pagesCount') - expect((nextResult as any).isError).not.toBe(true) - - if (!pageA.isClosed()) { - await pageA.close() - } - if (!pageB.isClosed()) { - await pageB.close() - } + }, }) - it('should show correct url when enabling extension after navigation', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + const closeOutput = (closeResult as any).content[0].text + expect(closeOutput).not.toContain('[WARNING] The current page in state.page was closed') + expect(closeOutput).not.toContain('Switched active page to index') + expect((closeResult as any).isError).not.toBe(true) - const page = await browserContext.newPage() - const targetUrl = 'https://example.com/late-enable' - await page.goto(targetUrl) - await page.bringToFront() + const nextResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` + return { pageUrl: page.url(), pagesCount: context.pages().length }; + `, + }, + }) - await page.waitForLoadState('domcontentloaded') + const nextOutput = (nextResult as any).content[0].text + expect(nextOutput).toContain('pagesCount') + expect((nextResult as any).isError).not.toBe(true) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + if (!pageA.isClosed()) { + await pageA.close() + } + if (!pageB.isClosed()) { + await pageB.close() + } + }) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - await new Promise(r => setTimeout(r, 100)) + it('should show correct url when enabling extension after navigation', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const cdpPage = browser.contexts()[0].pages().find(p => p.url() === targetUrl) + const page = await browserContext.newPage() + const targetUrl = 'https://example.com/late-enable' + await page.goto(targetUrl) + await page.bringToFront() - expect(cdpPage).toBeDefined() - expect(cdpPage?.url()).toBe(targetUrl) + await page.waitForLoadState('domcontentloaded') - await browser.close() - await page.close() - }, 60000) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - it('should be able to reconnect after disconnecting everything', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + await new Promise((r) => setTimeout(r, 100)) - const pages = await browserContext.pages() - expect(pages.length).toBeGreaterThan(0) - const page = pages[0] + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url() === targetUrl) - await page.goto('https://example.com/disconnect-test') - await page.waitForLoadState('domcontentloaded') - await page.bringToFront() + expect(cdpPage).toBeDefined() + expect(cdpPage?.url()).toBe(targetUrl) - // Enable extension on this page - const initialEnable = await serviceWorker.evaluate(async () => { - return await globalThis.toggleExtensionForActiveTab() - }) - console.log('Initial enable result:', initialEnable) - expect(initialEnable.isConnected).toBe(true) + await browser.close() + await page.close() + }, 60000) - await new Promise(resolve => setTimeout(resolve, 100)) + it('should be able to reconnect after disconnecting everything', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - // Verify MCP can see the page - const beforeDisconnect = await client.callTool({ - name: 'execute', - arguments: { - code: js` + const pages = await browserContext.pages() + expect(pages.length).toBeGreaterThan(0) + const page = pages[0] + + await page.goto('https://example.com/disconnect-test') + await page.waitForLoadState('domcontentloaded') + await page.bringToFront() + + // Enable extension on this page + const initialEnable = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() + }) + console.log('Initial enable result:', initialEnable) + expect(initialEnable.isConnected).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Verify MCP can see the page + const beforeDisconnect = await client.callTool({ + name: 'execute', + arguments: { + code: js` const pages = context.pages(); console.log('Pages before disconnect:', pages.length); const testPage = pages.find(p => p.url().includes('disconnect-test')); console.log('Found test page:', !!testPage); return { pagesCount: pages.length, foundTestPage: !!testPage }; `, - }, - }) + }, + }) - const beforeOutput = (beforeDisconnect as any).content[0].text - expect(beforeOutput).toContain('foundTestPage') - console.log('Before disconnect:', beforeOutput) + const beforeOutput = (beforeDisconnect as any).content[0].text + expect(beforeOutput).toContain('foundTestPage') + console.log('Before disconnect:', beforeOutput) - // 2. Disconnect everything - console.log('Calling disconnectEverything...') - await serviceWorker.evaluate(async () => { - await globalThis.disconnectEverything() - }) + // 2. Disconnect everything + console.log('Calling disconnectEverything...') + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) - // 3. Verify MCP cannot execute code anymore (no pages available) - const afterDisconnect = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // 3. Verify MCP cannot execute code anymore (no pages available) + const afterDisconnect = await client.callTool({ + name: 'execute', + arguments: { + code: js` const pages = context.pages(); console.log('Pages after disconnect:', pages.length); return { pagesCount: pages.length }; `, - }, - }) + }, + }) - const afterDisconnectOutput = (afterDisconnect as any).content[0].text - console.log('After disconnect:', afterDisconnectOutput) - expect((afterDisconnect as any).isError).toBe(true) - expect(afterDisconnectOutput).toContain('No Playwright pages are available') + const afterDisconnectOutput = (afterDisconnect as any).content[0].text + console.log('After disconnect:', afterDisconnectOutput) + expect((afterDisconnect as any).isError).toBe(true) + expect(afterDisconnectOutput).toContain('No Playwright pages are available') - // 4. Re-enable extension on the same page - console.log('Re-enabling extension...') - await page.bringToFront() - const reconnectResult = await serviceWorker.evaluate(async () => { - console.log('About to call toggleExtensionForActiveTab') - const result = await globalThis.toggleExtensionForActiveTab() - console.log('toggleExtensionForActiveTab result:', result) - return result - }) + // 4. Re-enable extension on the same page + console.log('Re-enabling extension...') + await page.bringToFront() + const reconnectResult = await serviceWorker.evaluate(async () => { + console.log('About to call toggleExtensionForActiveTab') + const result = await globalThis.toggleExtensionForActiveTab() + console.log('toggleExtensionForActiveTab result:', result) + return result + }) - console.log('Reconnect result:', reconnectResult) - expect(reconnectResult.isConnected).toBe(true) + console.log('Reconnect result:', reconnectResult) + expect(reconnectResult.isConnected).toBe(true) - console.log('Waiting for reconnection to stabilize...') - await new Promise(resolve => setTimeout(resolve, 100)) + console.log('Waiting for reconnection to stabilize...') + await new Promise((resolve) => setTimeout(resolve, 100)) - // 5. Reset the MCP client's playwright connection - console.log('Resetting MCP playwright connection...') - const resetResult = await client.callTool({ - name: 'reset', - arguments: {}, - }) - console.log('Reset result:', (resetResult as any).content[0].text) - expect((resetResult as any).content[0].text).toContain('Connection reset successfully') + // 5. Reset the MCP client's playwright connection + console.log('Resetting MCP playwright connection...') + const resetResult = await client.callTool({ + name: 'reset', + arguments: {}, + }) + console.log('Reset result:', (resetResult as any).content[0].text) + expect((resetResult as any).content[0].text).toContain('Connection reset successfully') - // 6. Verify MCP can see the page again - console.log('Attempting to access page via MCP...') - const afterReconnect = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // 6. Verify MCP can see the page again + console.log('Attempting to access page via MCP...') + const afterReconnect = await client.callTool({ + name: 'execute', + arguments: { + code: js` console.log('Checking pages after reconnect...'); const pages = context.pages(); console.log('Pages after reconnect:', pages.length); @@ -557,38 +568,38 @@ describe('Extension Connection Tests', () => { return { pagesCount: pages.length, foundTestPage: false }; `, - }, - }) - - const afterReconnectOutput = (afterReconnect as any).content[0].text - console.log('After reconnect:', afterReconnectOutput) - expect(afterReconnectOutput).toContain('foundTestPage') - expect(afterReconnectOutput).toContain('disconnect-test') - - // Clean up - await page.goto('about:blank') + }, }) - it('should auto-reconnect MCP after extension WebSocket reconnects', async () => { - const serviceWorker = await getExtensionServiceWorker(testCtx!.browserContext) + const afterReconnectOutput = (afterReconnect as any).content[0].text + console.log('After reconnect:', afterReconnectOutput) + expect(afterReconnectOutput).toContain('foundTestPage') + expect(afterReconnectOutput).toContain('disconnect-test') - // 1. Create a test page and enable extension - const page = await testCtx!.browserContext.newPage() - await page.goto('https://example.com/auto-reconnect-test') - await page.waitForLoadState('domcontentloaded') - await page.bringToFront() + // Clean up + await page.goto('about:blank') + }) - const initialEnable = await serviceWorker.evaluate(async () => { - return await globalThis.toggleExtensionForActiveTab() - }) - expect(initialEnable.isConnected).toBe(true) - await new Promise(resolve => setTimeout(resolve, 100)) + it('should auto-reconnect MCP after extension WebSocket reconnects', async () => { + const serviceWorker = await getExtensionServiceWorker(testCtx!.browserContext) - // 2. Verify MCP can execute commands - const beforeResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // 1. Create a test page and enable extension + const page = await testCtx!.browserContext.newPage() + await page.goto('https://example.com/auto-reconnect-test') + await page.waitForLoadState('domcontentloaded') + await page.bringToFront() + + const initialEnable = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() + }) + expect(initialEnable.isConnected).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 2. Verify MCP can execute commands + const beforeResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` let testPage; for (let i = 0; i < 20; i++) { const pages = context.pages(); @@ -599,32 +610,32 @@ describe('Extension Connection Tests', () => { const pages = context.pages(); return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() }; `, - }, - }) - const beforeOutput = (beforeResult as any).content[0].text - expect(beforeOutput).toContain('foundTestPage') - expect(beforeOutput).toContain('true') - expect(beforeOutput).toContain('auto-reconnect-test') + }, + }) + const beforeOutput = (beforeResult as any).content[0].text + expect(beforeOutput).toContain('foundTestPage') + expect(beforeOutput).toContain('true') + expect(beforeOutput).toContain('auto-reconnect-test') - // 3. Simulate extension WebSocket reconnection - await serviceWorker.evaluate(async () => { - await globalThis.disconnectEverything() - }) - await new Promise(resolve => setTimeout(resolve, 100)) + // 3. Simulate extension WebSocket reconnection + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) + await new Promise((resolve) => setTimeout(resolve, 100)) - // Re-enable extension - await page.bringToFront() - const reconnectResult = await serviceWorker.evaluate(async () => { - return await globalThis.toggleExtensionForActiveTab() - }) - expect(reconnectResult.isConnected).toBe(true) - await new Promise(resolve => setTimeout(resolve, 100)) + // Re-enable extension + await page.bringToFront() + const reconnectResult = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() + }) + expect(reconnectResult.isConnected).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 100)) - // 4. Execute command WITHOUT calling resetPlaywright() - const afterResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // 4. Execute command WITHOUT calling resetPlaywright() + const afterResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` let testPage; for (let i = 0; i < 20; i++) { const pages = context.pages(); @@ -635,112 +646,112 @@ describe('Extension Connection Tests', () => { const pages = context.pages(); return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() }; `, - }, - }) - - const afterOutput = (afterResult as any).content[0].text - expect(afterOutput).toContain('foundTestPage') - expect(afterOutput).toContain('true') - expect(afterOutput).toContain('auto-reconnect-test') - expect(afterOutput).not.toContain('Extension not connected') - expect((afterResult as any).isError).not.toBe(true) - - // Clean up - await page.goto('about:blank') + }, }) - it('should maintain correct page.url() with service worker pages', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + const afterOutput = (afterResult as any).content[0].text + expect(afterOutput).toContain('foundTestPage') + expect(afterOutput).toContain('true') + expect(afterOutput).toContain('auto-reconnect-test') + expect(afterOutput).not.toContain('Extension not connected') + expect((afterResult as any).isError).not.toBe(true) - const page = await browserContext.newPage() - const targetUrl = 'https://example.com/sw-test' - await page.goto(targetUrl) - await page.bringToFront() + // Clean up + await page.goto('about:blank') + }) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + it('should maintain correct page.url() with service worker pages', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - await new Promise(r => setTimeout(r, 100)) + const page = await browserContext.newPage() + const targetUrl = 'https://example.com/sw-test' + await page.goto(targetUrl) + await page.bringToFront() - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPages = browser.contexts()[0].pages() - const testPage = cdpPages.find(p => p.url().includes('sw-test')) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - expect(testPage).toBeDefined() - expect(testPage?.url()).toContain('sw-test') - expect(testPage?.url()).not.toContain('sw.js') + await new Promise((r) => setTimeout(r, 100)) - await browser.close() - await page.close() - }, 30000) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPages = browser.contexts()[0].pages() + const testPage = cdpPages.find((p) => p.url().includes('sw-test')) - it('should maintain correct page.url() after repeated connections', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + expect(testPage).toBeDefined() + expect(testPage?.url()).toContain('sw-test') + expect(testPage?.url()).not.toContain('sw.js') - const page = await browserContext.newPage() - const targetUrl = 'https://example.com/repeated-test' - await page.goto(targetUrl) - await page.bringToFront() + await browser.close() + await page.close() + }, 30000) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + it('should maintain correct page.url() after repeated connections', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - for (let i = 0; i < 5; i++) { - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPages = browser.contexts()[0].pages() - const testPage = cdpPages.find(p => p.url().includes('repeated-test')) + const page = await browserContext.newPage() + const targetUrl = 'https://example.com/repeated-test' + await page.goto(targetUrl) + await page.bringToFront() - expect(testPage).toBeDefined() - expect(testPage?.url()).toBe(targetUrl) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - await browser.close() - await new Promise(r => setTimeout(r, 100)) - } + for (let i = 0; i < 5; i++) { + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPages = browser.contexts()[0].pages() + const testPage = cdpPages.find((p) => p.url().includes('repeated-test')) - await page.close() - }, 30000) + expect(testPage).toBeDefined() + expect(testPage?.url()).toBe(targetUrl) - it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + await browser.close() + await new Promise((r) => setTimeout(r, 100)) + } - const page = await browserContext.newPage() - const targetUrl = 'https://example.com/concurrent-test' - await page.goto(targetUrl) - await page.bringToFront() + await page.close() + }, 30000) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - await new Promise(r => setTimeout(r, 400)) + const page = await browserContext.newPage() + const targetUrl = 'https://example.com/concurrent-test' + await page.goto(targetUrl) + await page.bringToFront() - const [mcpResult, cdpBrowser] = await Promise.all([ - client.callTool({ - name: 'execute', - arguments: { - code: js` + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + await new Promise((r) => setTimeout(r, 400)) + + const [mcpResult, cdpBrowser] = await Promise.all([ + client.callTool({ + name: 'execute', + arguments: { + code: js` const pages = context.pages(); const testPage = pages.find(p => p.url().includes('concurrent-test')); return { url: testPage?.url(), found: !!testPage }; `, - }, - }), - chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - ]) + }, + }), + chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), + ]) - const mcpOutput = (mcpResult as any).content[0].text - expect(mcpOutput).toContain(targetUrl) + const mcpOutput = (mcpResult as any).content[0].text + expect(mcpOutput).toContain(targetUrl) - const cdpPages = cdpBrowser.contexts()[0].pages() - const cdpPage = cdpPages.find(p => p.url().includes('concurrent-test')) - expect(cdpPage?.url()).toBe(targetUrl) + const cdpPages = cdpBrowser.contexts()[0].pages() + const cdpPage = cdpPages.find((p) => p.url().includes('concurrent-test')) + expect(cdpPage?.url()).toBe(targetUrl) - await cdpBrowser.close() - await page.close() - }, 30000) + await cdpBrowser.close() + await page.close() + }, 30000) }) diff --git a/playwriter/src/ghost-browser.ts b/playwriter/src/ghost-browser.ts index 2ef5192..5a1579e 100644 --- a/playwriter/src/ghost-browser.ts +++ b/playwriter/src/ghost-browser.ts @@ -40,9 +40,7 @@ export type GhostBrowserCommandParams = { args: unknown[] } -export type GhostBrowserCommandResult = - | { success: true; result: unknown } - | { success: false; error: string } +export type GhostBrowserCommandResult = { success: true; result: unknown } | { success: false; error: string } /** * Function signature for sending ghost-browser commands. @@ -52,7 +50,7 @@ export type GhostBrowserCommandResult = export type SendGhostBrowserCommand = ( namespace: GhostBrowserNamespace, method: string, - args: unknown[] + args: unknown[], ) => Promise // ============================================================================= @@ -66,7 +64,7 @@ export type SendGhostBrowserCommand = ( function createGhostBrowserProxy( namespace: GhostBrowserNamespace, constants: Record, - sendCommand: SendGhostBrowserCommand + sendCommand: SendGhostBrowserCommand, ) { return new Proxy(constants, { get(target, prop: string) { @@ -108,7 +106,7 @@ export function createGhostBrowserChrome(sendCommand: SendGhostBrowserCommand) { */ export async function handleGhostBrowserCommand( params: GhostBrowserCommandParams, - chromeApi: typeof chrome + chromeApi: typeof chrome, ): Promise { const { namespace, method, args } = params diff --git a/playwriter/src/htmlrewrite.test.ts b/playwriter/src/htmlrewrite.test.ts index 0e3e9aa..7b4410a 100644 --- a/playwriter/src/htmlrewrite.test.ts +++ b/playwriter/src/htmlrewrite.test.ts @@ -7977,8 +7977,12 @@ test('processes x.com.html with size savings', async () => { console.log(`\n📊 x.com.html processing stats:`) console.log(` Original: ${originalSize.toLocaleString()} chars (${originalTokens.toLocaleString()} tokens)`) - console.log(` Without styles: ${processedSize.toLocaleString()} chars (${processedTokens.toLocaleString()} tokens) - ${savingsPercent}% savings`) - console.log(` With styles: ${withStylesSize.toLocaleString()} chars (${withStylesTokens.toLocaleString()} tokens) - ${withStylesPercent}% savings`) + console.log( + ` Without styles: ${processedSize.toLocaleString()} chars (${processedTokens.toLocaleString()} tokens) - ${savingsPercent}% savings`, + ) + console.log( + ` With styles: ${withStylesSize.toLocaleString()} chars (${withStylesTokens.toLocaleString()} tokens) - ${withStylesPercent}% savings`, + ) await expect(result).toMatchFileSnapshot('./__snapshots__/x.com.processed.html') await expect(resultWithStyles).toMatchFileSnapshot('./__snapshots__/x.com.processed.withStyles.html') diff --git a/playwriter/src/htmlrewrite.ts b/playwriter/src/htmlrewrite.ts index 8392c2f..9eddcd5 100644 --- a/playwriter/src/htmlrewrite.ts +++ b/playwriter/src/htmlrewrite.ts @@ -2,427 +2,389 @@ import posthtml from 'posthtml' import beautify from 'posthtml-beautify' export interface FormatHtmlOptions { - html: string - keepStyles?: boolean - maxAttrLen?: number - maxContentLen?: number + html: string + keepStyles?: boolean + maxAttrLen?: number + maxContentLen?: number } export async function formatHtmlForPrompt({ - html, - keepStyles = false, - maxAttrLen = 200, - maxContentLen = 500, + html, + keepStyles = false, + maxAttrLen = 200, + maxContentLen = 500, }: FormatHtmlOptions) { - const tagsToRemove = [ - 'hint', - 'style', - 'link', - 'script', - 'meta', - 'noscript', - 'svg', - 'head', - ] + const tagsToRemove = ['hint', 'style', 'link', 'script', 'meta', 'noscript', 'svg', 'head'] - const attributesToKeep = [ - // Standard descriptive attributes - 'label', - 'title', - 'alt', - 'href', - 'name', - 'value', - 'checked', - 'placeholder', - 'type', - 'role', - 'target', - // Descriptive aria attributes (text content) - 'aria-label', - 'aria-placeholder', - 'aria-valuetext', - 'aria-roledescription', - // Useful aria state attributes - 'aria-hidden', - 'aria-expanded', - 'aria-checked', - 'aria-selected', - 'aria-disabled', - 'aria-pressed', - 'aria-required', - 'aria-current', - // Test IDs (data-testid, data-test, data-cy, data-qa are covered by data-* prefix) - 'testid', - 'test-id', - 'tid', - 'qa', - 'qa-id', - 'e2e', - 'e2e-id', - 'automation-id', - 'automationid', - 'selenium', - 'pw', - 'vimium-label', - // Conditionally added: 'style', 'class' - ] + const attributesToKeep = [ + // Standard descriptive attributes + 'label', + 'title', + 'alt', + 'href', + 'name', + 'value', + 'checked', + 'placeholder', + 'type', + 'role', + 'target', + // Descriptive aria attributes (text content) + 'aria-label', + 'aria-placeholder', + 'aria-valuetext', + 'aria-roledescription', + // Useful aria state attributes + 'aria-hidden', + 'aria-expanded', + 'aria-checked', + 'aria-selected', + 'aria-disabled', + 'aria-pressed', + 'aria-required', + 'aria-current', + // Test IDs (data-testid, data-test, data-cy, data-qa are covered by data-* prefix) + 'testid', + 'test-id', + 'tid', + 'qa', + 'qa-id', + 'e2e', + 'e2e-id', + 'automation-id', + 'automationid', + 'selenium', + 'pw', + 'vimium-label', + // Conditionally added: 'style', 'class' + ] - if (keepStyles) { - attributesToKeep.push('style', 'class') - } + if (keepStyles) { + attributesToKeep.push('style', 'class') + } - const truncate = (str: string, maxLen: number): string => { - if (str.length <= maxLen) return str - const remaining = str.length - maxLen - return str.slice(0, maxLen) + `...${remaining} more characters` - } + const truncate = (str: string, maxLen: number): string => { + if (str.length <= maxLen) return str + const remaining = str.length - maxLen + return str.slice(0, maxLen) + `...${remaining} more characters` + } - // Create a custom plugin to remove tags and filter attributes - const removeTagsAndAttrsPlugin = () => { - return (tree) => { - // Remove comments at root level - tree = tree.filter((item) => { - if (typeof item === 'string') { - const trimmed = item.trim() - return !(trimmed.startsWith('')) - } - 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('')) - } - return true - }) - } - - return node - } - - // Process all root nodes - return tree.map(processNode).filter(item => item !== null) + // Create a custom plugin to remove tags and filter attributes + const removeTagsAndAttrsPlugin = () => { + return (tree) => { + // Remove comments at root level + tree = tree.filter((item) => { + if (typeof item === 'string') { + const trimmed = item.trim() + return !(trimmed.startsWith('')) } - } + return true + }) - // Plugin to remove aria-hidden="true" subtrees entirely - // These are hidden from assistive tech and usually decorative - const removeAriaHiddenPlugin = () => { - return (tree) => { - const processNode = (node) => { - if (typeof node === 'string') return node - if (!node.tag) return node - - // Remove if aria-hidden="true" - if (node.attrs?.['aria-hidden'] === 'true') { - return null - } - - // Process children recursively - if (node.content && Array.isArray(node.content)) { - node.content = node.content - .map(processNode) - .filter((item) => item !== null) - } - - return node - } - - return tree.map(processNode).filter((item) => item !== null) - } - } - - // Plugin to remove images with empty alt text (purely decorative) - // Runs before decorative subtree pruning so containers become empty - const removeEmptyAltImagesPlugin = () => { - return (tree) => { - const processNode = (node) => { - if (typeof node === 'string') return node - if (!node.tag) return node - - // Remove img with empty or missing alt - if (node.tag.toLowerCase() === 'img') { - const alt = node.attrs?.alt - if (alt === '' || alt === undefined) { - return null - } - } - - // Process children recursively - if (node.content && Array.isArray(node.content)) { - node.content = node.content - .map(processNode) - .filter((item) => item !== null) - } - - return node - } - - return tree.map(processNode).filter((item) => item !== null) - } - } - - // Plugin to remove decorative subtrees that have no useful content for agents - // A subtree is decorative if it has: - // - No text content (leaf text nodes) - // - No actionable elements with meaningful attributes - const removeDecorativeSubtreesPlugin = () => { - const actionableTags = ['button', 'a', 'input', 'select', 'textarea'] - const meaningfulAttrs = [ - 'aria-label', - 'title', - 'alt', - 'value', - 'placeholder', - 'href', - 'name', - ] - - // Form elements are always actionable, keep unconditionally - const formTags = ['input', 'select', 'textarea'] - - // Check if a subtree has any useful content - const hasUsefulContent = (node): boolean => { - if (typeof node === 'string') { - return node.trim().length > 0 - } - if (!node.tag) return false - - // Form elements are always useful for agents to interact with - if (formTags.includes(node.tag.toLowerCase())) { - return true - } - - // Images with non-empty alt text are useful (descriptive content) - if (node.tag.toLowerCase() === 'img') { - const alt = node.attrs?.alt - if (typeof alt === 'string' && alt.trim().length > 0) { - return true - } - } - - // Check if this is an actionable element with meaningful attributes - if (actionableTags.includes(node.tag.toLowerCase())) { - if (node.attrs) { - for (const attr of meaningfulAttrs) { - const value = node.attrs[attr] - if (typeof value === 'string' && value.trim().length > 0) { - return true - } - } - } - } - - // Check children recursively - if (node.content && Array.isArray(node.content)) { - for (const child of node.content) { - if (hasUsefulContent(child)) { - return true - } - } - } - - return false + // Process each node recursively + const processNode = (node) => { + if (typeof node === 'string') { + // Truncate text content + const trimmed = node.trim() + if (trimmed.length === 0) return node + return truncate(node, maxContentLen) } - return (tree) => { - const processNode = (node) => { - if (typeof node === 'string') return node - if (!node.tag) return node - - // First process children - if (node.content && Array.isArray(node.content)) { - node.content = node.content - .map(processNode) - .filter((item) => item !== null) - } - - // After processing children, check if this subtree is now decorative - // Skip root-level semantic elements (body, main, etc.) - const semanticTags = [ - 'html', - 'body', - 'main', - 'header', - 'footer', - 'nav', - 'section', - 'article', - 'aside', - ] - if (semanticTags.includes(node.tag.toLowerCase())) { - return node - } - - // If no useful content in this subtree, remove it - if (!hasUsefulContent(node)) { - return null - } - - return node - } - - return tree.map(processNode).filter((item) => item !== null) + // Remove unwanted tags + if (node.tag && tagsToRemove.includes(node.tag.toLowerCase())) { + return null } + + // Filter attributes + if (node.attrs) { + const newAttrs: typeof node.attrs = {} + for (const [attr, value] of Object.entries(node.attrs)) { + const shouldKeep = attr.startsWith('data-') || attributesToKeep.includes(attr) + + if (shouldKeep) { + // Truncate attribute values + newAttrs[attr] = typeof value === 'string' ? truncate(value, maxAttrLen) : value + } + } + node.attrs = newAttrs + } + + // Process content recursively + if (node.content && Array.isArray(node.content)) { + node.content = node.content.map(processNode).filter((item) => { + if (item === null) return false + if (typeof item === 'string') { + const trimmed = item.trim() + return !(trimmed.startsWith('')) + } + return true + }) + } + + return node + } + + // Process all root nodes + return tree.map(processNode).filter((item) => item !== null) + } + } + + // Plugin to remove aria-hidden="true" subtrees entirely + // These are hidden from assistive tech and usually decorative + const removeAriaHiddenPlugin = () => { + return (tree) => { + const processNode = (node) => { + if (typeof node === 'string') return node + if (!node.tag) return node + + // Remove if aria-hidden="true" + if (node.attrs?.['aria-hidden'] === 'true') { + return null + } + + // Process children recursively + if (node.content && Array.isArray(node.content)) { + node.content = node.content.map(processNode).filter((item) => item !== null) + } + + return node + } + + return tree.map(processNode).filter((item) => item !== null) + } + } + + // Plugin to remove images with empty alt text (purely decorative) + // Runs before decorative subtree pruning so containers become empty + const removeEmptyAltImagesPlugin = () => { + return (tree) => { + const processNode = (node) => { + if (typeof node === 'string') return node + if (!node.tag) return node + + // Remove img with empty or missing alt + if (node.tag.toLowerCase() === 'img') { + const alt = node.attrs?.alt + if (alt === '' || alt === undefined) { + return null + } + } + + // Process children recursively + if (node.content && Array.isArray(node.content)) { + node.content = node.content.map(processNode).filter((item) => item !== null) + } + + return node + } + + return tree.map(processNode).filter((item) => item !== null) + } + } + + // Plugin to remove decorative subtrees that have no useful content for agents + // A subtree is decorative if it has: + // - No text content (leaf text nodes) + // - No actionable elements with meaningful attributes + const removeDecorativeSubtreesPlugin = () => { + const actionableTags = ['button', 'a', 'input', 'select', 'textarea'] + const meaningfulAttrs = ['aria-label', 'title', 'alt', 'value', 'placeholder', 'href', 'name'] + + // Form elements are always actionable, keep unconditionally + const formTags = ['input', 'select', 'textarea'] + + // Check if a subtree has any useful content + const hasUsefulContent = (node): boolean => { + if (typeof node === 'string') { + return node.trim().length > 0 + } + if (!node.tag) return false + + // Form elements are always useful for agents to interact with + if (formTags.includes(node.tag.toLowerCase())) { + return true + } + + // Images with non-empty alt text are useful (descriptive content) + if (node.tag.toLowerCase() === 'img') { + const alt = node.attrs?.alt + if (typeof alt === 'string' && alt.trim().length > 0) { + return true + } + } + + // Check if this is an actionable element with meaningful attributes + if (actionableTags.includes(node.tag.toLowerCase())) { + if (node.attrs) { + for (const attr of meaningfulAttrs) { + const value = node.attrs[attr] + if (typeof value === 'string' && value.trim().length > 0) { + return true + } + } + } + } + + // Check children recursively + if (node.content && Array.isArray(node.content)) { + for (const child of node.content) { + if (hasUsefulContent(child)) { + return true + } + } + } + + return false } - // Plugin to unwrap unnecessary nested wrapper elements - // e.g.,

text

->

text

- const unwrapNestedWrappersPlugin = () => { - return (tree) => { - const isWhitespaceOnly = (node) => { - return typeof node === 'string' && node.trim().length === 0 - } + return (tree) => { + const processNode = (node) => { + if (typeof node === 'string') return node + if (!node.tag) return node - const hasNoAttrs = (node) => { - return !node.attrs || Object.keys(node.attrs).length === 0 - } - - const unwrapNode = (node) => { - if (typeof node === 'string') return node - if (!node.tag) return node - - // First, recursively process children - if (node.content && Array.isArray(node.content)) { - node.content = node.content.map(unwrapNode) - } - - // Check if this node is an unnecessary wrapper: - // - has no attributes - // - has exactly one non-whitespace child that is an element - if (hasNoAttrs(node) && node.content && Array.isArray(node.content)) { - const nonWhitespaceChildren = node.content.filter(c => !isWhitespaceOnly(c)) - - if (nonWhitespaceChildren.length === 1) { - const onlyChild = nonWhitespaceChildren[0] - // If the only child is also an element (not text), unwrap - if (typeof onlyChild !== 'string' && onlyChild.tag) { - // Replace this node with its child - return onlyChild - } - } - } - - return node - } - - // Apply multiple passes until stable (handles deeply nested wrappers) - let result = tree.map(unwrapNode) - let prevJson = '' - let currJson = JSON.stringify(result) - while (prevJson !== currJson) { - prevJson = currJson - result = result.map(unwrapNode) - currJson = JSON.stringify(result) - } - - return result + // First process children + if (node.content && Array.isArray(node.content)) { + node.content = node.content.map(processNode).filter((item) => item !== null) } - } - // Plugin to remove empty elements (no attrs, no content) - // Runs repeatedly until no more empty elements exist - const removeEmptyElementsPlugin = () => { - return (tree) => { - const isEmptyElement = (node) => { - if (typeof node === 'string') return false - if (!node.tag) return false - const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0 - const hasContent = node.content && node.content.some(c => - typeof c === 'string' ? c.trim().length > 0 : true - ) - return !hasAttrs && !hasContent - } - - const removeEmpty = (content) => { - if (!content || !Array.isArray(content)) return content - - return content - .map(node => { - if (typeof node === 'string') return node - if (node.content) { - node.content = removeEmpty(node.content) - } - return node - }) - .filter(node => !isEmptyElement(node)) - } - - // Apply multiple passes until stable - let result = removeEmpty(tree) - let prevJson = '' - let currJson = JSON.stringify(result) - while (prevJson !== currJson) { - prevJson = currJson - result = removeEmpty(result) - currJson = JSON.stringify(result) - } - - return result + // After processing children, check if this subtree is now decorative + // Skip root-level semantic elements (body, main, etc.) + const semanticTags = ['html', 'body', 'main', 'header', 'footer', 'nav', 'section', 'article', 'aside'] + if (semanticTags.includes(node.tag.toLowerCase())) { + return node } + + // If no useful content in this subtree, remove it + if (!hasUsefulContent(node)) { + return null + } + + return node + } + + return tree.map(processNode).filter((item) => item !== null) } + } - // Process HTML - const processor = posthtml() - .use(removeTagsAndAttrsPlugin()) - .use(removeAriaHiddenPlugin()) - .use(removeEmptyAltImagesPlugin()) - .use(removeDecorativeSubtreesPlugin()) - .use(removeEmptyElementsPlugin()) - .use(unwrapNestedWrappersPlugin()) - .use(beautify({ - rules: { - indent: 1, // 1-space indent - blankLines: false, // no extra blank lines - maxlen: 100000 // effectively never wrap by content length - }, - jsBeautifyOptions: { - wrap_line_length: 0, // disable js-beautify wrapping - preserve_newlines: false // reduce stray newlines + // Plugin to unwrap unnecessary nested wrapper elements + // e.g.,

text

->

text

+ const unwrapNestedWrappersPlugin = () => { + return (tree) => { + const isWhitespaceOnly = (node) => { + return typeof node === 'string' && node.trim().length === 0 + } + + const hasNoAttrs = (node) => { + return !node.attrs || Object.keys(node.attrs).length === 0 + } + + const unwrapNode = (node) => { + if (typeof node === 'string') return node + if (!node.tag) return node + + // First, recursively process children + if (node.content && Array.isArray(node.content)) { + node.content = node.content.map(unwrapNode) + } + + // Check if this node is an unnecessary wrapper: + // - has no attributes + // - has exactly one non-whitespace child that is an element + if (hasNoAttrs(node) && node.content && Array.isArray(node.content)) { + const nonWhitespaceChildren = node.content.filter((c) => !isWhitespaceOnly(c)) + + if (nonWhitespaceChildren.length === 1) { + const onlyChild = nonWhitespaceChildren[0] + // If the only child is also an element (not text), unwrap + if (typeof onlyChild !== 'string' && onlyChild.tag) { + // Replace this node with its child + return onlyChild } - })) + } + } - // Process with await - const result = await processor.process(html) + return node + } - return result.html + // Apply multiple passes until stable (handles deeply nested wrappers) + let result = tree.map(unwrapNode) + let prevJson = '' + let currJson = JSON.stringify(result) + while (prevJson !== currJson) { + prevJson = currJson + result = result.map(unwrapNode) + currJson = JSON.stringify(result) + } + + return result + } + } + + // Plugin to remove empty elements (no attrs, no content) + // Runs repeatedly until no more empty elements exist + const removeEmptyElementsPlugin = () => { + return (tree) => { + const isEmptyElement = (node) => { + if (typeof node === 'string') return false + if (!node.tag) return false + const hasAttrs = node.attrs && Object.keys(node.attrs).length > 0 + const hasContent = + node.content && node.content.some((c) => (typeof c === 'string' ? c.trim().length > 0 : true)) + return !hasAttrs && !hasContent + } + + const removeEmpty = (content) => { + if (!content || !Array.isArray(content)) return content + + return content + .map((node) => { + if (typeof node === 'string') return node + if (node.content) { + node.content = removeEmpty(node.content) + } + return node + }) + .filter((node) => !isEmptyElement(node)) + } + + // Apply multiple passes until stable + let result = removeEmpty(tree) + let prevJson = '' + let currJson = JSON.stringify(result) + while (prevJson !== currJson) { + prevJson = currJson + result = removeEmpty(result) + currJson = JSON.stringify(result) + } + + return result + } + } + + // Process HTML + const processor = posthtml() + .use(removeTagsAndAttrsPlugin()) + .use(removeAriaHiddenPlugin()) + .use(removeEmptyAltImagesPlugin()) + .use(removeDecorativeSubtreesPlugin()) + .use(removeEmptyElementsPlugin()) + .use(unwrapNestedWrappersPlugin()) + .use( + beautify({ + rules: { + indent: 1, // 1-space indent + blankLines: false, // no extra blank lines + maxlen: 100000, // effectively never wrap by content length + }, + jsBeautifyOptions: { + wrap_line_length: 0, // disable js-beautify wrapping + preserve_newlines: false, // reduce stray newlines + }, + }), + ) + + // Process with await + const result = await processor.process(html) + + return result.html } diff --git a/playwriter/src/kill-port.ts b/playwriter/src/kill-port.ts index 68040e6..bf24c1d 100644 --- a/playwriter/src/kill-port.ts +++ b/playwriter/src/kill-port.ts @@ -148,9 +148,7 @@ export async function getListeningPidsForPort({ port }: { port: number }): Promi throw new Error(`Invalid port: ${port}`) } - return os.platform() === 'win32' - ? await getPidsForPortWindows(port) - : await getPidsForPortUnix(port) + return os.platform() === 'win32' ? await getPidsForPortWindows(port) : await getPidsForPortUnix(port) } function toError(value: unknown): Error { diff --git a/playwriter/src/mcp-client.ts b/playwriter/src/mcp-client.ts index a6584bd..e02b9b7 100644 --- a/playwriter/src/mcp-client.ts +++ b/playwriter/src/mcp-client.ts @@ -17,7 +17,7 @@ export async function createTransport({ args = [], port }: { args?: string[]; po stderr: Stream | null }> { const env: Record = { - ...process.env as Record, + ...(process.env as Record), DEBUG: 'playwriter:mcp:test', DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1', diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index 55b520f..aae092f 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -91,12 +91,12 @@ async function getOrCreateExecutor(): Promise { if (executor) { return executor } - + const remote = getRemoteConfig() if (!remote) { await ensureRelayServerForMcp() } - + // Pass config instead of pre-generated URL so executor can generate unique URLs for each connection const cdpConfig = remote || { port: RELAY_PORT } executor = new PlaywrightExecutor({ @@ -104,7 +104,7 @@ async function getOrCreateExecutor(): Promise { logger: mcpLogger, cwd: process.cwd(), }) - + return executor } @@ -198,37 +198,37 @@ server.tool( if (!remote) { await ensureRelayServerForMcp() } - + const exec = await getOrCreateExecutor() const result = await exec.execute(code, timeout) - + // Transform executor result to MCP format const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [ { type: 'text', text: result.text }, ] - + for (const image of result.images) { content.push({ type: 'image', data: image.data, mimeType: image.mimeType }) } - + if (result.isError) { return { content, isError: true } } - + return { content } } catch (error: any) { const errorStack = error.stack || error.message const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError' - + console.error('Error in execute tool:', errorStack) if (!isTimeoutError) { sendLogToRelayServer('error', 'Error in execute tool:', errorStack) } - + const resetHint = isTimeoutError ? '' : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]' - + return { content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }], isError: true, @@ -256,13 +256,16 @@ server.tool( if (!remote) { await ensureRelayServerForMcp() } - + const exec = await getOrCreateExecutor() const { page, context } = await exec.reset() const pagesCount = context.pages().length return { content: [ - { type: 'text', text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}` }, + { + type: 'text', + text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`, + }, ], } } catch (error: any) { diff --git a/playwriter/src/page-markdown.ts b/playwriter/src/page-markdown.ts index bcc0ecd..97097b4 100644 --- a/playwriter/src/page-markdown.ts +++ b/playwriter/src/page-markdown.ts @@ -1,6 +1,6 @@ /** * Extract page content as markdown using Mozilla Readability. - * + * * This utility injects the Readability library into the page and extracts * the main content, similar to Firefox Reader View. */ @@ -65,7 +65,7 @@ function isRegExp(value: unknown): value is RegExp { /** * Extract page content as markdown using Mozilla Readability. - * + * * Injects Readability into the page if not already present, then extracts * the main content. Returns plain text content (no HTML). */ @@ -81,7 +81,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise< } // Extract content using Readability - const result = await page.evaluate(() => { + const result = (await page.evaluate(() => { const readability = (globalThis as any).__readability if (!readability) { throw new Error('Readability not loaded') @@ -131,11 +131,11 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise< publishedTime: article.publishedTime || null, wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length, } - }) as PageMarkdownResult & { _notReadable?: boolean } + })) as PageMarkdownResult & { _notReadable?: boolean } // Format output const lines: string[] = [] - + if (result.title) { lines.push(`# ${result.title}`) lines.push('') diff --git a/playwriter/src/protocol.ts b/playwriter/src/protocol.ts index 8233a82..3b2bf54 100644 --- a/playwriter/src/protocol.ts +++ b/playwriter/src/protocol.ts @@ -2,19 +2,18 @@ import { CDPEventFor, ProtocolMapping } from './cdp-types.js' export const VERSION = 1 -type ForwardCDPCommand = - { - [K in keyof ProtocolMapping.Commands]: { - id: number - method: 'forwardCDPCommand' - params: { - method: K - sessionId?: string - params?: ProtocolMapping.Commands[K]['paramsType'][0] - source?: 'playwriter' - } +type ForwardCDPCommand = { + [K in keyof ProtocolMapping.Commands]: { + id: number + method: 'forwardCDPCommand' + params: { + method: K + sessionId?: string + params?: ProtocolMapping.Commands[K]['paramsType'][0] + source?: 'playwriter' } - }[keyof ProtocolMapping.Commands] + } +}[keyof ProtocolMapping.Commands] export type ExtensionCommandMessage = ForwardCDPCommand @@ -29,18 +28,17 @@ export type ExtensionResponseMessage = { * This produces a discriminated union for narrowing, similar to ForwardCDPCommand, * but for forwarded CDP events. Uses CDPEvent to maintain proper type extraction. */ -export type ExtensionEventMessage = - { - [K in keyof ProtocolMapping.Events]: { - id?: undefined - method: 'forwardCDPEvent' - params: { - method: CDPEventFor['method'] - sessionId?: string - params?: CDPEventFor['params'] - } +export type ExtensionEventMessage = { + [K in keyof ProtocolMapping.Events]: { + id?: undefined + method: 'forwardCDPEvent' + params: { + method: CDPEventFor['method'] + sessionId?: string + params?: CDPEventFor['params'] } - }[keyof ProtocolMapping.Events] + } +}[keyof ProtocolMapping.Events] export type ExtensionLogMessage = { id?: undefined @@ -78,7 +76,13 @@ export type RecordingCancelledMessage = { } } -export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage | ExtensionLogMessage | ExtensionPongMessage | RecordingDataMessage | RecordingCancelledMessage +export type ExtensionMessage = + | ExtensionResponseMessage + | ExtensionEventMessage + | ExtensionLogMessage + | ExtensionPongMessage + | RecordingDataMessage + | RecordingCancelledMessage // Recording command messages (MCP -> Extension via relay) export type StartRecordingParams = { @@ -137,36 +141,42 @@ export type RecordingCommandMessage = | CancelRecordingMessage // Recording result types -export type StartRecordingResult = { - success: true - tabId: number - startedAt: number -} | { - success: false - error: string -} +export type StartRecordingResult = + | { + success: true + tabId: number + startedAt: number + } + | { + success: false + error: string + } /** Result from extension - doesn't include path/size since relay writes the file */ -export type ExtensionStopRecordingResult = { - success: true - tabId: number - duration: number -} | { - success: false - error: string -} +export type ExtensionStopRecordingResult = + | { + success: true + tabId: number + duration: number + } + | { + success: false + error: string + } /** Final result from relay - includes path/size after file is written */ -export type StopRecordingResult = { - success: true - tabId: number - duration: number - path: string - size: number -} | { - success: false - error: string -} +export type StopRecordingResult = + | { + success: true + tabId: number + duration: number + path: string + size: number + } + | { + success: false + error: string + } export type IsRecordingResult = { isRecording: boolean @@ -193,10 +203,12 @@ export type GhostBrowserCommandMessage = { } } -export type GhostBrowserCommandResult = { - success: true - result: unknown -} | { - success: false - error: string -} +export type GhostBrowserCommandResult = + | { + success: true + result: unknown + } + | { + success: false + error: string + } diff --git a/playwriter/src/recording-relay.ts b/playwriter/src/recording-relay.ts index 1f5c97b..c1f02ed 100644 --- a/playwriter/src/recording-relay.ts +++ b/playwriter/src/recording-relay.ts @@ -22,7 +22,7 @@ import type { // Recording state - tracks active recordings and their accumulated chunks export interface ActiveRecording { tabId: number - sessionId?: string // The sessionId used to start this recording, for lookup when stopping + sessionId?: string // The sessionId used to start this recording, for lookup when stopping outputPath: string chunks: Buffer[] startedAt: number @@ -40,7 +40,7 @@ export class RecordingRelay { constructor( sendToExtension: (params: { method: string; params?: unknown; timeout?: number }) => Promise, isExtensionConnected: () => boolean, - logger?: { log(...args: unknown[]): void; error(...args: unknown[]): void } + logger?: { log(...args: unknown[]): void; error(...args: unknown[]): void }, ) { this.sendToExtension = sendToExtension this.isExtensionConnected = isExtensionConnected @@ -58,7 +58,11 @@ export class RecordingRelay { const recording = this.activeRecordings.get(tabId) if (recording) { recording.chunks.push(buffer) - this.logger?.log(pc.blue(`Received recording chunk for tab ${tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`)) + this.logger?.log( + pc.blue( + `Received recording chunk for tab ${tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`, + ), + ) } else { this.logger?.log(pc.yellow(`Received recording chunk for unknown tab ${tabId}, ignoring`)) } @@ -140,11 +144,11 @@ export class RecordingRelay { } try { - const result = await this.sendToExtension({ + const result = (await this.sendToExtension({ method: 'startRecording', params: recordingParams, timeout: 10000, - }) as StartRecordingResult + })) as StartRecordingResult if (!result) { return { success: false, error: 'Extension returned empty result' } @@ -158,7 +162,11 @@ export class RecordingRelay { chunks: [], startedAt: result.startedAt, }) - this.logger?.log(pc.green(`Recording started for tab ${result.tabId} (sessionId: ${recordingParams.sessionId || 'none'}), output: ${outputPath}`)) + this.logger?.log( + pc.green( + `Recording started for tab ${result.tabId} (sessionId: ${recordingParams.sessionId || 'none'}), output: ${outputPath}`, + ), + ) } return result @@ -211,11 +219,11 @@ export class RecordingRelay { }) try { - const result = await this.sendToExtension({ + const result = (await this.sendToExtension({ method: 'stopRecording', params, timeout: 10000, - }) as StopRecordingResult + })) as StopRecordingResult if (!result.success) { recording.resolveStop = undefined @@ -237,11 +245,11 @@ export class RecordingRelay { } try { - return await this.sendToExtension({ + return (await this.sendToExtension({ method: 'isRecording', params, timeout: 5000, - }) as IsRecordingResult + })) as IsRecordingResult } catch { return { isRecording: false } } @@ -253,11 +261,11 @@ export class RecordingRelay { } try { - return await this.sendToExtension({ + return (await this.sendToExtension({ method: 'cancelRecording', params, timeout: 5000, - }) as CancelRecordingResult + })) as CancelRecordingResult } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error) this.logger?.error('Cancel recording error:', error) diff --git a/playwriter/src/relay-client.ts b/playwriter/src/relay-client.ts index 59f3d7e..9a4411b 100644 --- a/playwriter/src/relay-client.ts +++ b/playwriter/src/relay-client.ts @@ -30,7 +30,9 @@ export async function getRelayServerVersion(port: number = RELAY_PORT): Promise< } } -export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> { +export async function getExtensionStatus( + port: number = RELAY_PORT, +): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> { try { const response = await fetch(`http://127.0.0.1:${port}/extension/status`, { signal: AbortSignal.timeout(500), @@ -38,7 +40,7 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c if (!response.ok) { return null } - return await response.json() as { connected: boolean; activeTargets: number; playwriterVersion: string | null } + return (await response.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null } } catch { return null } @@ -48,11 +50,13 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c * Wait for the extension to connect to the relay server. * Returns true if connected within timeout, false otherwise. */ -export async function waitForExtension(options: { - port?: number - timeoutMs?: number - logger?: { log: (...args: any[]) => void } -} = {}): Promise { +export async function waitForExtension( + options: { + port?: number + timeoutMs?: number + logger?: { log: (...args: any[]) => void } + } = {}, +): Promise { const { port = RELAY_PORT, timeoutMs = 5000, logger } = options const startTime = Date.now() @@ -155,7 +159,9 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}): if (serverVersion !== null) { if (restartOnVersionMismatch) { - logger?.log(pc.yellow(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`)) + logger?.log( + pc.yellow(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`), + ) await killRelayServer({ port: RELAY_PORT }) } else { // Server is running but different version, just use it @@ -164,7 +170,11 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}): } else { const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => []) if (listeningPids.length > 0) { - logger?.log(pc.yellow(`Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`)) + logger?.log( + pc.yellow( + `Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`, + ), + ) await killRelayServer({ port: RELAY_PORT }) } diff --git a/playwriter/src/relay-core.test.ts b/playwriter/src/relay-core.test.ts index e343e6f..3ac5fbc 100644 --- a/playwriter/src/relay-core.test.ts +++ b/playwriter/src/relay-core.test.ts @@ -2,115 +2,125 @@ import { createMCPClient } from './mcp-client.js' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { getCDPSessionForPage } from './cdp-session.js' import { getCdpUrl } from './utils.js' -import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, js, tryJsonParse } from './test-utils.js' +import { + setupTestContext, + cleanupTestContext, + getExtensionServiceWorker, + type TestContext, + withTimeout, + js, + tryJsonParse, +} from './test-utils.js' import './test-declarations.js' const TEST_PORT = 19987 describe('Relay Core Tests', () => { - let client: Awaited>['client'] - let cleanup: (() => Promise) | null = null - let testCtx: TestContext | null = null + let client: Awaited>['client'] + let cleanup: (() => Promise) | null = null + let testCtx: TestContext | null = null - beforeAll(async () => { - testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-test-', toggleExtension: true }) + beforeAll(async () => { + testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-test-', toggleExtension: true }) - const result = await createMCPClient({ port: TEST_PORT }) - client = result.client - cleanup = result.cleanup - }, 600000) + const result = await createMCPClient({ port: TEST_PORT }) + client = result.client + cleanup = result.cleanup + }, 600000) - afterAll(async () => { - await cleanupTestContext(testCtx, cleanup) - cleanup = null - testCtx = null + afterAll(async () => { + await cleanupTestContext(testCtx, cleanup) + cleanup = null + testCtx = null + }) + + const getBrowserContext = () => { + if (!testCtx?.browserContext) throw new Error('Browser not initialized') + return testCtx.browserContext + } + + it('should inject script via addScriptTag through CDP relay', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await withTimeout({ + promise: getExtensionServiceWorker(browserContext), + timeoutMs: 5000, + errorMessage: 'Timed out waiting for extension service worker for iframe test', }) - const getBrowserContext = () => { - if (!testCtx?.browserContext) throw new Error('Browser not initialized') - return testCtx.browserContext - } + const page = await browserContext.newPage() + const html = '' + const dataUrl = `data:text/html,${encodeURIComponent(html)}` + await page.goto(dataUrl) + await page.bringToFront() - it('should inject script via addScriptTag through CDP relay', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await withTimeout({ - promise: getExtensionServiceWorker(browserContext), - timeoutMs: 5000, - errorMessage: 'Timed out waiting for extension service worker for iframe test', + await withTimeout({ + promise: serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }), + timeoutMs: 10000, + errorMessage: 'Timed out toggling extension for active tab', + }) + await new Promise((r) => { + setTimeout(r, 100) + }) + + const cdpSession = await withTimeout({ + promise: getCDPSessionForPage({ page }), + timeoutMs: 10000, + errorMessage: 'Timed out creating CDP session for page', + }) + + const hasGlobalBefore = await page.evaluate(() => { + return Boolean((globalThis as { __testGlobal?: unknown }).__testGlobal) + }) + expect(hasGlobalBefore).toBe(false) + + await withTimeout({ + promise: (async () => { + await cdpSession.send('Page.enable') + await cdpSession.send('Page.addScriptToEvaluateOnNewDocument', { + source: 'globalThis.__testGlobal = { foo: "bar" }', }) + await page.reload({ waitUntil: 'domcontentloaded' }) + })(), + timeoutMs: 10000, + errorMessage: 'Timed out injecting script via CDP session', + }) - const page = await browserContext.newPage() - const html = '' - const dataUrl = `data:text/html,${encodeURIComponent(html)}` - await page.goto(dataUrl) - await page.bringToFront() + const hasGlobalAfter = await page.evaluate(() => { + return (globalThis as { __testGlobal?: unknown }).__testGlobal + }) + expect(hasGlobalAfter).toEqual({ foo: 'bar' }) - await withTimeout({ - promise: serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }), - timeoutMs: 10000, - errorMessage: 'Timed out toggling extension for active tab', - }) - await new Promise((r) => { setTimeout(r, 100) }) + await cdpSession.detach() + await page.close() + }, 60000) - const cdpSession = await withTimeout({ - promise: getCDPSessionForPage({ page }), - timeoutMs: 10000, - errorMessage: 'Timed out creating CDP session for page', - }) - - const hasGlobalBefore = await page.evaluate(() => { - return Boolean((globalThis as { __testGlobal?: unknown }).__testGlobal) - }) - expect(hasGlobalBefore).toBe(false) - - await withTimeout({ - promise: (async () => { - await cdpSession.send('Page.enable') - await cdpSession.send('Page.addScriptToEvaluateOnNewDocument', { - source: 'globalThis.__testGlobal = { foo: "bar" }', - }) - await page.reload({ waitUntil: 'domcontentloaded' }) - })(), - timeoutMs: 10000, - errorMessage: 'Timed out injecting script via CDP session', - }) - - const hasGlobalAfter = await page.evaluate(() => { - return (globalThis as { __testGlobal?: unknown }).__testGlobal - }) - expect(hasGlobalAfter).toEqual({ foo: 'bar' }) - - await cdpSession.detach() - await page.close() - }, 60000) - - it('should execute code and capture console output', async () => { - await client.callTool({ - name: 'execute', - arguments: { - code: js` + it('should execute code and capture console output', async () => { + await client.callTool({ + name: 'execute', + arguments: { + code: js` const newPage = await context.newPage(); state.page = newPage; if (!state.pages) state.pages = []; state.pages.push(newPage); `, - }, - }) + }, + }) - const result = await client.callTool({ - name: 'execute', - arguments: { - code: js` + const result = await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.page.goto('https://example.com'); const title = await state.page.title(); console.log('Page title:', title); return { url: state.page.url(), title }; `, - }, - }) - expect(result.content).toMatchInlineSnapshot(` + }, + }) + expect(result.content).toMatchInlineSnapshot(` [ { "text": "Console output: @@ -121,138 +131,134 @@ describe('Relay Core Tests', () => { }, ] `) - expect(result.content).toBeDefined() - }, 30000) + expect(result.content).toBeDefined() + }, 30000) - it('should show extension as connected for pages created via newPage()', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should show extension as connected for pages created via newPage()', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - // Create a page via MCP (which uses context.newPage()) - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Create a page via MCP (which uses context.newPage()) + await client.callTool({ + name: 'execute', + arguments: { + code: js` const newPage = await context.newPage(); state.testPage = newPage; await newPage.goto('https://example.com/mcp-test'); return newPage.url(); `, - }, - }) + }, + }) - // Get extension state to verify the page is marked as connected - const extensionState = await serviceWorker.evaluate(async () => { - const state = globalThis.getExtensionState() - const tabs = await chrome.tabs.query({}) - const testTab = tabs.find((t: any) => t.url?.includes('mcp-test')) - return { - connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id), - tabId: testTab?.id, - tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null, - connectionState: state.connectionState - } - }) + // Get extension state to verify the page is marked as connected + const extensionState = await serviceWorker.evaluate(async () => { + const state = globalThis.getExtensionState() + const tabs = await chrome.tabs.query({}) + const testTab = tabs.find((t: any) => t.url?.includes('mcp-test')) + return { + connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id), + tabId: testTab?.id, + tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null, + connectionState: state.connectionState, + } + }) - expect(extensionState.connected).toBe(true) - expect(extensionState.tabInfo?.state).toBe('connected') - expect(extensionState.connectionState).toBe('connected') + expect(extensionState.connected).toBe(true) + expect(extensionState.tabInfo?.state).toBe('connected') + expect(extensionState.connectionState).toBe('connected') - // Clean up - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Clean up + await client.callTool({ + name: 'execute', + arguments: { + code: js` if (state.testPage) { await state.testPage.close(); delete state.testPage; } `, - }, - }) - }, 30000) + }, + }) + }, 30000) - const snapshotTestCases = [ - { - name: 'hacker-news', - url: 'https://news.ycombinator.com/item?id=1', - expectedContent: ['role=link', 'Hacker News'], - }, - { - name: 'shadcn-ui', - url: 'https://ui.shadcn.com/', - expectedContent: ['shadcn'], - }, - ] + const snapshotTestCases = [ + { + name: 'hacker-news', + url: 'https://news.ycombinator.com/item?id=1', + expectedContent: ['role=link', 'Hacker News'], + }, + { + name: 'shadcn-ui', + url: 'https://ui.shadcn.com/', + expectedContent: ['shadcn'], + }, + ] - for (const testCase of snapshotTestCases) { - it(`should get accessibility snapshot of ${testCase.name}`, async () => { - await client.callTool({ - name: 'execute', - arguments: { - code: js` + for (const testCase of snapshotTestCases) { + it(`should get accessibility snapshot of ${testCase.name}`, async () => { + await client.callTool({ + name: 'execute', + arguments: { + code: js` const newPage = await context.newPage(); state.page = newPage; if (!state.pages) state.pages = []; state.pages.push(newPage); `, - }, - }) + }, + }) - // Capture interactiveOnly=true snapshot (default) - const interactiveResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Capture interactiveOnly=true snapshot (default) + const interactiveResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.page.goto('${testCase.url}', { waitUntil: 'domcontentloaded' }); const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: true }); return snap; `, - }, - }) + }, + }) - const interactiveData = - typeof interactiveResult === 'object' && interactiveResult.content?.[0]?.text - ? tryJsonParse(interactiveResult.content[0].text) - : interactiveResult - await expect(interactiveData).toMatchFileSnapshot( - `snapshots/${testCase.name}-accessibility-interactive.md`, - ) - expect(interactiveResult.content).toBeDefined() - for (const expected of testCase.expectedContent) { - expect(interactiveData).toContain(expected) - } + const interactiveData = + typeof interactiveResult === 'object' && interactiveResult.content?.[0]?.text + ? tryJsonParse(interactiveResult.content[0].text) + : interactiveResult + await expect(interactiveData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-interactive.md`) + expect(interactiveResult.content).toBeDefined() + for (const expected of testCase.expectedContent) { + expect(interactiveData).toContain(expected) + } - // Capture interactiveOnly=false snapshot (full tree) - const fullResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Capture interactiveOnly=false snapshot (full tree) + const fullResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: false }); return snap; `, - }, - }) + }, + }) - const fullData = - typeof fullResult === 'object' && fullResult.content?.[0]?.text - ? tryJsonParse(fullResult.content[0].text) - : fullResult - await expect(fullData).toMatchFileSnapshot( - `snapshots/${testCase.name}-accessibility-full.md`, - ) - expect(fullResult.content).toBeDefined() - for (const expected of testCase.expectedContent) { - expect(fullData).toContain(expected) - } - }, 60000) - } + const fullData = + typeof fullResult === 'object' && fullResult.content?.[0]?.text + ? tryJsonParse(fullResult.content[0].text) + : fullResult + await expect(fullData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-full.md`) + expect(fullResult.content).toBeDefined() + for (const expected of testCase.expectedContent) { + expect(fullData).toContain(expected) + } + }, 60000) + } - it('should close all created pages', async () => { - const result = await client.callTool({ - name: 'execute', - arguments: { - code: js` + it('should close all created pages', async () => { + const result = await client.callTool({ + name: 'execute', + arguments: { + code: js` if (state.pages && state.pages.length > 0) { for (const page of state.pages) { await page.close(); @@ -263,17 +269,16 @@ describe('Relay Core Tests', () => { } return { closedCount: 0 }; `, - }, - }) - + }, }) + }) - it('should capture browser console logs with getLatestLogs', async () => { - // Ensure clean state and clear any existing logs - const resetResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + it('should capture browser console logs with getLatestLogs', async () => { + // Ensure clean state and clear any existing logs + const resetResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` // Clear any existing logs from previous tests clearAllLogs(); console.log('Cleared all existing logs'); @@ -284,27 +289,27 @@ describe('Relay Core Tests', () => { return { success: true, pagesCount: pages.length }; `, - }, - }) - console.log('Cleanup result:', resetResult) + }, + }) + console.log('Cleanup result:', resetResult) - // Create a new page for this test - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Create a new page for this test + await client.callTool({ + name: 'execute', + arguments: { + code: js` const newPage = await context.newPage(); state.testLogPage = newPage; await newPage.goto('about:blank'); `, - }, - }) + }, + }) - // Generate some console logs in the browser - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Generate some console logs in the browser + await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.testLogPage.evaluate(() => { console.log('Test log 12345'); console.error('Test error 67890'); @@ -314,325 +319,327 @@ describe('Relay Core Tests', () => { // Wait for logs to be captured await new Promise(resolve => setTimeout(resolve, 100)); `, - }, - }) + }, + }) - // Test getting all logs - const allLogsResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test getting all logs + const allLogsResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs(); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const output = (allLogsResult as any).content[0].text - expect(output).toContain('[log] Test log 12345') - expect(output).toContain('[error] Test error 67890') - expect(output).toContain('[warning] Test warning 11111') + const output = (allLogsResult as any).content[0].text + expect(output).toContain('[log] Test log 12345') + expect(output).toContain('[error] Test error 67890') + expect(output).toContain('[warning] Test warning 11111') - // Test filtering by search string - const errorLogsResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test filtering by search string + const errorLogsResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs({ search: 'error' }); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const errorOutput = (errorLogsResult as any).content[0].text - expect(errorOutput).toContain('[error] Test error 67890') - // With context lines (5 above/below), nearby logs are also included - expect(errorOutput).toContain('[log] Test log 12345') + const errorOutput = (errorLogsResult as any).content[0].text + expect(errorOutput).toContain('[error] Test error 67890') + // With context lines (5 above/below), nearby logs are also included + expect(errorOutput).toContain('[log] Test log 12345') - // Test that logs are cleared on page reload - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test that logs are cleared on page reload + await client.callTool({ + name: 'execute', + arguments: { + code: js` // First add a log before reload await state.testLogPage.evaluate(() => { console.log('Before reload 99999'); }); await new Promise(resolve => setTimeout(resolve, 100)); `, - }, - }) + }, + }) - // Verify the log exists - const beforeReloadResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Verify the log exists + const beforeReloadResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs({ page: state.testLogPage }); console.log('Logs before reload:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const beforeReloadOutput = (beforeReloadResult as any).content[0].text - expect(beforeReloadOutput).toContain('[log] Before reload 99999') + const beforeReloadOutput = (beforeReloadResult as any).content[0].text + expect(beforeReloadOutput).toContain('[log] Before reload 99999') - // Reload the page - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Reload the page + await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.testLogPage.reload(); await state.testLogPage.evaluate(() => { console.log('After reload 88888'); }); await new Promise(resolve => setTimeout(resolve, 100)); `, - }, - }) + }, + }) - // Check logs after reload - old logs should be gone - const afterReloadResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Check logs after reload - old logs should be gone + const afterReloadResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs({ page: state.testLogPage }); console.log('Logs after reload:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const afterReloadOutput = (afterReloadResult as any).content[0].text - expect(afterReloadOutput).toContain('[log] After reload 88888') - expect(afterReloadOutput).not.toContain('[log] Before reload 99999') + const afterReloadOutput = (afterReloadResult as any).content[0].text + expect(afterReloadOutput).toContain('[log] After reload 88888') + expect(afterReloadOutput).not.toContain('[log] Before reload 99999') - // Clean up - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Clean up + await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.testLogPage.close(); delete state.testLogPage; `, - }, - }) - }, 30000) + }, + }) + }, 30000) - it('should keep logs separate between different pages', async () => { - // Clear any existing logs from previous tests - await client.callTool({ - name: 'execute', - arguments: { - code: js` + it('should keep logs separate between different pages', async () => { + // Clear any existing logs from previous tests + await client.callTool({ + name: 'execute', + arguments: { + code: js` clearAllLogs(); console.log('Cleared all existing logs for second log test'); `, - }, - }) + }, + }) - // Create two pages - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Create two pages + await client.callTool({ + name: 'execute', + arguments: { + code: js` state.pageA = await context.newPage(); state.pageB = await context.newPage(); await state.pageA.goto('about:blank'); await state.pageB.goto('about:blank'); `, - }, - }) + }, + }) - // Generate logs in page A - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Generate logs in page A + await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.pageA.evaluate(() => { console.log('PageA log 11111'); console.error('PageA error 22222'); }); await new Promise(resolve => setTimeout(resolve, 100)); `, - }, - }) + }, + }) - // Generate logs in page B - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Generate logs in page B + await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.pageB.evaluate(() => { console.log('PageB log 33333'); console.error('PageB error 44444'); }); await new Promise(resolve => setTimeout(resolve, 100)); `, - }, - }) + }, + }) - // Check logs for page A - should only have page A logs - const pageALogsResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Check logs for page A - should only have page A logs + const pageALogsResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs({ page: state.pageA }); console.log('Page A logs:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const pageAOutput = (pageALogsResult as any).content[0].text - expect(pageAOutput).toContain('[log] PageA log 11111') - expect(pageAOutput).toContain('[error] PageA error 22222') - expect(pageAOutput).not.toContain('PageB') + const pageAOutput = (pageALogsResult as any).content[0].text + expect(pageAOutput).toContain('[log] PageA log 11111') + expect(pageAOutput).toContain('[error] PageA error 22222') + expect(pageAOutput).not.toContain('PageB') - // Check logs for page B - should only have page B logs - const pageBLogsResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Check logs for page B - should only have page B logs + const pageBLogsResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs({ page: state.pageB }); console.log('Page B logs:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const pageBOutput = (pageBLogsResult as any).content[0].text - expect(pageBOutput).toContain('[log] PageB log 33333') - expect(pageBOutput).toContain('[error] PageB error 44444') - expect(pageBOutput).not.toContain('PageA') + const pageBOutput = (pageBLogsResult as any).content[0].text + expect(pageBOutput).toContain('[log] PageB log 33333') + expect(pageBOutput).toContain('[error] PageB error 44444') + expect(pageBOutput).not.toContain('PageA') - // Check all logs - should have logs from both pages - const allLogsResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Check all logs - should have logs from both pages + const allLogsResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs(); console.log('All logs:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const allOutput = (allLogsResult as any).content[0].text - expect(allOutput).toContain('[log] PageA log 11111') - expect(allOutput).toContain('[log] PageB log 33333') + const allOutput = (allLogsResult as any).content[0].text + expect(allOutput).toContain('[log] PageA log 11111') + expect(allOutput).toContain('[log] PageB log 33333') - // Test that reloading page A clears only page A logs - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test that reloading page A clears only page A logs + await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.pageA.reload(); await state.pageA.evaluate(() => { console.log('PageA after reload 55555'); }); await new Promise(resolve => setTimeout(resolve, 100)); `, - }, - }) + }, + }) - // Check page A logs - should only have new log - const pageAAfterReloadResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Check page A logs - should only have new log + const pageAAfterReloadResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs({ page: state.pageA }); console.log('Page A logs after reload:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text - expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555') - expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111') + const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text + expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555') + expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111') - // Check page B logs - should still have original logs - const pageBAfterAReloadResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Check page B logs - should still have original logs + const pageBAfterAReloadResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs({ page: state.pageB }); console.log('Page B logs after A reload:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text - expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333') - expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444') + const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text + expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333') + expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444') - // Test that logs are deleted when page is closed - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test that logs are deleted when page is closed + await client.callTool({ + name: 'execute', + arguments: { + code: js` // Close page A await state.pageA.close(); await new Promise(resolve => setTimeout(resolve, 100)); `, - }, - }) + }, + }) - // Check all logs - page A logs should be gone - const logsAfterCloseResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Check all logs - page A logs should be gone + const logsAfterCloseResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const logs = await getLatestLogs(); console.log('All logs after closing page A:', logs.length); logs.forEach(log => console.log(log)); `, - }, - }) + }, + }) - const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text - expect(logsAfterCloseOutput).not.toContain('PageA') - expect(logsAfterCloseOutput).toContain('[log] PageB log 33333') + const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text + expect(logsAfterCloseOutput).not.toContain('PageA') + expect(logsAfterCloseOutput).toContain('[log] PageB log 33333') - // Clean up remaining page - await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Clean up remaining page + await client.callTool({ + name: 'execute', + arguments: { + code: js` await state.pageB.close(); delete state.pageA; delete state.pageB; `, - }, - }) - }, 30000) + }, + }) + }, 30000) - // right now our extension always forces light mode because of a playwright cdp bug - it.todo('should preserve system color scheme instead of forcing light mode', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + // right now our extension always forces light mode because of a playwright cdp bug + it.todo( + 'should preserve system color scheme instead of forcing light mode', + async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - await page.goto('https://example.com') - await page.bringToFront() + const page = await browserContext.newPage() + await page.goto('https://example.com') + await page.bringToFront() - const colorSchemeBefore = await page.evaluate(() => { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' - }) - console.log('Color scheme before MCP connection:', colorSchemeBefore) + const colorSchemeBefore = await page.evaluate(() => { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + }) + console.log('Color scheme before MCP connection:', colorSchemeBefore) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const result = await client.callTool({ - name: 'execute', - arguments: { - code: js` + const result = await client.callTool({ + name: 'execute', + arguments: { + code: js` const pages = context.pages(); const urls = pages.map(p => p.url()); const targetPage = pages.find(p => p.url().includes('example.com')); @@ -643,12 +650,12 @@ describe('Relay Core Tests', () => { const isLight = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: light)').matches); return { matchesDark: isDark, matchesLight: isLight }; `, - }, - }) + }, + }) - console.log('Color scheme after MCP connection:', result.content) + console.log('Color scheme after MCP connection:', result.content) - expect(result.content).toMatchInlineSnapshot(` + expect(result.content).toMatchInlineSnapshot(` [ { "text": "[return value] { error: 'Page not found', urls: [ 'about:blank' ] }", @@ -657,15 +664,17 @@ describe('Relay Core Tests', () => { ] `) - await page.close() - }, 60000) + await page.close() + }, + 60000, + ) - it('should get clean HTML with getCleanHTML', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should get clean HTML with getCleanHTML', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - await page.setContent(` + const page = await browserContext.newPage() + await page.setContent(` @@ -681,18 +690,18 @@ describe('Relay Core Tests', () => { `) - await page.bringToFront() + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 400)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 400)) - // Test basic getCleanHTML - const result = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test basic getCleanHTML + const result = await client.callTool({ + name: 'execute', + arguments: { + code: js` let testPage; for (const p of context.pages()) { const html = await p.content(); @@ -702,15 +711,15 @@ describe('Relay Core Tests', () => { const html = await getCleanHTML({ locator: testPage.locator('body') }); return html; `, - timeout: 15000, - }, - }) + timeout: 15000, + }, + }) - expect(result.isError).toBeFalsy() - const text = (result.content as any)[0]?.text || '' + expect(result.isError).toBeFalsy() + const text = (result.content as any)[0]?.text || '' - // Inline snapshot of cleaned HTML - expect(text).toMatchInlineSnapshot(` + // Inline snapshot of cleaned HTML + expect(text).toMatchInlineSnapshot(` "[return value]

Hello World

@@ -719,16 +728,16 @@ describe('Relay Core Tests', () => {
" `) - // Should NOT contain script/style tags (they're removed) - expect(text).not.toContain(' { const html = await getCleanHTML({ locator: testPage, search: /button/i }); return html; `, - timeout: 15000, - }, - }) + timeout: 15000, + }, + }) - expect(searchResult.isError).toBeFalsy() - const searchText = (searchResult.content as any)[0]?.text || '' - expect(searchText).toContain('button') + expect(searchResult.isError).toBeFalsy() + const searchText = (searchResult.content as any)[0]?.text || '' + expect(searchText).toContain('button') - await page.close() - }, 60000) + await page.close() + }, 60000) - it('should extract page content as markdown with getPageMarkdown', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should extract page content as markdown with getPageMarkdown', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - // Create a realistic article-like page structure - await page.setContent(` + const page = await browserContext.newPage() + // Create a realistic article-like page structure + await page.setContent(` Test Article Title @@ -782,18 +791,18 @@ describe('Relay Core Tests', () => { `) - await page.bringToFront() + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 400)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 400)) - // Test basic getPageMarkdown - const result = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test basic getPageMarkdown + const result = await client.callTool({ + name: 'execute', + arguments: { + code: js` let testPage; for (const p of context.pages()) { const html = await p.content(); @@ -803,30 +812,30 @@ describe('Relay Core Tests', () => { const content = await getPageMarkdown({ page: testPage }); console.log(content); `, - timeout: 15000, - }, - }) + timeout: 15000, + }, + }) - expect(result.isError).toBeFalsy() - const text = (result.content as any)[0]?.text || '' + expect(result.isError).toBeFalsy() + const text = (result.content as any)[0]?.text || '' - // Snapshot the full output - await expect(text).toMatchFileSnapshot('./snapshots/page-markdown-output.txt') + // Snapshot the full output + await expect(text).toMatchFileSnapshot('./snapshots/page-markdown-output.txt') - // Should contain article content - expect(text).toContain('Test Article Title') - expect(text).toContain('first paragraph') - expect(text).toContain('second paragraph') + // Should contain article content + expect(text).toContain('Test Article Title') + expect(text).toContain('first paragraph') + expect(text).toContain('second paragraph') - // Should NOT contain script/style content - expect(text).not.toContain('analytics') - expect(text).not.toContain('background: blue') + // Should NOT contain script/style content + expect(text).not.toContain('analytics') + expect(text).not.toContain('background: blue') - // Test search functionality - const searchResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // Test search functionality + const searchResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` let testPage; for (const p of context.pages()) { const html = await p.content(); @@ -836,97 +845,97 @@ describe('Relay Core Tests', () => { const content = await getPageMarkdown({ page: testPage, search: /important/i, showDiffSinceLastCall: false }); return content; `, - timeout: 15000, - }, - }) + timeout: 15000, + }, + }) - expect(searchResult.isError).toBeFalsy() - const searchText = (searchResult.content as any)[0]?.text || '' - expect(searchText).toContain('important') + expect(searchResult.isError).toBeFalsy() + const searchText = (searchResult.content as any)[0]?.text || '' + expect(searchText).toContain('important') - await page.close() - }, 60000) + await page.close() + }, 60000) - it('should handle default page being closed and switch to another available page', async () => { - // This test verifies that when the default `page` in MCP scope is closed, - // the MCP automatically switches to another available page instead of failing - // with cryptic "page closed" errors. + it('should handle default page being closed and switch to another available page', async () => { + // This test verifies that when the default `page` in MCP scope is closed, + // the MCP automatically switches to another available page instead of failing + // with cryptic "page closed" errors. - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - // 1. Disconnect everything to start fresh - await serviceWorker.evaluate(async () => { - await globalThis.disconnectEverything() - }) - await new Promise(r => setTimeout(r, 100)) + // 1. Disconnect everything to start fresh + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) + await new Promise((r) => setTimeout(r, 100)) - // 2. Create first page and enable extension - const page1 = await browserContext.newPage() - await page1.goto('https://example.com/first-page') - await page1.bringToFront() + // 2. Create first page and enable extension + const page1 = await browserContext.newPage() + await page1.goto('https://example.com/first-page') + await page1.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - // 3. Reset MCP to ensure page1 becomes the default page (only page available) - const resetResult = await client.callTool({ - name: 'reset', - arguments: {}, - }) - expect((resetResult as any).content[0].text).toContain('Connection reset successfully') + // 3. Reset MCP to ensure page1 becomes the default page (only page available) + const resetResult = await client.callTool({ + name: 'reset', + arguments: {}, + }) + expect((resetResult as any).content[0].text).toContain('Connection reset successfully') - // 4. Verify initial page is accessible via default `page` - const initialResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // 4. Verify initial page is accessible via default `page` + const initialResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const url = page.url(); console.log('Initial page URL:', url); return { url }; `, - }, - }) - expect((initialResult as any).content[0].text).toContain('first-page') + }, + }) + expect((initialResult as any).content[0].text).toContain('first-page') - // 5. Create second page and enable extension - const page2 = await browserContext.newPage() - await page2.goto('https://example.com/second-page') - await page2.bringToFront() + // 5. Create second page and enable extension + const page2 = await browserContext.newPage() + await page2.goto('https://example.com/second-page') + await page2.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - // 6. Close the first page (which is the default `page` in MCP scope) - await page1.close() - await new Promise(r => setTimeout(r, 100)) + // 6. Close the first page (which is the default `page` in MCP scope) + await page1.close() + await new Promise((r) => setTimeout(r, 100)) - // 7. Execute code via MCP - should NOT fail with "page closed" error - // Instead, it should automatically switch to the second page - const afterCloseResult = await client.callTool({ - name: 'execute', - arguments: { - code: js` + // 7. Execute code via MCP - should NOT fail with "page closed" error + // Instead, it should automatically switch to the second page + const afterCloseResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` const url = page.url(); console.log('Page URL after close:', url); const title = await page.title(); return { url, title }; `, - }, - }) + }, + }) - // Should succeed and return the second page's info - expect((afterCloseResult as any).isError).toBeFalsy() - const output = (afterCloseResult as any).content[0].text - expect(output).toContain('second-page') - expect(output).not.toContain('page closed') - expect(output).not.toContain('Target closed') + // Should succeed and return the second page's info + expect((afterCloseResult as any).isError).toBeFalsy() + const output = (afterCloseResult as any).content[0].text + expect(output).toContain('second-page') + expect(output).not.toContain('page closed') + expect(output).not.toContain('Target closed') - // Cleanup - await page2.close() - }, 60000) + // Cleanup + await page2.close() + }, 60000) }) diff --git a/playwriter/src/relay-navigation.test.ts b/playwriter/src/relay-navigation.test.ts index 6f0be04..2f8f70e 100644 --- a/playwriter/src/relay-navigation.test.ts +++ b/playwriter/src/relay-navigation.test.ts @@ -1,194 +1,194 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { chromium, type Page } from '@xmorse/playwright-core' import { getCdpUrl } from './utils.js' -import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, createSimpleServer } from './test-utils.js' +import { + setupTestContext, + cleanupTestContext, + getExtensionServiceWorker, + type TestContext, + withTimeout, + createSimpleServer, +} from './test-utils.js' import './test-declarations.js' const TEST_PORT = 19992 describe('Relay Navigation Tests', () => { - let testCtx: TestContext | null = null + let testCtx: TestContext | null = null - beforeAll(async () => { - testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-nav-test-', toggleExtension: true }) - }, 600000) + beforeAll(async () => { + testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-nav-test-', toggleExtension: true }) + }, 600000) - afterAll(async () => { - await cleanupTestContext(testCtx) - testCtx = null + afterAll(async () => { + await cleanupTestContext(testCtx) + testCtx = null + }) + + const getBrowserContext = () => { + if (!testCtx?.browserContext) throw new Error('Browser not initialized') + return testCtx.browserContext + } + + const waitForStableDocumentReadyState = async ({ page, timeoutMs }: { page: Page; timeoutMs: number }) => { + const startTime = Date.now() + + while (Date.now() - startTime < timeoutMs) { + try { + const readyState = await page.evaluate(() => { + return document.readyState + }) + if (readyState !== 'loading') { + return + } + } catch (e) { + if (!(e instanceof Error) || !e.message.includes('Execution context was destroyed')) { + throw new Error('Failed while waiting for stable document readyState', { cause: e }) + } + } + + await page.waitForTimeout(100) + } + + throw new Error(`Timed out waiting for stable document readyState after ${timeoutMs}ms`) + } + + it('should be usable after toggle with valid URL', async () => { + // Validates the extension waits for a non-empty URL before attaching. + + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const context = browser.contexts()[0] + + const server = await createSimpleServer({ + routes: { + '/': 'ok', + }, }) - const getBrowserContext = () => { - if (!testCtx?.browserContext) throw new Error('Browser not initialized') - return testCtx.browserContext + const page = await browserContext.newPage() + try { + await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' }) + await page.bringToFront() + + const pagePromise = context.waitForEvent('page', { timeout: 5000 }) + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + const targetPage = await pagePromise + console.log('Page URL when event fired:', targetPage.url()) + + expect(targetPage.url()).not.toBe('') + expect(targetPage.url()).not.toBe(':') + expect(targetPage.url()).toContain(server.baseUrl) + + const result = await targetPage.evaluate(() => window.location.href) + expect(result).toContain(server.baseUrl) + } finally { + await browser.close() + await page.close() + await server.close() } + }, 15000) - const waitForStableDocumentReadyState = async ({ - page, - timeoutMs, - }: { - page: Page - timeoutMs: number - }) => { - const startTime = Date.now() + it('should expose iframe frames when connecting to an existing page over CDP', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - while (Date.now() - startTime < timeoutMs) { - try { - const readyState = await page.evaluate(() => { - return document.readyState - }) - if (readyState !== 'loading') { - return - } - } catch (e) { - if (!(e instanceof Error) || !e.message.includes('Execution context was destroyed')) { - throw new Error('Failed while waiting for stable document readyState', { cause: e }) - } - } + const childServer = await createSimpleServer({ + routes: { + '/child.html': 'child', + }, + }) + const childUrl = `${childServer.baseUrl}/child.html` - await page.waitForTimeout(100) - } + const parentServer = await createSimpleServer({ + routes: { + '/': ``, + }, + }) - throw new Error(`Timed out waiting for stable document readyState after ${timeoutMs}ms`) + const page = await browserContext.newPage() + try { + await withTimeout({ + promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }), + timeoutMs: 6000, + errorMessage: 'Timed out loading parent page for iframe test', + }) + await withTimeout({ + promise: page.frameLocator('iframe').locator('body').waitFor({ timeout: 5000 }), + timeoutMs: 6000, + errorMessage: 'Timed out waiting for iframe to attach in parent page', + }) + expect(page.frames().map((frame) => frame.url())).toContain(childUrl) + await page.bringToFront() + + await withTimeout({ + promise: serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }), + timeoutMs: 5000, + errorMessage: 'Timed out toggling extension for iframe test', + }) + await new Promise((r) => { + setTimeout(r, 400) + }) + + const browser = await withTimeout({ + promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), + timeoutMs: 5000, + errorMessage: 'Timed out connecting over CDP for iframe test', + }) + const context = browser.contexts()[0] + const cdpPage = context.pages().find((candidate) => { + return candidate.url().startsWith(parentServer.baseUrl) + }) + expect(cdpPage).toBeDefined() + + const frames = cdpPage!.frames() + const childFrame = frames.find((frame) => { + return frame.url() === childUrl + }) + + expect(frames.length).toBe(2) + expect(childFrame).toBeDefined() + + await withTimeout({ + promise: browser.close(), + timeoutMs: 5000, + errorMessage: 'Timed out closing CDP browser for iframe test', + }) + } finally { + await withTimeout({ + promise: page.close(), + timeoutMs: 5000, + errorMessage: 'Timed out closing page for iframe test', + }) + await Promise.all([parentServer.close(), childServer.close()]) } + }, 60000) - it('should be usable after toggle with valid URL', async () => { - // Validates the extension waits for a non-empty URL before attaching. + it('should resolve locators for cross-origin iframe that starts with empty src', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const context = browser.contexts()[0] + const childServer = await createSimpleServer({ + routes: { + '/login.html': '', + '/canvas.html': '', + }, + }) + const loginUrl = `${childServer.baseUrl}/login.html` + const canvasUrl = `${childServer.baseUrl}/canvas.html` - const server = await createSimpleServer({ - routes: { - '/': 'ok', - }, - }) - - const page = await browserContext.newPage() - try { - await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' }) - await page.bringToFront() - - const pagePromise = context.waitForEvent('page', { timeout: 5000 }) - - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - const targetPage = await pagePromise - console.log('Page URL when event fired:', targetPage.url()) - - expect(targetPage.url()).not.toBe('') - expect(targetPage.url()).not.toBe(':') - expect(targetPage.url()).toContain(server.baseUrl) - - const result = await targetPage.evaluate(() => window.location.href) - expect(result).toContain(server.baseUrl) - } finally { - await browser.close() - await page.close() - await server.close() - } - }, 15000) - - it('should expose iframe frames when connecting to an existing page over CDP', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - - const childServer = await createSimpleServer({ - routes: { - '/child.html': 'child', - }, - }) - const childUrl = `${childServer.baseUrl}/child.html` - - const parentServer = await createSimpleServer({ - routes: { - '/': ``, - }, - }) - - const page = await browserContext.newPage() - try { - await withTimeout({ - promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }), - timeoutMs: 6000, - errorMessage: 'Timed out loading parent page for iframe test', - }) - await withTimeout({ - promise: page.frameLocator('iframe').locator('body').waitFor({ timeout: 5000 }), - timeoutMs: 6000, - errorMessage: 'Timed out waiting for iframe to attach in parent page', - }) - expect(page.frames().map((frame) => frame.url())).toContain(childUrl) - await page.bringToFront() - - await withTimeout({ - promise: serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }), - timeoutMs: 5000, - errorMessage: 'Timed out toggling extension for iframe test', - }) - await new Promise((r) => { setTimeout(r, 400) }) - - const browser = await withTimeout({ - promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), - timeoutMs: 5000, - errorMessage: 'Timed out connecting over CDP for iframe test', - }) - const context = browser.contexts()[0] - const cdpPage = context.pages().find((candidate) => { - return candidate.url().startsWith(parentServer.baseUrl) - }) - expect(cdpPage).toBeDefined() - - const frames = cdpPage!.frames() - const childFrame = frames.find((frame) => { - return frame.url() === childUrl - }) - - expect(frames.length).toBe(2) - expect(childFrame).toBeDefined() - - await withTimeout({ - promise: browser.close(), - timeoutMs: 5000, - errorMessage: 'Timed out closing CDP browser for iframe test', - }) - } finally { - await withTimeout({ - promise: page.close(), - timeoutMs: 5000, - errorMessage: 'Timed out closing page for iframe test', - }) - await Promise.all([ - parentServer.close(), - childServer.close(), - ]) - } - }, 60000) - - it('should resolve locators for cross-origin iframe that starts with empty src', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - - const childServer = await createSimpleServer({ - routes: { - '/login.html': '', - '/canvas.html': '', - }, - }) - const loginUrl = `${childServer.baseUrl}/login.html` - const canvasUrl = `${childServer.baseUrl}/canvas.html` - - const parentServer = await createSimpleServer({ - routes: { - // Reproduces Framer-like plugin iframes: attached with empty src first, - // then navigated cross-origin after auto-attach is active. - '/': ` + const parentServer = await createSimpleServer({ + routes: { + // Reproduces Framer-like plugin iframes: attached with empty src first, + // then navigated cross-origin after auto-attach is active. + '/': ` @@ -203,194 +203,200 @@ describe('Relay Navigation Tests', () => { `, - }, - }) + }, + }) - const page = await browserContext.newPage() - try { - await withTimeout({ - promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }), - timeoutMs: 6000, - errorMessage: 'Timed out loading parent page for empty-src iframe test', - }) - await page.bringToFront() + const page = await browserContext.newPage() + try { + await withTimeout({ + promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }), + timeoutMs: 6000, + errorMessage: 'Timed out loading parent page for empty-src iframe test', + }) + await page.bringToFront() - await withTimeout({ - promise: serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }), - timeoutMs: 5000, - errorMessage: 'Timed out toggling extension for empty-src iframe test', - }) + await withTimeout({ + promise: serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }), + timeoutMs: 5000, + errorMessage: 'Timed out toggling extension for empty-src iframe test', + }) - const browser = await withTimeout({ - promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), - timeoutMs: 5000, - errorMessage: 'Timed out connecting over CDP for empty-src iframe test', - }) + const browser = await withTimeout({ + promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), + timeoutMs: 5000, + errorMessage: 'Timed out connecting over CDP for empty-src iframe test', + }) - try { - const context = browser.contexts()[0] - const cdpPage = context.pages().find((candidate) => { - return candidate.url().startsWith(parentServer.baseUrl) - }) - expect(cdpPage).toBeDefined() - - await withTimeout({ - promise: page.evaluate(() => { - ;(window as Window & { startPluginFlow?: () => void }).startPluginFlow?.() - }), - timeoutMs: 3000, - errorMessage: 'Timed out starting plugin iframe flow', - }) - - const pluginFrame = await withTimeout({ - promise: (async () => { - for (let attempt = 0; attempt < 40; attempt += 1) { - const frame = cdpPage!.frames().find((candidate) => { - return candidate.url() === loginUrl || candidate.url() === canvasUrl - }) - if (frame) { - return frame - } - await cdpPage!.waitForTimeout(100) - } - throw new Error('Plugin frame did not appear with expected URL') - })(), - timeoutMs: 5000, - errorMessage: 'Timed out waiting for plugin frame URL in empty-src iframe test', - }) - - await withTimeout({ - promise: pluginFrame.locator('button').first().waitFor({ state: 'attached' }), - timeoutMs: 5000, - errorMessage: 'Timed out waiting for button locator in empty-src iframe test', - }) - - const buttonCount = await pluginFrame.locator('button').count() - expect(buttonCount).toBe(1) - } finally { - await withTimeout({ - promise: browser.close(), - timeoutMs: 5000, - errorMessage: 'Timed out closing CDP browser for empty-src iframe test', - }) - } - } finally { - await withTimeout({ - promise: page.close(), - timeoutMs: 5000, - errorMessage: 'Timed out closing page for empty-src iframe test', - }) - await Promise.all([ - parentServer.close(), - childServer.close(), - ]) - } - }, 60000) - - it('should have non-empty URLs when connecting to already-loaded pages', async () => { - const _browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(_browserContext) - - const page = await _browserContext.newPage() - await page.goto('https://discord.com/login', { waitUntil: 'load' }) - await page.bringToFront() - - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + try { const context = browser.contexts()[0] - - const pages = context.pages() - console.log('All page URLs:', pages.map(p => p.url())) - - expect(pages.length).toBeGreaterThan(0) - for (const p of pages) { - expect(p.url()).not.toBe('') - expect(p.url()).not.toBe(':') - expect(p.url()).not.toBeUndefined() - } - - const discordPage = pages.find(p => p.url().includes('discord.com')) - expect(discordPage).toBeDefined() - - const result = await discordPage!.evaluate(() => window.location.href) - expect(result).toContain('discord.com') - - await browser.close() - await page.close() - }, 60000) - - it('should navigate to notion without hanging', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - - const page = await browserContext.newPage() - const initialUrl = 'https://example.com/notion-repro' - await page.goto(initialUrl) - await page.bringToFront() - - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() + const cdpPage = context.pages().find((candidate) => { + return candidate.url().startsWith(parentServer.baseUrl) }) - - await new Promise(r => setTimeout(r, 100)) - - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url() === initialUrl) expect(cdpPage).toBeDefined() - const response = await cdpPage!.goto('https://www.notion.so', { waitUntil: 'domcontentloaded', timeout: 20000 }) - - const currentUrl = cdpPage!.url() - const responseUrl = response?.url() ?? '' - expect(responseUrl).toMatch(/notion\.(so|com)/) - expect(currentUrl).toMatch(/notion\.(so|com)/) - expect(await cdpPage!.evaluate(() => document.readyState)).not.toBe('loading') - - await browser.close() - await page.close() - }, 60000) - - it('should navigate to youtube without hanging', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - - const page = await browserContext.newPage() - await page.goto('about:blank') - await page.bringToFront() - - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() + await withTimeout({ + promise: page.evaluate(() => { + ;(window as Window & { startPluginFlow?: () => void }).startPluginFlow?.() + }), + timeoutMs: 3000, + errorMessage: 'Timed out starting plugin iframe flow', }) - await new Promise(r => setTimeout(r, 100)) + const pluginFrame = await withTimeout({ + promise: (async () => { + for (let attempt = 0; attempt < 40; attempt += 1) { + const frame = cdpPage!.frames().find((candidate) => { + return candidate.url() === loginUrl || candidate.url() === canvasUrl + }) + if (frame) { + return frame + } + await cdpPage!.waitForTimeout(100) + } + throw new Error('Plugin frame did not appear with expected URL') + })(), + timeoutMs: 5000, + errorMessage: 'Timed out waiting for plugin frame URL in empty-src iframe test', + }) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('about:')) - expect(cdpPage).toBeDefined() + await withTimeout({ + promise: pluginFrame.locator('button').first().waitFor({ state: 'attached' }), + timeoutMs: 5000, + errorMessage: 'Timed out waiting for button locator in empty-src iframe test', + }) - const response = await cdpPage!.goto('https://www.youtube.com', { waitUntil: 'domcontentloaded', timeout: 20000 }) - const currentUrl = cdpPage!.url() - const responseUrl = response?.url() ?? '' + const buttonCount = await pluginFrame.locator('button').count() + expect(buttonCount).toBe(1) + } finally { + await withTimeout({ + promise: browser.close(), + timeoutMs: 5000, + errorMessage: 'Timed out closing CDP browser for empty-src iframe test', + }) + } + } finally { + await withTimeout({ + promise: page.close(), + timeoutMs: 5000, + errorMessage: 'Timed out closing page for empty-src iframe test', + }) + await Promise.all([parentServer.close(), childServer.close()]) + } + }, 60000) - expect(responseUrl).toContain('youtube') - expect(currentUrl).toContain('youtube') - await waitForStableDocumentReadyState({ page: cdpPage!, timeoutMs: 5000 }) + it('should have non-empty URLs when connecting to already-loaded pages', async () => { + const _browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(_browserContext) - await browser.close() - await page.close() - }, 60000) + const page = await _browserContext.newPage() + await page.goto('https://discord.com/login', { waitUntil: 'load' }) + await page.bringToFront() - it('should maintain correct page.url() with iframe-heavy pages', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - const page = await browserContext.newPage() - await page.setContent(` + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const context = browser.contexts()[0] + + const pages = context.pages() + console.log( + 'All page URLs:', + pages.map((p) => p.url()), + ) + + expect(pages.length).toBeGreaterThan(0) + for (const p of pages) { + expect(p.url()).not.toBe('') + expect(p.url()).not.toBe(':') + expect(p.url()).not.toBeUndefined() + } + + const discordPage = pages.find((p) => p.url().includes('discord.com')) + expect(discordPage).toBeDefined() + + const result = await discordPage!.evaluate(() => window.location.href) + expect(result).toContain('discord.com') + + await browser.close() + await page.close() + }, 60000) + + it('should navigate to notion without hanging', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + const initialUrl = 'https://example.com/notion-repro' + await page.goto(initialUrl) + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + await new Promise((r) => setTimeout(r, 100)) + + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url() === initialUrl) + expect(cdpPage).toBeDefined() + + const response = await cdpPage!.goto('https://www.notion.so', { waitUntil: 'domcontentloaded', timeout: 20000 }) + + const currentUrl = cdpPage!.url() + const responseUrl = response?.url() ?? '' + expect(responseUrl).toMatch(/notion\.(so|com)/) + expect(currentUrl).toMatch(/notion\.(so|com)/) + expect(await cdpPage!.evaluate(() => document.readyState)).not.toBe('loading') + + await browser.close() + await page.close() + }, 60000) + + it('should navigate to youtube without hanging', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + await page.goto('about:blank') + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + await new Promise((r) => setTimeout(r, 100)) + + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url().includes('about:')) + expect(cdpPage).toBeDefined() + + const response = await cdpPage!.goto('https://www.youtube.com', { waitUntil: 'domcontentloaded', timeout: 20000 }) + const currentUrl = cdpPage!.url() + const responseUrl = response?.url() ?? '' + + expect(responseUrl).toContain('youtube') + expect(currentUrl).toContain('youtube') + await waitForStableDocumentReadyState({ page: cdpPage!, timeoutMs: 5000 }) + + await browser.close() + await page.close() + }, 60000) + + it('should maintain correct page.url() with iframe-heavy pages', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + await page.setContent(` Iframe Test Page @@ -401,195 +407,199 @@ describe('Relay Navigation Tests', () => { `) - await page.bringToFront() + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - await new Promise(r => setTimeout(r, 100)) + await new Promise((r) => setTimeout(r, 100)) - for (let i = 0; i < 3; i++) { - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const pages = browser.contexts()[0].pages() - let iframePage - for (const p of pages) { - const html = await p.content() - if (html.includes('Iframe Heavy Page')) { - iframePage = p - break - } - } - - expect(iframePage).toBeDefined() - expect(iframePage?.url()).toContain('about:') - - await browser.close() - await new Promise(r => setTimeout(r, 100)) + for (let i = 0; i < 3; i++) { + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const pages = browser.contexts()[0].pages() + let iframePage + for (const p of pages) { + const html = await p.content() + if (html.includes('Iframe Heavy Page')) { + iframePage = p + break } + } - await page.close() - }, 30000) + expect(iframePage).toBeDefined() + expect(iframePage?.url()).toContain('about:') - it('should work with stagehand', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + await browser.close() + await new Promise((r) => setTimeout(r, 100)) + } - await serviceWorker.evaluate(async () => { - await globalThis.disconnectEverything() - }) - await new Promise(r => setTimeout(r, 100)) + await page.close() + }, 30000) - const targetUrl = 'https://example.com/' + it('should work with stagehand', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const enableResult = await serviceWorker.evaluate(async (url) => { - const tab = await chrome.tabs.create({ url, active: true }) - await new Promise(r => setTimeout(r, 100)) - return await globalThis.toggleExtensionForActiveTab() - }, targetUrl) + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) + await new Promise((r) => setTimeout(r, 100)) - console.log('Extension enabled:', enableResult) - expect(enableResult.isConnected).toBe(true) + const targetUrl = 'https://example.com/' - await new Promise(r => setTimeout(r, 100)) + const enableResult = await serviceWorker.evaluate(async (url) => { + const tab = await chrome.tabs.create({ url, active: true }) + await new Promise((r) => setTimeout(r, 100)) + return await globalThis.toggleExtensionForActiveTab() + }, targetUrl) - const { Stagehand } = await import('@browserbasehq/stagehand') + console.log('Extension enabled:', enableResult) + expect(enableResult.isConnected).toBe(true) - const stagehand = new Stagehand({ - env: 'LOCAL', - verbose: 1, - disablePino: true, - localBrowserLaunchOptions: { - cdpUrl: getCdpUrl({ port: TEST_PORT }), - }, - }) + await new Promise((r) => setTimeout(r, 100)) - console.log('Initializing Stagehand...') - await stagehand.init() - console.log('Stagehand initialized') + const { Stagehand } = await import('@browserbasehq/stagehand') - const context = stagehand.context - expect(context).toBeDefined() + const stagehand = new Stagehand({ + env: 'LOCAL', + verbose: 1, + disablePino: true, + localBrowserLaunchOptions: { + cdpUrl: getCdpUrl({ port: TEST_PORT }), + }, + }) - const pages = context.pages() - console.log('Stagehand pages:', pages.length, pages.map(p => p.url())) + console.log('Initializing Stagehand...') + await stagehand.init() + console.log('Stagehand initialized') - const stagehandPage = pages.find(p => p.url().includes('example.com')) - expect(stagehandPage).toBeDefined() + const context = stagehand.context + expect(context).toBeDefined() - const url = stagehandPage!.url() - console.log('Stagehand page URL:', url) - expect(url).toContain('example.com') + const pages = context.pages() + console.log( + 'Stagehand pages:', + pages.length, + pages.map((p) => p.url()), + ) - await stagehand.close() - }, 60000) + const stagehandPage = pages.find((p) => p.url().includes('example.com')) + expect(stagehandPage).toBeDefined() - it('should expose CDP discovery endpoints /json/version and /json/list', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + const url = stagehandPage!.url() + console.log('Stagehand page URL:', url) + expect(url).toContain('example.com') - const page = await browserContext.newPage() - await page.goto('https://example.com') - await page.bringToFront() + await stagehand.close() + }, 60000) - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 200)) + it('should expose CDP discovery endpoints /json/version and /json/list', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - // Test /json/version - const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`) - expect(versionRes.status).toBe(200) - const versionJson = await versionRes.json() as { webSocketDebuggerUrl: string } - expect(versionJson).toMatchObject({ - 'Browser': expect.stringContaining('Playwriter/'), - 'Protocol-Version': '1.3', - 'webSocketDebuggerUrl': expect.stringContaining('ws://'), - }) - expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`) + const page = await browserContext.newPage() + await page.goto('https://example.com') + await page.bringToFront() - // Test /json/version/ (trailing slash) - const versionSlashRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version/`) - expect(versionSlashRes.status).toBe(200) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 200)) - // Test /json/list - const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`) - expect(listRes.status).toBe(200) - const listJson = await listRes.json() as Array<{ url?: string }> - expect(Array.isArray(listJson)).toBe(true) - expect(listJson.length).toBeGreaterThan(0) + // Test /json/version + const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`) + expect(versionRes.status).toBe(200) + const versionJson = (await versionRes.json()) as { webSocketDebuggerUrl: string } + expect(versionJson).toMatchObject({ + Browser: expect.stringContaining('Playwriter/'), + 'Protocol-Version': '1.3', + webSocketDebuggerUrl: expect.stringContaining('ws://'), + }) + expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`) - const examplePage = listJson.find((t) => t.url?.includes('example.com')) - expect(examplePage).toBeDefined() - expect(examplePage).toMatchObject({ - id: expect.any(String), - type: 'page', - url: expect.stringContaining('example.com'), - webSocketDebuggerUrl: expect.stringContaining('ws://'), - }) + // Test /json/version/ (trailing slash) + const versionSlashRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version/`) + expect(versionSlashRes.status).toBe(200) - // Test /json (alias for /json/list) - const jsonRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json`) - expect(jsonRes.status).toBe(200) - const jsonData = await jsonRes.json() - expect(Array.isArray(jsonData)).toBe(true) + // Test /json/list + const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`) + expect(listRes.status).toBe(200) + const listJson = (await listRes.json()) as Array<{ url?: string }> + expect(Array.isArray(listJson)).toBe(true) + expect(listJson.length).toBeGreaterThan(0) - // Test PUT method (Chrome 66+ prefers PUT) - const putRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`, { method: 'PUT' }) - expect(putRes.status).toBe(200) + const examplePage = listJson.find((t) => t.url?.includes('example.com')) + expect(examplePage).toBeDefined() + expect(examplePage).toMatchObject({ + id: expect.any(String), + type: 'page', + url: expect.stringContaining('example.com'), + webSocketDebuggerUrl: expect.stringContaining('ws://'), + }) - await page.close() - }, 60000) + // Test /json (alias for /json/list) + const jsonRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json`) + expect(jsonRes.status).toBe(200) + const jsonData = await jsonRes.json() + expect(Array.isArray(jsonData)).toBe(true) - // Skip: chrome.tabCapture.getMediaStreamId() requires activeTab permission - it.skip('should record screen with navigation using chrome.tabCapture', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - const path = await import('node:path') - const fs = await import('node:fs') + // Test PUT method (Chrome 66+ prefers PUT) + const putRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`, { method: 'PUT' }) + expect(putRes.status).toBe(200) - const recordingPage = await browserContext.newPage() - await recordingPage.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' }) - await recordingPage.bringToFront() - - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 200)) + await page.close() + }, 60000) - const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.mp4') - if (!fs.existsSync(path.dirname(outputPath))) { - fs.mkdirSync(path.dirname(outputPath), { recursive: true }) - } + // Skip: chrome.tabCapture.getMediaStreamId() requires activeTab permission + it.skip('should record screen with navigation using chrome.tabCapture', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + const path = await import('node:path') + const fs = await import('node:fs') - const { startRecording, stopRecording, isRecording } = await import('./screen-recording.js') - - const startResult = await startRecording({ - page: recordingPage, - outputPath, - frameRate: 30, - audio: false, - videoBitsPerSecond: 1500000, - relayPort: TEST_PORT, - }) - expect(startResult.isRecording).toBe(true) + const recordingPage = await browserContext.newPage() + await recordingPage.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' }) + await recordingPage.bringToFront() - await recordingPage.locator('.titleline a').first().click() - await recordingPage.waitForLoadState('domcontentloaded') - await new Promise(r => setTimeout(r, 500)) - - await recordingPage.goBack() - await recordingPage.waitForLoadState('domcontentloaded') + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 200)) - const status = await isRecording({ page: recordingPage, relayPort: TEST_PORT }) - expect(status.isRecording).toBe(true) + const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.mp4') + if (!fs.existsSync(path.dirname(outputPath))) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + } - const stopResult = await stopRecording({ page: recordingPage, relayPort: TEST_PORT }) - expect(stopResult.path).toBe(outputPath) - expect(stopResult.size).toBeGreaterThan(10000) - expect(fs.existsSync(outputPath)).toBe(true) + const { startRecording, stopRecording, isRecording } = await import('./screen-recording.js') - await recordingPage.close() - fs.unlinkSync(outputPath) - }, 60000) + const startResult = await startRecording({ + page: recordingPage, + outputPath, + frameRate: 30, + audio: false, + videoBitsPerSecond: 1500000, + relayPort: TEST_PORT, + }) + expect(startResult.isRecording).toBe(true) + + await recordingPage.locator('.titleline a').first().click() + await recordingPage.waitForLoadState('domcontentloaded') + await new Promise((r) => setTimeout(r, 500)) + + await recordingPage.goBack() + await recordingPage.waitForLoadState('domcontentloaded') + + const status = await isRecording({ page: recordingPage, relayPort: TEST_PORT }) + expect(status.isRecording).toBe(true) + + const stopResult = await stopRecording({ page: recordingPage, relayPort: TEST_PORT }) + expect(stopResult.path).toBe(outputPath) + expect(stopResult.size).toBeGreaterThan(10000) + expect(fs.existsSync(outputPath)).toBe(true) + + await recordingPage.close() + fs.unlinkSync(outputPath) + }, 60000) }) diff --git a/playwriter/src/relay-session.test.ts b/playwriter/src/relay-session.test.ts index 66073f9..44fa66f 100644 --- a/playwriter/src/relay-session.test.ts +++ b/playwriter/src/relay-session.test.ts @@ -6,7 +6,16 @@ import { getCDPSessionForPage } from './cdp-session.js' import { Debugger } from './debugger.js' import { Editor } from './editor.js' import { PlaywrightExecutor } from './executor.js' -import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, createSseServer, safeCloseCDPBrowser, type TestContext, withTimeout, js } from './test-utils.js' +import { + setupTestContext, + cleanupTestContext, + getExtensionServiceWorker, + createSseServer, + safeCloseCDPBrowser, + type TestContext, + withTimeout, + js, +} from './test-utils.js' import './test-declarations.js' const TEST_PORT = 19993 @@ -14,121 +23,119 @@ const TEST_PORT = 19993 // --- CDP Session Tests --- describe('CDP Session Tests', () => { - let testCtx: TestContext | null = null + let testCtx: TestContext | null = null - beforeAll(async () => { - testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-cdp-test-', toggleExtension: true }) + beforeAll(async () => { + testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-cdp-test-', toggleExtension: true }) - const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext) - await serviceWorker.evaluate(async () => { - await globalThis.disconnectEverything() - }) - await new Promise(r => setTimeout(r, 100)) - }, 600000) + const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext) + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) + await new Promise((r) => setTimeout(r, 100)) + }, 600000) - afterAll(async () => { - await cleanupTestContext(testCtx) - testCtx = null + afterAll(async () => { + await cleanupTestContext(testCtx) + testCtx = null + }) + + const getBrowserContext = () => { + if (!testCtx?.browserContext) throw new Error('Browser not initialized') + return testCtx.browserContext + } + + it('should use Debugger class to set breakpoints and inspect variables', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + const testUrl = 'https://example.com/?test=debugger-variables' + await page.goto(testUrl) + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) + + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page }) + const dbg = new Debugger({ cdp: cdpSession }) + + await dbg.enable() + + expect(dbg.isPaused()).toBe(false) + + const pausedPromise = new Promise((resolve) => { + cdpSession.on('Debugger.paused', () => { + resolve() + }) }) - const getBrowserContext = () => { - if (!testCtx?.browserContext) throw new Error('Browser not initialized') - return testCtx.browserContext - } - - - - it('should use Debugger class to set breakpoints and inspect variables', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) - - const page = await browserContext.newPage() - const testUrl = 'https://example.com/?test=debugger-variables' - await page.goto(testUrl) - await page.bringToFront() - - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) - - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page }) - const dbg = new Debugger({ cdp: cdpSession }) - - await dbg.enable() - - expect(dbg.isPaused()).toBe(false) - - const pausedPromise = new Promise((resolve) => { - cdpSession.on('Debugger.paused', () => { - resolve() - }) - }) - - const evalPromise = cdpSession.send('Runtime.evaluate', { - expression: `(function testFunction() { + const evalPromise = cdpSession.send('Runtime.evaluate', { + expression: `(function testFunction() { const localVar = 'hello'; const numberVar = 42; debugger; return localVar + numberVar; - })()` - }) + })()`, + }) - await Promise.race([ - pausedPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)) - ]) + await Promise.race([ + pausedPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)), + ]) - expect(dbg.isPaused()).toBe(true) + expect(dbg.isPaused()).toBe(true) - const location = await dbg.getLocation() - expect(location.callstack[0].functionName).toBe('testFunction') - expect(location.sourceContext).toContain('debugger') + const location = await dbg.getLocation() + expect(location.callstack[0].functionName).toBe('testFunction') + expect(location.sourceContext).toContain('debugger') - const vars = await dbg.inspectLocalVariables() - expect(vars).toMatchInlineSnapshot(` + const vars = await dbg.inspectLocalVariables() + expect(vars).toMatchInlineSnapshot(` { "localVar": "hello", "numberVar": 42, } `) - const evalResult = await dbg.evaluate({ expression: 'localVar + " world"' }) - expect(evalResult.value).toBe('hello world') + const evalResult = await dbg.evaluate({ expression: 'localVar + " world"' }) + expect(evalResult.value).toBe('hello world') - await dbg.resume() - await new Promise(r => setTimeout(r, 100)) - expect(dbg.isPaused()).toBe(false) - await evalPromise + await dbg.resume() + await new Promise((r) => setTimeout(r, 100)) + expect(dbg.isPaused()).toBe(false) + await evalPromise - await cdpSession.detach() - await page.close() - }, 60000) + await cdpSession.detach() + await page.close() + }, 60000) - it('should reuse cached CDP session and close on page close', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should reuse cached CDP session and close on page close', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - const testUrl = 'https://example.com/?test=debugger-step' - await page.goto(testUrl) - await page.bringToFront() + const page = await browserContext.newPage() + const testUrl = 'https://example.com/?test=debugger-step' + await page.goto(testUrl) + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const executor = new PlaywrightExecutor({ - cdpConfig: { port: TEST_PORT }, - logger: { - log: () => {}, - error: () => {}, - }, - }) + const executor = new PlaywrightExecutor({ + cdpConfig: { port: TEST_PORT }, + logger: { + log: () => {}, + error: () => {}, + }, + }) - const result = await executor.execute(js` + const result = await executor.execute(js` const sessionA = await getCDPSession({ page }) const sessionB = await getCDPSession({ page }) await sessionA.send('Runtime.evaluate', { expression: '1 + 1', returnByValue: true }) @@ -136,52 +143,55 @@ describe('CDP Session Tests', () => { return evalResult.result.value `) - expect(result.isError).toBe(false) - expect(result.text).toContain('[return value] 4') + expect(result.isError).toBe(false) + expect(result.text).toContain('[return value] 4') - await page.close() - }, 60000) + await page.close() + }, 60000) - it('should list scripts with Debugger class', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should list scripts with Debugger class', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - // Use a page with actual script URLs (not just inline scripts) - const page = await browserContext.newPage() - await page.goto('https://news.ycombinator.com/') - await page.bringToFront() + // Use a page with actual script URLs (not just inline scripts) + const page = await browserContext.newPage() + await page.goto('https://news.ycombinator.com/') + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('news.ycombinator')) - expect(cdpPage).toBeDefined() + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url().includes('news.ycombinator')) + expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) - const dbg = new Debugger({ cdp: cdpSession }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) + const dbg = new Debugger({ cdp: cdpSession }) - await dbg.enable() + await dbg.enable() - const scripts = await dbg.listScripts() - expect(scripts.length).toBeGreaterThan(0) - expect(scripts[0]).toHaveProperty('scriptId') - expect(scripts[0]).toHaveProperty('url') + const scripts = await dbg.listScripts() + expect(scripts.length).toBeGreaterThan(0) + expect(scripts[0]).toHaveProperty('scriptId') + expect(scripts[0]).toHaveProperty('url') - await cdpSession.detach() - await browser.close() - await page.close() - }, 60000) + await cdpSession.detach() + await browser.close() + await page.close() + }, 60000) - it('should manage breakpoints with Debugger class', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should manage breakpoints with Debugger class', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - await page.setContent(` + const page = await browserContext.newPage() + await page.setContent(` @@ -189,62 +199,62 @@ describe('CDP Session Tests', () => { `) - await page.bringToFront() + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page }) - const dbg = new Debugger({ cdp: cdpSession }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page }) + const dbg = new Debugger({ cdp: cdpSession }) - await dbg.enable() + await dbg.enable() - expect(dbg.listBreakpoints()).toHaveLength(0) + expect(dbg.listBreakpoints()).toHaveLength(0) - const bpId = await dbg.setBreakpoint({ file: 'https://example.com/test.js', line: 1 }) - expect(typeof bpId).toBe('string') - expect(dbg.listBreakpoints()).toHaveLength(1) - expect(dbg.listBreakpoints()[0]).toMatchObject({ - id: bpId, - file: 'https://example.com/test.js', - line: 1, - }) + const bpId = await dbg.setBreakpoint({ file: 'https://example.com/test.js', line: 1 }) + expect(typeof bpId).toBe('string') + expect(dbg.listBreakpoints()).toHaveLength(1) + expect(dbg.listBreakpoints()[0]).toMatchObject({ + id: bpId, + file: 'https://example.com/test.js', + line: 1, + }) - await dbg.deleteBreakpoint({ breakpointId: bpId }) - expect(dbg.listBreakpoints()).toHaveLength(0) + await dbg.deleteBreakpoint({ breakpointId: bpId }) + expect(dbg.listBreakpoints()).toHaveLength(0) - await cdpSession.detach() - await page.close() - }, 60000) + await cdpSession.detach() + await page.close() + }, 60000) - it('should step through code with Debugger class', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should step through code with Debugger class', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - await page.goto('https://example.com/') - await page.bringToFront() + const page = await browserContext.newPage() + await page.goto('https://example.com/') + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page }) - const dbg = new Debugger({ cdp: cdpSession }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page }) + const dbg = new Debugger({ cdp: cdpSession }) - await dbg.enable() + await dbg.enable() - const pausedPromise = new Promise((resolve) => { - cdpSession.on('Debugger.paused', () => resolve()) - }) + const pausedPromise = new Promise((resolve) => { + cdpSession.on('Debugger.paused', () => resolve()) + }) - const evalPromise = cdpSession.send('Runtime.evaluate', { - expression: `(function outer() { + const evalPromise = cdpSession.send('Runtime.evaluate', { + expression: `(function outer() { function inner() { const x = 1; debugger; @@ -253,63 +263,63 @@ describe('CDP Session Tests', () => { } const result = inner(); return result; - })()` - }) + })()`, + }) - await pausedPromise - expect(dbg.isPaused()).toBe(true) + await pausedPromise + expect(dbg.isPaused()).toBe(true) - const location1 = await dbg.getLocation() - expect(location1.callstack.length).toBeGreaterThanOrEqual(2) - expect(location1.callstack[0].functionName).toBe('inner') - expect(location1.callstack[1].functionName).toBe('outer') + const location1 = await dbg.getLocation() + expect(location1.callstack.length).toBeGreaterThanOrEqual(2) + expect(location1.callstack[0].functionName).toBe('inner') + expect(location1.callstack[1].functionName).toBe('outer') - const stepOverPromise = new Promise((resolve) => { - cdpSession.on('Debugger.paused', () => resolve()) - }) - await dbg.stepOver() - await stepOverPromise + const stepOverPromise = new Promise((resolve) => { + cdpSession.on('Debugger.paused', () => resolve()) + }) + await dbg.stepOver() + await stepOverPromise - const location2 = await dbg.getLocation() - expect(location2.lineNumber).toBeGreaterThan(location1.lineNumber) + const location2 = await dbg.getLocation() + expect(location2.lineNumber).toBeGreaterThan(location1.lineNumber) - const stepOutPromise = new Promise((resolve) => { - cdpSession.on('Debugger.paused', () => resolve()) - }) - await dbg.stepOut() - await stepOutPromise + const stepOutPromise = new Promise((resolve) => { + cdpSession.on('Debugger.paused', () => resolve()) + }) + await dbg.stepOut() + await stepOutPromise - const location3 = await dbg.getLocation() - expect(location3.callstack[0].functionName).toBe('outer') + const location3 = await dbg.getLocation() + expect(location3.callstack[0].functionName).toBe('outer') - await dbg.resume() - await evalPromise + await dbg.resume() + await evalPromise - await cdpSession.detach() - await page.close() - }, 60000) + await cdpSession.detach() + await page.close() + }, 60000) - it('should profile JavaScript execution using CDP Profiler', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should profile JavaScript execution using CDP Profiler', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - const testUrl = 'https://example.com/?test=debugger-profiler' - await page.goto(testUrl) - await page.bringToFront() + const page = await browserContext.newPage() + const testUrl = 'https://example.com/?test=debugger-profiler' + await page.goto(testUrl) + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page }) - await cdpSession.send('Profiler.enable') - await cdpSession.send('Profiler.start') + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page }) + await cdpSession.send('Profiler.enable') + await cdpSession.send('Profiler.start') - await cdpSession.send('Runtime.evaluate', { - expression: `(() => { + await cdpSession.send('Runtime.evaluate', { + expression: `(() => { function fibonacci(n) { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) @@ -320,116 +330,122 @@ describe('CDP Session Tests', () => { for (let i = 0; i < 1000; i++) { document.querySelectorAll('*') } - })()` - }) + })()`, + }) - const stopResult = await cdpSession.send('Profiler.stop') - const profile = stopResult.profile + const stopResult = await cdpSession.send('Profiler.stop') + const profile = stopResult.profile - const functionNames = profile.nodes - .map(n => n.callFrame.functionName) - .filter(name => name && name.length > 0) - .slice(0, 10) + const functionNames = profile.nodes + .map((n) => n.callFrame.functionName) + .filter((name) => name && name.length > 0) + .slice(0, 10) - expect(profile.nodes.length).toBeGreaterThan(0) - expect(profile.endTime - profile.startTime).toBeGreaterThan(0) - expect(functionNames.every((name) => typeof name === 'string')).toBe(true) + expect(profile.nodes.length).toBeGreaterThan(0) + expect(profile.endTime - profile.startTime).toBeGreaterThan(0) + expect(functionNames.every((name) => typeof name === 'string')).toBe(true) - await cdpSession.send('Profiler.disable') - await cdpSession.detach() - await page.close() - }, 60000) + await cdpSession.send('Profiler.disable') + await cdpSession.detach() + await page.close() + }, 60000) - it('should update Target.getTargets URL after page navigation', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should update Target.getTargets URL after page navigation', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - await serviceWorker.evaluate(async () => { - await globalThis.disconnectEverything() - }) + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) - const page = await browserContext.newPage() - await page.goto('https://example.com/') - await page.bringToFront() + const page = await browserContext.newPage() + await page.goto('https://example.com/') + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) - expect(cdpPage).toBeDefined() + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) - const initialTargets = await cdpSession.send('Target.getTargets') - const initialPageTarget = initialTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('example.com')) - expect(initialPageTarget?.url).toBe('https://example.com/') + const initialTargets = await cdpSession.send('Target.getTargets') + const initialPageTarget = initialTargets.targetInfos.find((t) => t.type === 'page' && t.url.includes('example.com')) + expect(initialPageTarget?.url).toBe('https://example.com/') - await cdpPage!.goto('https://example.org/', { waitUntil: 'domcontentloaded' }) - await new Promise(r => setTimeout(r, 100)) + await cdpPage!.goto('https://example.org/', { waitUntil: 'domcontentloaded' }) + await new Promise((r) => setTimeout(r, 100)) - const afterNavTargets = await cdpSession.send('Target.getTargets') - const allPageTargets = afterNavTargets.targetInfos.filter(t => t.type === 'page') + const afterNavTargets = await cdpSession.send('Target.getTargets') + const allPageTargets = afterNavTargets.targetInfos.filter((t) => t.type === 'page') - const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank') - expect(aboutBlankTargets).toHaveLength(0) + const aboutBlankTargets = allPageTargets.filter((t) => t.url === 'about:blank') + expect(aboutBlankTargets).toHaveLength(0) - const exampleComTargets = allPageTargets.filter(t => t.url.includes('example.com')) - expect(exampleComTargets).toHaveLength(0) + const exampleComTargets = allPageTargets.filter((t) => t.url.includes('example.com')) + expect(exampleComTargets).toHaveLength(0) - const exampleOrgTargets = allPageTargets.filter(t => t.url.includes('example.org')) - expect(exampleOrgTargets).toHaveLength(1) + const exampleOrgTargets = allPageTargets.filter((t) => t.url.includes('example.org')) + expect(exampleOrgTargets).toHaveLength(1) - await cdpSession.detach() - await browser.close() - await page.close() - }, 60000) + await cdpSession.detach() + await browser.close() + await page.close() + }, 60000) - it('should return correct targets for multiple pages via Target.getTargets', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should return correct targets for multiple pages via Target.getTargets', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - await serviceWorker.evaluate(async () => { - await globalThis.disconnectEverything() - }) + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) - const page1 = await browserContext.newPage() - await page1.goto('https://example.com/') - await page1.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + const page1 = await browserContext.newPage() + await page1.goto('https://example.com/') + await page1.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - const page2 = await browserContext.newPage() - await page2.goto('https://example.org/') - await page2.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) + const page2 = await browserContext.newPage() + await page2.goto('https://example.org/') + await page2.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) - await new Promise(r => setTimeout(r, 100)) + await new Promise((r) => setTimeout(r, 100)) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) - expect(cdpPage).toBeDefined() + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) - const { targetInfos } = await cdpSession.send('Target.getTargets') - const allPageTargets = targetInfos.filter(t => t.type === 'page') + const { targetInfos } = await cdpSession.send('Target.getTargets') + const allPageTargets = targetInfos.filter((t) => t.type === 'page') - const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank') - expect(aboutBlankTargets).toHaveLength(0) + const aboutBlankTargets = allPageTargets.filter((t) => t.url === 'about:blank') + expect(aboutBlankTargets).toHaveLength(0) - const pageTargets = allPageTargets - .map(t => ({ type: t.type, url: t.url })) - .sort((a, b) => a.url.localeCompare(b.url)) + const pageTargets = allPageTargets + .map((t) => ({ type: t.type, url: t.url })) + .sort((a, b) => a.url.localeCompare(b.url)) - expect(pageTargets).toMatchInlineSnapshot(` + expect(pageTargets).toMatchInlineSnapshot(` [ { "type": "page", @@ -442,163 +458,172 @@ describe('CDP Session Tests', () => { ] `) - await cdpSession.detach() - await browser.close() - await page1.close() - await page2.close() - }, 60000) + await cdpSession.detach() + await browser.close() + await page1.close() + await page2.close() + }, 60000) - it('should create CDP session for page after navigation', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should create CDP session for page after navigation', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - await page.goto('https://example.com/') - await page.bringToFront() + const page = await browserContext.newPage() + await page.goto('https://example.com/') + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - await page.goto('https://example.org/', { waitUntil: 'domcontentloaded' }) - await new Promise(r => setTimeout(r, 100)) + await page.goto('https://example.org/', { waitUntil: 'domcontentloaded' }) + await new Promise((r) => setTimeout(r, 100)) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.org')) - expect(cdpPage).toBeDefined() + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url().includes('example.org')) + expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) - const evalResult = await cdpSession.send('Runtime.evaluate', { - expression: 'document.title', - returnByValue: true, - }) - expect(evalResult.result.value).toContain('Example Domain') + const evalResult = await cdpSession.send('Runtime.evaluate', { + expression: 'document.title', + returnByValue: true, + }) + expect(evalResult.result.value).toContain('Example Domain') - await cdpSession.detach() - await browser.close() - await page.close() - }, 60000) + await cdpSession.detach() + await browser.close() + await page.close() + }, 60000) - it('should maintain CDP session functionality after page URL change', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should maintain CDP session functionality after page URL change', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - const initialUrl = 'https://example.com/' - await page.goto(initialUrl) - await page.bringToFront() + const page = await browserContext.newPage() + const initialUrl = 'https://example.com/' + await page.goto(initialUrl) + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) - expect(cdpPage).toBeDefined() + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) - const initialEvalResult = await cdpSession.send('Runtime.evaluate', { - expression: 'document.title', - returnByValue: true, - }) - expect(initialEvalResult.result.value).toBe('Example Domain') + const initialEvalResult = await cdpSession.send('Runtime.evaluate', { + expression: 'document.title', + returnByValue: true, + }) + expect(initialEvalResult.result.value).toBe('Example Domain') - const newUrl = 'https://example.org/' - await cdpPage!.goto(newUrl, { waitUntil: 'domcontentloaded' }) + const newUrl = 'https://example.org/' + await cdpPage!.goto(newUrl, { waitUntil: 'domcontentloaded' }) - expect(cdpPage!.url()).toBe(newUrl) + expect(cdpPage!.url()).toBe(newUrl) - const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics') - expect(layoutMetrics.cssVisualViewport).toBeDefined() - expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0) + const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics') + expect(layoutMetrics.cssVisualViewport).toBeDefined() + expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0) - const afterNavEvalResult = await cdpSession.send('Runtime.evaluate', { - expression: 'document.title', - returnByValue: true, - }) - expect(afterNavEvalResult.result.value).toContain('Example Domain') + const afterNavEvalResult = await cdpSession.send('Runtime.evaluate', { + expression: 'document.title', + returnByValue: true, + }) + expect(afterNavEvalResult.result.value).toContain('Example Domain') - const locationResult = await cdpSession.send('Runtime.evaluate', { - expression: 'window.location.href', - returnByValue: true, - }) - expect(locationResult.result.value).toBe(newUrl) + const locationResult = await cdpSession.send('Runtime.evaluate', { + expression: 'window.location.href', + returnByValue: true, + }) + expect(locationResult.result.value).toBe(newUrl) - await cdpSession.detach() - await browser.close() - await page.close() - }, 60000) + await cdpSession.detach() + await browser.close() + await page.close() + }, 60000) - it('should pause on all exceptions with setPauseOnExceptions', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should pause on all exceptions with setPauseOnExceptions', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - await page.goto('https://example.com/') - await page.bringToFront() + const page = await browserContext.newPage() + await page.goto('https://example.com/') + await page.bringToFront() - await serviceWorker.evaluate(async () => { - await globalThis.toggleExtensionForActiveTab() - }) - await new Promise(r => setTimeout(r, 100)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise((r) => setTimeout(r, 100)) - const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) - const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) - expect(cdpPage).toBeDefined() + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser + .contexts()[0] + .pages() + .find((p) => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl({ port: TEST_PORT }) - const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) - const dbg = new Debugger({ cdp: cdpSession }) + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page: cdpPage! }) + const dbg = new Debugger({ cdp: cdpSession }) - await dbg.enable() - await dbg.setPauseOnExceptions({ state: 'all' }) + await dbg.enable() + await dbg.setPauseOnExceptions({ state: 'all' }) - const pausedPromise = new Promise((resolve) => { - cdpSession.on('Debugger.paused', () => resolve()) - }) + const pausedPromise = new Promise((resolve) => { + cdpSession.on('Debugger.paused', () => resolve()) + }) - const evalPromise = cdpSession.send('Runtime.evaluate', { - expression: `(function() { + const evalPromise = cdpSession.send('Runtime.evaluate', { + expression: `(function() { try { throw new Error('Caught test error'); } catch (e) { // caught but should still pause with state 'all' } - })()` - }) + })()`, + }) - await Promise.race([ - pausedPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)) - ]) + await Promise.race([ + pausedPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)), + ]) - expect(dbg.isPaused()).toBe(true) + expect(dbg.isPaused()).toBe(true) - const location = await dbg.getLocation() - expect(location.sourceContext).toContain('throw') + const location = await dbg.getLocation() + expect(location.sourceContext).toContain('throw') - await dbg.resume() - await evalPromise + await dbg.resume() + await evalPromise - await dbg.setPauseOnExceptions({ state: 'none' }) + await dbg.setPauseOnExceptions({ state: 'none' }) - await cdpSession.detach() - await browser.close() - await page.close() - }, 60000) + await cdpSession.detach() + await browser.close() + await page.close() + }, 60000) - it('should inspect local and global variables with inline snapshots', async () => { - const browserContext = getBrowserContext() - const serviceWorker = await getExtensionServiceWorker(browserContext) + it('should inspect local and global variables with inline snapshots', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) - const page = await browserContext.newPage() - await page.setContent(` + const page = await browserContext.newPage() + await page.setContent(`