diff --git a/.gitignore b/.gitignore index 68b4648..5a1ebc0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ pnpm-debug.log* .tmp/ tmp/ *.tmp +PULL_REQUEST.md diff --git a/README.md b/README.md index 6e106f1..3935b5f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,19 @@ This is not a standalone browser testing framework and does not host a daemon. I 中文文档: [README.zh-CN.md](./README.zh-CN.md) +## Tools + +### `browser_execute` +Run arbitrary JavaScript in a visible Chrome browser via CDP. Supports console capture, screenshot collection, and workspace imports. + +### `web_search` +Search Google using a visible Chrome browser via CDP. Extracts up to 20 visible result links along with a text snapshot of the search results page as structured markdown. Handles Google consent dialogs automatically. + +### `web_fetch` +Fetch and extract page content from any URL using a visible Chrome browser. Dynamically scrolls to trigger lazy loading, then extracts structured markdown from semantic HTML elements (headings, paragraphs, lists, code blocks, blockquotes). Truncates content at 900KB. + +All three tools share the same Chrome profile for session continuity and can use `profileDir` to reuse an existing Chrome session. + ## Quick Start ### 1. Install the extension @@ -45,7 +58,10 @@ Pi will connect to an authorized Chromium browser, drive the page, inspect the r ## What it gives Pi -- `browser_execute`: Pi-callable tool name. +- `browser_execute`: Pi-callable tool for running arbitrary JavaScript in the browser. +- `web_search`: Pi-callable tool for Google search via a visible Chrome browser. +- `web_fetch`: Pi-callable tool for fetching and extracting page content as structured markdown. +- `session`: persistent CDP session; multiple calls in the same Pi session reuse browser state. - `session`: persistent CDP session; multiple calls in the same Pi session reuse browser state. - `console`: captures `log`, `error`, `warn`, `info`, and `debug` output and streams it back in the tool result. - Screenshot collection: successful `Page.captureScreenshot` calls are automatically converted into Pi image content. @@ -81,6 +97,28 @@ Do not use this for: - Pure unit testing; Playwright or Vitest is more direct. - Untrusted pages or untrusted CDP endpoints. CDP can control the connected browser, so only connect to browsers you authorize. +## Auto-launching Chrome + +When you pass a `profileDir` to `browser_execute`, the extension can automatically launch Chrome if no browser with remote debugging is already connected. + +The tool accepts a `profileDir` parameter: + +```javascript +await session.connect({ + profileDir: '/home/user/.ds4/browser', + launchBrowser: true, +}); +``` + +The extension will: +1. Create the profile directory if it doesn't exist +2. Launch Chrome with `--remote-debugging-port=0 --user-data-dir= --no-first-run --no-default-browser-check` +3. Wait for Chrome to write `DevToolsActivePort` and connect + +If the directory already has a Chrome instance running with remote debugging, it reuses that instance. + +To customize the Chrome executable path, set `BROWSER_PATH` or `CHROME_BIN` environment variable. + ## Configuration Environment variables: @@ -103,7 +141,7 @@ npm run typecheck npm test ``` -Current tests cover session reuse/isolation, workspace imports, console streaming, timeout handling, screenshot collection, CDP target filtering, active `sessionId` routing, and Pi image content conversion. +Current tests cover session reuse/isolation, workspace imports, console streaming, timeout handling, screenshot collection, CDP target filtering, active `sessionId` routing, Pi image content conversion, web search and fetch logic, Google consent handling, dynamic scrolling, and Pi extension adapter integration. ## Acknowledgements diff --git a/README.zh-CN.md b/README.zh-CN.md index 163d5cf..2b399d4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,9 +43,24 @@ pi install . Pi 会连接已授权的 Chromium 浏览器,打开页面、读取结果,并把截图附在回复里。 +## 工具 + +### `browser_execute` +通过 CDP 在可见的 Chrome 浏览器中执行任意 JavaScript。支持 console 捕获、截图收集和 workspace 导入。 + +### `web_search` +通过可见的 Chrome 浏览器搜索 Google。提取最多 20 条可见结果链接及搜索结果页面的文本摘要,以结构化 markdown 返回。自动处理 Google 同意弹窗。 + +### `web_fetch` +通过可见的 Chrome 浏览器抓取任意 URL 的页面内容。自动滚动触发懒加载,然后从语义化 HTML 元素(标题、段落、列表、代码块、引用块)中提取结构化 markdown。900KB 截断。 + +三个工具共享同一个 Chrome 配置用于 session 连续性,都可以使用 `profileDir` 复用已有的 Chrome session。 + ## 给 Pi 提供什么 - `browser_execute`:Pi 可调用的工具名。 +- `web_search`:通过可见 Chrome 浏览器搜索 Google 的工具。 +- `web_fetch`:抓取页面内容并提取结构化 markdown 的工具。 - `session`:持久 CDP Session,同一个 Pi session 内多次调用会复用状态。 - `console`:捕获 `log/error/warn/info/debug`,作为工具输出流式返回。 - 截图收集:成功的 `Page.captureScreenshot` 会自动转成 Pi image content。 @@ -103,7 +118,7 @@ npm run typecheck npm test ``` -当前测试覆盖包括:session 复用/隔离、workspace import、console streaming、timeout、screenshot 收集、CDP target 过滤、active sessionId 路由、Pi image content 转换。 +当前测试覆盖包括:session 复用/隔离、workspace import、console streaming、timeout、screenshot 收集、CDP target 过滤、active sessionId 路由、Pi image content 转换、web search 和 fetch 逻辑、Google 同意弹窗处理、动态滚动、Pi extension adapter 集成。 ## 致谢 diff --git a/extensions/browser-execute-web.ts b/extensions/browser-execute-web.ts new file mode 100644 index 0000000..403ed05 --- /dev/null +++ b/extensions/browser-execute-web.ts @@ -0,0 +1,188 @@ +import path from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { Component } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { executeBrowserCode, type BrowserExecuteParameters } from "../src/browser-execute.js"; +import { webSearch, webFetch, type WebSearchOptions, type WebFetchOptions } from "../src/web-fetch.js"; + +// ============================================================================ +// Web Search Tool +// ============================================================================ + +const WebSearchParams = Type.Object({ + query: Type.String({ + description: "The search query to look up on Google. Examples: 'typescript async await', 'latest react patterns 2024', 'pi coding agent documentation'.", + }), + profileDir: Type.Optional( + Type.String({ + description: "Chrome user-data directory. If provided, connects to (or launches) Chrome with --user-data-dir=profileDir. E.g. /home/user/.ds4/browser", + }), + ), +}); + +// ============================================================================ +// Web Fetch Tool +// ============================================================================ + +const WebFetchParams = Type.Object({ + url: Type.String({ + description: "The full URL to fetch and extract. Examples: 'https://example.com', 'https://github.com/citrolabs/pi-browser-cdp-extension'.", + }), + profileDir: Type.Optional( + Type.String({ + description: "Chrome user-data directory. If provided, connects to (or launches) Chrome with --user-data-dir=profileDir. E.g. /home/user/.ds4/browser", + }), + ), +}); + +// ============================================================================ +// Extension: Web Search + Web Fetch +// ============================================================================ + +function workspaceDirOf(cwd: string): string { + return path.join(cwd, ".pi", "browser-execute-workspace"); +} + +export default function browserExecuteWebExtension(pi: ExtensionAPI) { + // --- web_search tool --- + pi.registerTool({ + name: "web_search", + label: "Web Search", + description: `Search Google and return structured results as markdown. Always prefer this tool over curl for web searches. + +This tool queries Google Search and returns up to 20 visible result links with their text and URLs, plus a text snapshot of the results page. + +Use this tool instead of curl when: +- The user asks to search the web or find information +- You need to see Google search results in a structured format +- You want to discover URLs before reading them with web_fetch + +Returns structured markdown with links and a text snapshot of results.`, + promptSnippet: "Search Google and return structured results as markdown. Use instead of curl for web searches.", + promptGuidelines: [ + "Use web_search (NOT curl) whenever the user asks to search the web or find information online.", + "Pass a descriptive, specific query for best results.", + "Use web_fetch to read the full content of URLs discovered through search.", + "web_search and web_fetch share the same browser session for continuity.", + ], + parameters: WebSearchParams, + renderCall: (args: { query: string }, ..._rest: any[]) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Text } = require("@earendil-works/pi-tui"); + const text = new Text(0, 0); + const q = args.query?.trim() || "..."; + text.setText(`Web search: ${q}`); + return text; + }, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const options: WebSearchOptions = { + query: params.query, + profileDir: params.profileDir, + onProgress: (status) => { + _onUpdate?.({ + content: [{ type: "text" as const, text: status }], + details: { status }, + }); + }, + }; + + const result = await webSearch(options); + + if ("error" in result) { + return { + content: [ + { type: "text" as const, text: `Error: web_search failed:\n${result.message}` }, + ], + details: { + query: options.query, + error: result.message, + }, + }; + } + + return { + content: [ + { type: "text" as const, text: result.markdown }, + ], + details: { + query: options.query, + searchUrl: result.searchUrl, + linkCount: result.linkCount, + }, + }; + }, + }); + + // --- web_fetch tool --- + pi.registerTool({ + name: "web_fetch", + label: "Web Fetch", + description: `Fetch a URL and extract its content as structured markdown. Always prefer this tool over curl for fetching web pages. + +This tool fetches any URL and returns structured markdown including the page title, content from semantic HTML elements (headings, paragraphs, lists, code, blockquotes), and visible links. + +Use this tool instead of curl when: +- The user asks to read, fetch, or get the content of a webpage +- You need structured markdown output instead of raw HTML +- The page uses JavaScript rendering or lazy loading +- You want to avoid parsing HTML yourself + +Returns clean structured markdown, not raw HTML or curl output.`, + promptSnippet: "Fetch any URL and extract content as structured markdown. Use instead of curl for web pages.", + promptGuidelines: [ + "Use web_fetch (NOT curl) whenever the user asks to read, fetch, or get the content of a webpage.", + "Pass the complete URL including the protocol (https://).", + "For dynamic pages with lazy loading, this tool automatically scrolls to load more content.", + "web_fetch and web_search share the same browser session for continuity.", + ], + parameters: WebFetchParams, + renderCall: (args: { url: string }, ..._rest: any[]) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Text } = require("@earendil-works/pi-tui"); + const text = new Text(0, 0); + const u = args.url?.trim() || "..."; + text.setText(`Web fetch: ${u}`); + return text; + }, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const options: WebFetchOptions = { + url: params.url, + profileDir: params.profileDir, + onProgress: (status) => { + _onUpdate?.({ + content: [{ type: "text" as const, text: status }], + details: { status }, + }); + }, + }; + + const result = await webFetch(options); + + if ("error" in result) { + return { + content: [ + { type: "text" as const, text: `Error: web_fetch failed:\n${result.message}` }, + ], + details: { + url: options.url, + error: result.message, + }, + }; + } + + return { + content: [ + { type: "text" as const, text: result.markdown }, + ], + details: { + url: options.url, + finalUrl: result.finalUrl, + title: result.title, + linkCount: result.linkCount, + lineCount: result.lineCount, + scrolled: result.scrolled, + }, + }; + }, + }); +} diff --git a/extensions/browser-execute.ts b/extensions/browser-execute.ts index 7349e5e..10fdb0b 100644 --- a/extensions/browser-execute.ts +++ b/extensions/browser-execute.ts @@ -3,6 +3,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { executeBrowserCode, type BrowserExecuteParameters } from "../src/browser-execute.js"; +console.error("[DEBUG] browser-execute.ts: Extension loading..."); + const MAX_METADATA_LENGTH = 30_000; const BrowserExecuteParams = Type.Object({ @@ -14,6 +16,16 @@ const BrowserExecuteParams = Type.Object({ description: "Clear, concise description of what this snippet does in 3-7 words. Examples: Connect to local Chrome; Scrape product titles; Screenshot homepage.", }), + profileDir: Type.Optional( + Type.String({ + description: "Chrome user-data directory. If provided, connects to (or launches) Chrome with --user-data-dir=profileDir. E.g. /home/user/.ds4/browser", + }), + ), + launchBrowser: Type.Optional( + Type.Boolean({ + description: "When true and no browser is detected at profileDir, launch Chrome automatically. Default true when profileDir is provided.", + }), + ), timeout: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds. Default 60000; maximum 600000. CPU-bound snippets without await yield points may overrun.", @@ -39,7 +51,7 @@ export default function browserExecuteExtension(pi: ExtensionAPI) { label: "Browser Execute", description: `Execute JavaScript against a Chromium browser through the Chrome DevTools Protocol (CDP). -The snippet receives a persistent CDP session as \`session\` and a captured \`console\`. Connect once with \`await session.connect()\`, \`await session.connect({ wsUrl })\`, or \`await session.connect({ profileDir })\`, then attach a page target with \`await session.use(targetId)\`. The session persists across later browser_execute calls in the same Pi process/session key. Every successful \`Page.captureScreenshot\` call is returned as an image part. Reusable scripts belong in \`.pi/browser-execute-workspace\` and can be loaded with \`await import(absPath + "?t=" + Date.now())\`. +The snippet receives a persistent CDP session as \`session\` and a captured \`console\`. Connect once with \`await session.connect()\`, \`await session.connect({ wsUrl })\`, \`await session.connect({ profileDir })\`, or \`await session.connect({ profileDir, launchBrowser: true })\` to auto-launch Chrome. Then attach a page target with \`await session.use(targetId)\`. The session persists across later browser_execute calls in the same Pi process/session key. Every successful \`Page.captureScreenshot\` call is returned as an image part. Reusable scripts belong in \`.pi/browser-execute-workspace\` and can be loaded with \`await import(absPath + "?t=" + Date.now())\`. Security: CDP controls the connected browser. Only use this tool against browsers/endpoints the user authorized.`, promptSnippet: "Execute JavaScript snippets against a real Chromium browser via CDP.", @@ -48,50 +60,67 @@ Security: CDP controls the connected browser. Only use this tool against browser "Before using browser_execute for page operations, connect with session.connect(), choose a page target from Target.getTargets, and call session.use(targetId).", "browser_execute snippets have session and console in scope; write reusable helper modules under .pi/browser-execute-workspace and import them with await import(...).", "browser_execute automatically returns Page.captureScreenshot results as image parts; do not manually decode screenshots unless processing bytes is required.", + "To launch Chrome with a specific profile directory, pass { profileDir: '/path', launchBrowser: true } to session.connect().", ], parameters: BrowserExecuteParams, async execute(_toolCallId, params, _signal, onUpdate, ctx) { const sessionID = sessionIDOf(ctx as { sessionId?: string; sessionID?: string; cwd: string }); const workspaceDir = workspaceDirOf(ctx.cwd); - const result = await executeBrowserCode(params as BrowserExecuteParameters, { - sessionID, - workspaceDir, - onChunk: (output) => { - onUpdate?.({ - content: [{ type: "text", text: preview(output) }], - details: { output: preview(output) }, - }); - }, - }); + try { + const result = await executeBrowserCode(params as BrowserExecuteParameters, { + sessionID, + workspaceDir, + profileDir: params.profileDir, + launchBrowser: params.launchBrowser ?? undefined, + onChunk: (output: string) => { + onUpdate?.({ + content: [{ type: "text", text: preview(output) }], + details: { output: preview(output) }, + }); + }, + } as any); - const text = [ - result.output.trimEnd(), - result.result === "null" ? "" : `=> ${result.result}`, - result.screenshots.length > 0 - ? `(${result.screenshots.length} screenshot${result.screenshots.length === 1 ? "" : "s"} attached)` - : "", - ] - .filter(Boolean) - .join("\n\n"); + const text = [ + result.output.trimEnd(), + result.result === "null" ? "" : `=> ${result.result}`, + result.screenshots.length > 0 + ? `(${result.screenshots.length} screenshot${result.screenshots.length === 1 ? "" : "s"} attached)` + : "", + ] + .filter(Boolean) + .join("\n\n"); - return { - content: [ - { type: "text" as const, text: text || "browser_execute completed" }, - ...result.screenshots.map((screenshot) => ({ - type: "image" as const, - mimeType: screenshot.mime, - data: screenshot.base64, - })), - ], - details: { - description: params.description, - result: result.result, - output: preview(result.output), - screenshotCount: result.screenshots.length, - workspaceDir, - }, - }; + return { + content: [ + { type: "text" as const, text: text || "browser_execute completed" }, + ...result.screenshots.map((screenshot) => ({ + type: "image" as const, + mimeType: screenshot.mime, + data: screenshot.base64, + })), + ], + details: { + description: params.description, + result: result.result, + output: preview(result.output), + screenshotCount: result.screenshots.length, + workspaceDir, + }, + }; + } catch (error) { + const errMessage = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text" as const, text: `Error: browser_execute failed:\n${errMessage}` }, + ], + details: { + description: params.description, + error: errMessage, + workspaceDir, + }, + }; + } }, }); } diff --git a/package-lock.json b/package-lock.json index 663f644..8a915ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@earendil-works/pi-coding-agent": "^0.74.0", "@types/node": "^24.3.0", + "tsx": "^4.22.4", "typebox": "^1.1.24", "typescript": "^5.7.3", "vitest": "^3.2.4" @@ -1372,9 +1373,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1392,9 +1390,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1412,9 +1407,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1432,9 +1424,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1452,9 +1441,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1663,9 +1649,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1680,9 +1663,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1697,9 +1677,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1714,9 +1691,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1731,9 +1705,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1748,9 +1719,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1765,9 +1733,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1782,9 +1747,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1799,9 +1761,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1816,9 +1775,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1833,9 +1789,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1850,9 +1803,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1867,9 +1817,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4254,13 +4201,518 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typebox": { "version": "1.1.38", "dev": true, "license": "MIT" }, "node_modules/typescript": { - "version": "5.9.3", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 3c91f3d..eb15306 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ ], "pi": { "extensions": [ - "./extensions" + "./extensions/browser-execute.ts", + "./extensions/browser-execute-web.ts" ], "image": "https://raw.githubusercontent.com/citrolabs/pi-browser-cdp-extension/main/logo.png" }, @@ -35,8 +36,9 @@ "devDependencies": { "@earendil-works/pi-coding-agent": "^0.74.0", "@types/node": "^24.3.0", - "typescript": "^5.7.3", + "tsx": "^4.22.4", "typebox": "^1.1.24", + "typescript": "^5.7.3", "vitest": "^3.2.4" }, "engines": { diff --git a/src/browser-execute.ts b/src/browser-execute.ts index 94140d5..a8be74c 100644 --- a/src/browser-execute.ts +++ b/src/browser-execute.ts @@ -14,6 +14,8 @@ export type BrowserExecuteParameters = { export type ExecuteContext = { sessionID: string; workspaceDir: string; + profileDir: string | undefined; + launchBrowser: boolean | undefined; onChunk?: (output: string) => void; }; @@ -40,6 +42,44 @@ const SCREENSHOT_FORMAT_TO_EXT: Record = { webp: "webp", }; +// Concurrency-safe global process error interceptor state +const activeCatchers = new Set<(error: unknown) => void>(); +let backupUncaught: any[] = []; +let backupUnhandled: any[] = []; +let isListening = false; + +const globalErrorHandler = (error: unknown) => { + for (const catcher of activeCatchers) { + try { + catcher(error); + } catch { + // Prevent catcher failures from breaking other catchers + } + } +}; + +function startGlobalListening() { + if (isListening) return; + isListening = true; + backupUncaught = process.listeners("uncaughtException"); + backupUnhandled = process.listeners("unhandledRejection"); + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + process.on("uncaughtException", globalErrorHandler); + process.on("unhandledRejection", globalErrorHandler); +} + +function stopGlobalListening() { + if (!isListening || activeCatchers.size > 0) return; + isListening = false; + process.off("uncaughtException", globalErrorHandler); + process.off("unhandledRejection", globalErrorHandler); + for (const l of backupUncaught) process.on("uncaughtException", l); + for (const l of backupUnhandled) process.on("unhandledRejection", l); + backupUncaught = []; + backupUnhandled = []; +} + const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...injected: unknown[]) => Promise; const dynamicImport = (specifier: string) => import(specifier); @@ -79,6 +119,15 @@ export async function executeBrowserCode(args: BrowserExecuteParameters, ctx: Ex const session = SessionStore.get(ctx.sessionID); await mkdir(ctx.workspaceDir, { recursive: true }); + // Auto-connect if profileDir is provided + if (ctx.profileDir && !session.isConnected()) { + await session.connect({ + profileDir: ctx.profileDir, + launchBrowser: ctx.launchBrowser ?? true, + timeoutMs: args.timeout ?? DEFAULT_TIMEOUT_MS, + }); + } + let wrapped: (...injected: unknown[]) => Promise; try { wrapped = new AsyncFunction("session", "console", "__import", args.code.replaceAll("import(", "__import(")); @@ -126,13 +175,29 @@ export async function executeBrowserCode(args: BrowserExecuteParameters, ctx: Ex } }); + let snippetError: Error | null = null; + const localCatcher = (error: unknown) => { + snippetError = error instanceof Error ? error : new Error(String(error)); + }; + + activeCatchers.add(localCatcher); + startGlobalListening(); + try { const timeoutMs = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); const ran = await Promise.race([wrapped(session, snippetConsole, dynamicImport), timeoutSignal(timeoutMs)]); + await new Promise((resolve) => setImmediate(resolve)); + if (snippetError) { + throw snippetError; + } return { output, result: serialize(ran), screenshots }; } catch (error) { - throw new Error(`browser_execute snippet threw: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + await new Promise((resolve) => setImmediate(resolve)); + const finalError = snippetError ?? error; + throw new Error(`browser_execute snippet threw: ${finalError instanceof Error ? finalError.message : String(finalError)}`); } finally { + activeCatchers.delete(localCatcher); + stopGlobalListening(); unsubscribe(); } } diff --git a/src/cdp/session.ts b/src/cdp/session.ts index 3d12ff5..a7e7853 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -5,9 +5,11 @@ * Connect with flatten:true so all sessions share one WebSocket. */ -import { readFile, stat } from "node:fs/promises"; +import { readFile, stat, mkdir } from "node:fs/promises"; import { setTimeout as sleep } from "node:timers/promises"; +import { spawn } from "node:child_process"; import { bindDomains, type Domains, type Transport } from "./generated.js"; +import path from "node:path"; type Pending = { resolve: (value: unknown) => void; @@ -21,6 +23,8 @@ export type ConnectOptions = { profileDir?: string; /** Per-candidate WS-open timeout in ms. Default 5000. */ timeoutMs?: number; + /** Launch Chrome with --user-data-dir=profileDir when no existing browser is detected. */ + launchBrowser?: boolean; }; export type DetectedBrowser = { @@ -37,6 +41,7 @@ export class Session implements Transport { private nextId = 1; private pending = new Map(); private activeSessionId: string | undefined; + private chromeProcess: import("node:child_process").ChildProcess | undefined; private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = []; private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = []; @@ -52,7 +57,28 @@ export class Session implements Transport { async connect(opts: ConnectOptions = {}): Promise { const timeoutMs = opts.timeoutMs ?? 5_000; - if (opts.wsUrl || opts.profileDir) { + if (opts.wsUrl) { + await this.openWs(opts.wsUrl, timeoutMs); + return; + } + + if (opts.profileDir) { + const parsed = await tryReadDevToolsActivePort(opts.profileDir); + if (parsed) { + try { + await this.openWs(`ws://127.0.0.1:${parsed.port}${parsed.path}`, timeoutMs); + return; + } catch { + // Fall through to launch if connection failed + } + } + + if (opts.launchBrowser) { + const wsUrl = await this.launchChrome(opts.profileDir, timeoutMs); + await this.openWs(wsUrl, timeoutMs); + return; + } + const wsUrl = await resolveWsUrl(opts, timeoutMs); await this.openWs(wsUrl, timeoutMs); return; @@ -65,26 +91,33 @@ export class Session implements Transport { } const browsers = await detectBrowsers(); - if (browsers.length === 0) { - const scanned = getBrowserCandidates().map((candidate) => candidate.name).join(", "); + if (browsers.length > 0) { + const errors: string[] = []; + for (const browser of browsers) { + try { + await this.openWs(browser.wsUrl, timeoutMs); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(` ${browser.name} @ ${browser.wsUrl}: ${message}`); + } + } throw new Error( - `No running browser with remote debugging detected. Enable it from chrome://inspect > "Discover network targets", or pass { profileDir } / { wsUrl } explicitly. Scanned: ${scanned}.`, + `No detected browser accepted a connection. If one of these is the browser you want, click "Allow" on its remote-debugging prompt and retry:\n${errors.join("\n")}`, ); } - const errors: string[] = []; - for (const browser of browsers) { - try { - await this.openWs(browser.wsUrl, timeoutMs); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(` ${browser.name} @ ${browser.wsUrl}: ${message}`); - } + if (opts.launchBrowser) { + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + const defaultProfile = path.join(home, ".pi-browser-profile"); + const wsUrl = await this.launchChrome(defaultProfile, timeoutMs); + await this.openWs(wsUrl, timeoutMs); + return; } + const scanned = getBrowserCandidates().map((candidate) => candidate.name).join(", "); throw new Error( - `No detected browser accepted a connection. If one of these is the browser you want, click "Allow" on its remote-debugging prompt and retry, or pass { profileDir, timeoutMs: 30000 } to wait for the click:\n${errors.join("\n")}`, + `No running browser with remote debugging detected. Enable it from chrome://inspect > "Discover network targets", or pass { profileDir } / { wsUrl } explicitly. Scanned: ${scanned}.`, ); } @@ -131,6 +164,10 @@ export class Session implements Transport { close(): void { this.ws?.close(); + if (this.chromeProcess) { + this.chromeProcess.kill(); + this.chromeProcess = undefined; + } } async use(targetId: string): Promise { @@ -207,6 +244,92 @@ export class Session implements Transport { }); } + private async launchChrome(profileDir: string, timeoutMs: number): Promise { + const chromePath = process.env.BROWSER_PATH ?? this.findChrome(); + if (!chromePath) { + throw new Error("Chrome not found. Set BROWSER_PATH or install Chrome."); + } + + await mkdir(profileDir, { recursive: true }); + + const child = spawn(chromePath, [ + "--remote-debugging-port=0", + `--user-data-dir=${profileDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-features=OptimizationGuideModelDownloading,OptimizationGuideFetching,OptimizationTargetPrediction,OptimizationHints", + ], { + stdio: ["ignore", "ignore", "pipe"], + }); + this.chromeProcess = child; + + const filePath = `${profileDir}/DevToolsActivePort`; + const deadline = Date.now() + timeoutMs; + + return new Promise((resolve, reject) => { + let lastErr = "unknown"; + + const poll = async () => { + try { + const text = (await readFile(filePath, "utf8")).trim(); + const [portStr, path] = text.split("\n"); + const port = Number(portStr); + if (!Number.isFinite(port)) { + lastErr = `malformed port: ${portStr}`; + if (Date.now() < deadline) setTimeout(poll, 250); + else reject(new Error(`Chrome started but no valid DevToolsActivePort: ${lastErr}`)); + return; + } + if (!path || !path.startsWith("/devtools/")) { + lastErr = `invalid path: ${path}`; + if (Date.now() < deadline) setTimeout(poll, 250); + else reject(new Error(`Chrome started but bad DevToolsActivePort: ${lastErr}`)); + return; + } + child.on("exit", () => {}); // ignore exit after successful connect + resolve(`ws://127.0.0.1:${port}${path}`); + } catch (err) { + lastErr = err instanceof Error ? err.message : String(err); + if (Date.now() < deadline) setTimeout(poll, 250); + else reject(new Error(`Chrome may have exited: ${lastErr}`)); + } + }; + + (child.stdout ?? process.stdout).on("data", (chunk: Buffer) => { + const msg = String(chunk).trim(); + if (msg) console.warn(`[chrome] ${msg}`); + }); + + (child.stderr ?? process.stderr).on("data", (chunk: Buffer) => { + const msg = String(chunk).trim(); + if (msg) console.warn(`[chrome-stderr] ${msg}`); + }); + + child.on("exit", (code) => { + if (code !== 0) { + reject(new Error(`Chrome exited with code ${code ?? "null"}`)); + } + }); + + poll(); + }); + } + + private findChrome(): string | undefined { + switch (process.platform) { + case "darwin": return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + case "linux": return process.env.CHROME_BIN ?? "/usr/bin/google-chrome"; + case "win32": { + const local = process.env.LOCALAPPDATA ?? ""; + return local ? `${local}\\Google\\Chrome\\Application\\chrome.exe` : undefined; + } + } + return undefined; + } + private onMessage(raw: string): void { let message: { id?: unknown; method?: unknown; params?: unknown; sessionId?: string; error?: { code: number; message: string; data?: unknown }; result?: unknown }; try { @@ -295,6 +418,9 @@ export async function listPageTargets(session: Session): Promise { return targetInfos.filter((target) => target.type === "page" && !target.url.startsWith("chrome://") && !target.url.startsWith("devtools://")); } +// Common fixed remote-debugging ports to try when DevToolsActivePort is not found. +const FIXED_DEBUGGING_PORTS = [9333, 9222, 9223, 9323, 9330, 9331]; + export async function detectBrowsers(): Promise { const candidates = getBrowserCandidates(); const detected: DetectedBrowser[] = []; @@ -312,6 +438,26 @@ export async function detectBrowsers(): Promise { }); } + // Fallback: try common fixed ports by fetching /json/version. + for (const port of FIXED_DEBUGGING_PORTS) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`); + if (res.ok) { + const ver = (await res.json()) as { webSocketDebuggerUrl: string }; + detected.push({ + name: `Chrome/Chromium @ port ${port}`, + profileDir: "", + port, + wsPath: new URL(ver.webSocketDebuggerUrl).pathname, + wsUrl: ver.webSocketDebuggerUrl, + mtimeMs: 0, + }); + } + } catch { + // Port not open or browser not running — skip. + } + } + detected.sort((a, b) => b.mtimeMs - a.mtimeMs); return detected; } diff --git a/src/web-fetch.ts b/src/web-fetch.ts new file mode 100644 index 0000000..17e3660 --- /dev/null +++ b/src/web-fetch.ts @@ -0,0 +1,529 @@ +/** + * Web Fetch & Search: high-level browser tools that wrap the CDP session. + * + * Ported from ds4_web.c (ds4 checkout on aimax). Provides: + * - web_search: searches Google via Chrome and extracts results as markdown + * - web_fetch: visits a URL via Chrome and extracts page content as structured markdown + * + * Both tools reuse the existing CDP session infrastructure (visible Chrome with + * user profile, CDP communication via WebSocket). + */ + +import { Session } from "./cdp/session.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export type ProgressCallback = (status: string) => void; + +export type WebSearchOptions = { + query: string; + profileDir?: string | undefined; + timeoutMs?: number; + onProgress?: ProgressCallback; +}; + +export type WebFetchOptions = { + url: string; + profileDir?: string | undefined; + timeoutMs?: number; + onProgress?: ProgressCallback; +}; + +export type WebSearchResult = { + /** Markdown text of search results (visible links + text snapshot). */ + markdown: string; + /** URL of the Google search page used. */ + searchUrl: string; + /** Total number of visible result links extracted. */ + linkCount: number; +}; + +export type WebFetchResult = { + /** Markdown text of the page content. */ + markdown: string; + /** Final URL after redirects. */ + finalUrl: string; + /** Page title. */ + title: string; + /** Whether the page was dynamically scrolled to load more content. */ + scrolled: boolean; + /** Number of links extracted. */ + linkCount: number; + /** Total line count of the extracted content. */ + lineCount: number; +}; + +export type WebError = { + error: true; + message: string; +}; + +export type WebResult = WebSearchResult | WebFetchResult | WebError; + +// ============================================================================ +// Helper to build strings with backticks (port from ds4_web.c C code) +// ============================================================================ + +function t(str: string): string { + return str; +} + +// ============================================================================ +// Google Search JavaScript (port from ds4_web.c:web_extract_search_js) +// ============================================================================ + +const GOOGLE_CONSENT_CLICK_JS = t(`(() => { + const clean=s=>(s||'').replace(/\\s+/g,' ').trim(); + const pats=[/accept all/i,/i agree/i,/agree/i,/accetta tutto/i,/tout accepter/i,/aceptar todo/i,/alle akzeptieren/i]; + const els=[...document.querySelectorAll('button,[role=button],input[type=submit],a')]; + for (const el of els){const t=clean(el.innerText||el.value||el.textContent); + if(!t)continue; if(pats.some(p=>p.test(t))){el.click(); return 'clicked '+t;}} + return ''; +})()`); + +const GOOGLE_SEARCH_EXTRACT_JS = t(`(() => { + const clean=s=>(s||'').replace(/\\s+/g,' ').trim(); + const esc=s=>clean(s).replace(/\\\\/g,'\\\\\\\\').replace(/\\[/g,'\\\\[').replace(/\\]/g,'\\\\]').replace(/\\n/g,' '); + const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0'}; + const bad=h=>(/(^|\\.)google\\./.test(h)||/(^|\\.)gstatic\\./.test(h)||/(^|\\.)googleusercontent\\./.test(h)); + const lines=['# Google search results','',\`URL: \${location.href}\`,'','## Visible links']; + const seen=new Set(); + for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;let href=a.href||''; + try{const u=new URL(href);if(u.pathname==='/url'&&u.searchParams.get('q'))href=u.searchParams.get('q');}catch{} + let u;try{u=new URL(href);}catch{continue;}if(!/^https?:$/.test(u.protocol))continue;if(bad(u.hostname))continue; + const text=esc(a.innerText||a.textContent);if(text.length<3)continue;if(seen.has(u.href))continue;seen.add(u.href); + lines.push(\`- [\${text.slice(0,180)}](\${u.href})\`);if(seen.size>=20)break;} + lines.push('','## Text snapshot',clean(document.body.innerText).slice(0,1200)); + return lines.join('\\n'); +})()`); + +// ============================================================================ +// Page Content JavaScript (port from ds4_web.c:web_extract_page_js) +// ============================================================================ + +const PAGE_EXTRACT_JS = t(`(() => { + const clean=s=>(s||'').replace(/\\s+/g,' ').trim(); + const esc=s=>clean(s).replace(/\\\\/g,'\\\\\\\\').replace(/\\[/g,'\\\\[').replace(/\\]/g,'\\\\]').replace(/\\n/g,' '); + const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0'}; + const inline=n=>{if(!n)return'';if(n.nodeType===3)return n.nodeValue;if(n.nodeType!==1)return'';const el=n; + if(el.tagName==='SCRIPT'||el.tagName==='STYLE'||el.tagName==='NOSCRIPT')return''; + if(el.tagName==='A'){const t=esc(el.innerText||el.textContent);const h=el.href||'';return t&&h?\`[\${t}](\${h})\`:t;} + if(el.tagName==='CODE')return '\`' + clean(el.innerText||el.textContent).replace(/\`/g,'\\\\\`') + '\`'; + return [...el.childNodes].map(inline).join('');}; + const lines=[\`# \${clean(document.title)||location.href}\`,'',\`URL: \${location.href}\`,'','## Content']; + const blocks=[...document.body.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id="content-text"],[class*="comment-body"],[class*="comment-content"],[data-testid*="comment-text"]')]; + const seen=new Set(); + for(const el of blocks){if(!visible(el))continue;let s='';const tag=el.tagName; + if(/^H[1-6]$/.test(tag)){s='#'.repeat(Number(tag[1]))+' '+inline(el);} + else if(tag==='LI'){s='- '+inline(el);} + else if(tag==='PRE'){s='\\x60\\x60\\x60\\n'+(el.innerText||el.textContent||'').trimEnd()+'\\n\\x60\\x60\\x60';} + else if(tag==='BLOCKQUOTE'){s='> '+clean(el.innerText||el.textContent);} + else{s=inline(el);}s=s.trim();if(!s||seen.has(s))continue;seen.add(s);lines.push('',s); + if(lines.join('\\n').length>900000){lines.push('','[Content truncated by browser extractor.]');break;}} + lines.push('','## Visible links');let n=0;const linkSeen=new Set(); + for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;const t=esc(a.innerText||a.textContent);if(t.length<3)continue; + let u;try{u=new URL(a.href);}catch{continue;}if(!/^https?:$/.test(u.protocol)||linkSeen.has(u.href))continue;linkSeen.add(u.href); + lines.push(\`- [\${t.slice(0,160)}](\${u.href})\`);if(++n>=80)break;} + return lines.join('\\n'); +})()`); + +// ============================================================================ +// Dynamic Scroll JavaScript (port from ds4_web.c:web_scroll_dynamic_page) +// ============================================================================ + +const DYNAMIC_SCROLL_JS = t(`(() => new Promise(resolve => { + const root=()=>document.scrollingElement||document.documentElement||document.body; + const blockSel='h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id="content-text"],[class*="comment-body"],[class*="comment-content"],[data-testid*="comment-text"]'; + const lazySel='[onscroll],[loading="lazy"],[data-src],[data-lazy],[class*="lazy"],[class*="infinite"],[class*="virtual"],[role="feed"],[id*="comment"],[class*="comment"],[data-testid*="comment"]'; + const hookCount=()=>{let n=0;try{if(window.onscroll)n++;if(document.onscroll)n++;if(document.body&&document.body.onscroll)n++;}catch(e){} + try{if(typeof getEventListeners==='function'){for(const o of [window,document,document.body]){if(!o)continue;const ev=getEventListeners(o);if(ev&&ev.scroll)n+=ev.scroll.length;}}}catch(e){} + try{n+=document.querySelectorAll(lazySel).length;}catch(e){}return n;}; + const metrics=()=>{const r=root();return { + height:r?r.scrollHeight:0, + view:innerHeight||900, + y:scrollY||(r&&r.scrollTop)||0, + text:((document.body&&document.body.innerText)||'').length, + links:document.links?document.links.length:0, + blocks:document.body?document.body.querySelectorAll(blockSel).length:0, + hooks:hookCount()};}; + const sig=m=>[m.height,m.text,m.links,m.blocks].join('|'); + const grew=(a,b)=>b.height>a.height+20||b.text>a.text+200||b.links>a.links+2||b.blocks>a.blocks+2; + const scrollOnce=()=>{const r=root();if(!r)return; + const h=Math.max(700,Math.floor((innerHeight||900)*0.85)); + window.scrollTo(0,Math.min(r.scrollHeight,(scrollY||r.scrollTop||0)+h));}; + let last=metrics(),lastSig=sig(last),same=0,steps=0; + const scrollable=last.height>last.view*1.35; + if(!scrollable||last.hooks===0){resolve('scroll skipped hooks='+last.hooks+' text='+last.text);return;} + const tick=()=>{ + if(steps>=28){resolve('scrolled '+steps+' text='+last.text);return;} + const before=last; + scrollOnce();steps++; + setTimeout(()=>{const now=metrics(),nowSig=sig(now); + if(nowSig===lastSig)same++;else same=0; + const loaded=grew(before,now); + last=now;lastSig=nowSig; + if(steps===1&&!loaded){resolve('scroll probe unchanged text='+now.text);return;} + const atBottom=now.y+now.view+20>=now.height; + if(same>=4||(atBottom&&same>=1)){resolve('scrolled '+steps+' text='+now.text);return;} + tick();},900);};tick(); +}))()`); + +// ============================================================================ +// Utility: wait for page readiness +// ============================================================================ + +async function waitForPageReady(session: Session): Promise { + for (let i = 0; i < 80; i++) { + try { + const state = (await session.domains.Runtime.evaluate({ + expression: "document.readyState", + returnByValue: true, + })) as { result?: { value?: string } }; + const ready = state?.result?.value as string; + if (ready === "complete" || ready === "interactive") { + await new Promise((r) => setTimeout(r, 800)); + return; + } + } catch { + // Skip on transient errors + } + await new Promise((r) => setTimeout(r, 250)); + } +} + +// ============================================================================ +// Wait for navigation to complete +// ============================================================================ + +async function waitForNavigated(session: Session, maxAttempts = 100): Promise { + let lastLen = -1; + let stable = 0; + let sawRealUrl = false; + + for (let i = 0; i < maxAttempts; i++) { + try { + const probe = (await session.domains.Runtime.evaluate({ + expression: + "location.href+'\\n'+document.readyState+'\\n'+((document.body&&document.body.innerText)||'').length", + returnByValue: true, + })) as { result?: { value?: string } }; + const raw = probe?.result?.value as string; + if (!raw) { + await new Promise((r) => setTimeout(r, 250)); + continue; + } + const parts = raw.split('\n'); + if (parts.length < 3) { + await new Promise((r) => setTimeout(r, 250)); + continue; + } + const href = parts[0]?.trim() || ''; + const ready = parts[1]?.trim() || ''; + const textLen = Number(parts[2]) || 0; + + const realUrl = href && href.length > 0 && + href !== "about:blank" && + !href.startsWith("chrome://"); + const readyState = ready === "complete" || ready === "interactive"; + + if (realUrl) sawRealUrl = true; + if (textLen > 0 && textLen === lastLen) stable++; + else stable = 0; + lastLen = textLen; + + if (sawRealUrl && readyState && textLen > 0 && stable >= 2) { + await new Promise((r) => setTimeout(r, 500)); + return true; + } + if (sawRealUrl && readyState && i >= 24) return true; + } catch { + // Skip on transient errors + } + await new Promise((r) => setTimeout(r, 250)); + } + return true; +} + +// ============================================================================ +// Open a tab and attach to it via CDP +// ============================================================================ + +async function openTab( + session: Session, + url: string, +): Promise { + // Create a new visible target (tab) so user can see what's happening + const result = (await session.domains.Target.createTarget({ + url, + background: false, + newWindow: false, + })) as { targetId: string }; + return result.targetId; +} + +// ============================================================================ +// Execute JS on a page with error handling +// ============================================================================ + +async function evaluate( + session: Session, + expression: string, +): Promise { + const result = (await session.domains.Runtime.evaluate({ + expression, + returnByValue: true, + awaitPromise: true, + })) as { + result?: { type?: string; value?: unknown; className?: string }; + exceptionDetails?: { text?: string }; + }; + + if (result.exceptionDetails?.text) { + throw new Error(`JavaScript evaluation failed: ${result.exceptionDetails.text}`); + } + + if (result.result?.type === "undefined") return null; + if (result.result?.type === "string") return result.result.value as string; + if (result.result?.value !== undefined) return JSON.stringify(result.result.value); + return null; +} + +// ============================================================================ +// Run a page with CDP: navigate, scroll (optional), and extract +// ============================================================================ + +export interface RunPageOptions { + session: Session; + url: string; + extractJs: string; + scrollDynamic?: boolean; + /** Milliseconds to leave the tab visible after extraction (default: 15000). */ + keepTabVisibleMs?: number; + /** Optional progress callback for UI feedback. */ + onProgress?: ProgressCallback; +} + +export async function runPage(opts: RunPageOptions): Promise { + const { session, url, extractJs, scrollDynamic = true, keepTabVisibleMs = 15000, onProgress } = opts; + + // Open a new tab for this operation + const targetId = await openTab(session, "about:blank"); + + // Attach to the tab + const sessionId = await session.use(targetId); + + try { + // Enable domains + await session.domains.Page.enable(); + await session.domains.Runtime.enable(); + + // Navigate to the target URL + onProgress?.(`Navigating to ${url}`); + await session.domains.Page.navigate({ url }); + await waitForNavigated(session); + + // Try to click Google consent dialogs + try { + const consentResult = await evaluate(session, GOOGLE_CONSENT_CLICK_JS); + if (consentResult && consentResult.length > 0) { + // Consent was clicked; wait for any navigation + await new Promise((r) => setTimeout(r, 1500)); + try { + await waitForNavigated(session); + } catch { + // Ignore navigation wait failures after consent + } + } + } catch { + // Consent click is best-effort + } + + // Optionally scroll the page to trigger lazy loading + if (scrollDynamic) { + try { + await evaluate(session, DYNAMIC_SCROLL_JS); + } catch { + // Scrolling is best-effort + } + } + + // Extract content using the provided JS + onProgress?.(`Extracting content`); + const content = await evaluate(session, extractJs); + if (content === null) { + throw new Error("Page extraction returned null"); + } + + // Fire-and-forget: close the tab in the background after the visibility delay. + // This lets the tool return immediately so the agent can continue working. + // The tab stays visible for the full duration so the user can see what happened. + scheduleTabClose(session, targetId, keepTabVisibleMs); + + return content; + } catch (error) { + // If extraction failed, close the tab immediately + session.setActiveSession(undefined); + try { + await session.domains.Target.closeTarget({ targetId }); + } catch { + // Ignore close errors + } + throw error; + } +} + +/** + * Close a Chrome tab after a delay without blocking the caller. + * The tab stays visible for the full duration; the caller returns immediately. + */ +function scheduleTabClose(session: Session, targetId: string, delayMs: number): void { + setTimeout(async () => { + session.setActiveSession(undefined); + try { + await session.domains.Target.closeTarget({ targetId }); + } catch { + // Tab may have already been closed; ignore + } + }, delayMs); +} + +// ============================================================================ +// Ensure Chrome is running and connected +// ============================================================================ + +async function ensureConnected(session: Session): Promise { + if (session.isConnected()) { + // Check if browser is alive + try { + await session.domains.Browser.getVersion(); + return; // Browser is alive + } catch { + // Browser may have died; fall through + } + } + // Session is not connected; try to connect (uses detectBrowsers fallback) + await session.connect({ launchBrowser: false }); +} + +// ============================================================================ +// Web Search +// ============================================================================ + +/** + * Search Google using a visible Chrome browser via CDP. + * + * Returns markdown containing: + * - Visible links (up to 20 results) + * - A text snapshot of the search results page + * + * Handles Google consent dialogs automatically. + */ +export async function webSearch(opts: WebSearchOptions): Promise { + const { query, profileDir, onProgress } = opts; + if (!query || query.trim().length === 0) { + return { error: true, message: "web_search requires a query parameter" }; + } + + onProgress?.(`Searching: ${query}`); + + // Build the Google search URL + const encoded = encodeURIComponent(query); + const searchUrl = `https://www.google.com/search?q=${encoded}`; + + try { + const sessionObj = new Session(); + if (profileDir) { + onProgress?.(`Connecting to Chrome`); + await sessionObj.connect({ profileDir, launchBrowser: true }); + } else { + await ensureConnected(sessionObj); + } + + const result = await runPage({ + session: sessionObj, + url: searchUrl, + extractJs: GOOGLE_SEARCH_EXTRACT_JS, + scrollDynamic: false, + ...(onProgress && { onProgress }), + }); + + // Count links + const linkCount = (result.match(/^- \[/gm) || []).length; + + return { + markdown: result, + searchUrl, + linkCount, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: true, message: `web_search failed: ${message}` }; + } +} + +// ============================================================================ +// Web Fetch +// ============================================================================ + +/** + * Fetch and extract page content from a URL using a visible Chrome browser via CDP. + * + * Returns structured markdown containing: + * - Page title as heading + * - Content extracted from semantic HTML elements (headings, paragraphs, lists, code, blockquotes) + * - Visible links section + * - Truncation at 900KB + * + * Dynamically scrolls the page to trigger lazy loading before extraction. + */ +export async function webFetch(opts: WebFetchOptions): Promise { + const { url, profileDir, onProgress } = opts; + if (!url || url.length === 0) { + return { error: true, message: "web_fetch requires a url parameter" }; + } + + onProgress?.(`Fetching: ${url}`); + + try { + const sessionObj = new Session(); + if (profileDir) { + onProgress?.(`Connecting to Chrome`); + await sessionObj.connect({ profileDir, launchBrowser: true }); + } else { + await ensureConnected(sessionObj); + } + + const result = await runPage({ + session: sessionObj, + url, + extractJs: PAGE_EXTRACT_JS, + scrollDynamic: true, + ...(onProgress && { onProgress }), + }); + + // Extract metadata + const titleMatch = result.match(/^# (.+)$/m); + const title = titleMatch?.[1] ?? ""; + const finalUrlMatch = result.match(/^URL: (.+)$/m); + const finalUrl = finalUrlMatch?.[1] ?? url; + const linkCount = (result.match(/^- \[/gm) || []).length; + const lineCount = result.split('\n').length; + + // Determine if scrolled (look for scroll info in comments or content) + const scrolled = result.includes("scrolled") || result.includes("Content truncated"); + + return { + markdown: result, + finalUrl, + title, + scrolled, + linkCount, + lineCount, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: true, message: `web_fetch failed: ${message}` }; + } +} diff --git a/test-manual-web-search.ts b/test-manual-web-search.ts new file mode 100644 index 0000000..2ba2e95 --- /dev/null +++ b/test-manual-web-search.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env tsx +/** + * Manual test script to verify Chrome tab visibility for web_search and web_fetch. + * + * Run this script to see Chrome open, navigate, and leave the tab visible for 15 seconds. + * + * Requirements: + * - Chrome running with --remote-debugging-port=9333 + * - Profile directory: /home/dpavlin/.ds4/browser + */ + +import { webSearch, webFetch } from "./src/web-fetch.js"; + +async function testWebSearch() { + console.log("🔍 Testing web_search..."); + console.log("Chrome should open a tab with Google search results."); + console.log("The tab will stay visible for 15 seconds after extraction.\n"); + + const result = await webSearch({ + query: "ds4 antirez", + profileDir: undefined, // Let detectBrowsers find Chrome on 9333 + }); + + if ("error" in result) { + console.error(`❌ web_search failed: ${result.message}`); + return; + } + + console.log(`✅ web_search extracted ${result.linkCount} links`); + console.log(` URL: ${result.searchUrl}`); + console.log(` ${result.markdown.length} characters of markdown\n`); +} + +async function testWebFetch() { + console.log("🌐 Testing web_fetch..."); + console.log("Chrome should open a tab with example.com."); + console.log("The tab will stay visible for 15 seconds after extraction.\n"); + + const result = await webFetch({ + url: "https://example.com", + profileDir: undefined, // Let detectBrowsers find Chrome on 9333 + }); + + if ("error" in result) { + console.error(`❌ web_fetch failed: ${result.message}`); + return; + } + + console.log(`✅ web_fetch extracted ${result.lineCount} lines`); + console.log(` URL: ${result.finalUrl}`); + console.log(` Title: ${result.title}`); + console.log(` ${result.markdown.length} characters of markdown\n`); +} + +async function main() { + try { + await testWebSearch(); + await testWebFetch(); + console.log("✅ Manual tests complete. Check Chrome for visible tabs."); + } catch (error) { + console.error(`❌ Test error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} + +main(); diff --git a/test/browser-execute-web.test.ts b/test/browser-execute-web.test.ts new file mode 100644 index 0000000..7a37037 --- /dev/null +++ b/test/browser-execute-web.test.ts @@ -0,0 +1,209 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionStore } from "../src/session-store.js"; +import browserExecuteWebExtension from "../extensions/browser-execute-web.js"; + +const tempDirs: string[] = []; +const sessionIds = new Set(); + +async function tmp(prefix: string): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function minimalContext(cwd: string, sessionId?: string): ExtensionContext & { sessionId?: string } { + const ctx: ExtensionContext & { sessionId?: string } = { + cwd, + ui: {} as ExtensionContext["ui"], + hasUI: false, + sessionManager: {} as ExtensionContext["sessionManager"], + modelRegistry: {} as ExtensionContext["modelRegistry"], + model: undefined, + signal: undefined, + isIdle: () => true, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + }; + if (sessionId !== undefined) ctx.sessionId = sessionId; + return ctx; +} + +function loadTools(): { webSearch: ToolDefinition; webFetch: ToolDefinition } { + let webSearch: ToolDefinition | undefined; + let webFetch: ToolDefinition | undefined; + + browserExecuteWebExtension({ + registerTool(tool: ToolDefinition) { + if (tool.name === "web_search") webSearch = tool; + if (tool.name === "web_fetch") webFetch = tool; + }, + } as ExtensionAPI); + + if (!webSearch) throw new Error("extension did not register web_search"); + if (!webFetch) throw new Error("extension did not register web_fetch"); + return { webSearch, webFetch }; +} + +afterEach(async () => { + for (const sessionID of sessionIds) SessionStore.evict(sessionID); + sessionIds.clear(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + vi.restoreAllMocks(); +}); + +function getTextContent(result: { content: Array }): Record { + const first = result.content[0]; + expect(first).toBeDefined(); + expect((first as Record).type).toBe("text"); + return first as Record; +} + +// ============================================================================ +// web_search tests +// ============================================================================ + +describe("web_search Pi extension adapter", () => { + it("registers the web_search tool with prompt guidance", () => { + const { webSearch } = loadTools(); + + expect(webSearch.name).toBe("web_search"); + expect(webSearch.label).toBe("Web Search"); + expect(webSearch.promptSnippet).toContain("Google"); + expect(webSearch.promptGuidelines?.some((line) => line.includes("web_fetch"))).toBe(true); + }); + + it("returns text content with error message for empty query", async () => { + const cwd = await tmp("pi-websearch-cwd-"); + const { webSearch } = loadTools(); + const sessionID = "websearch-session"; + sessionIds.add(sessionID); + + const result = await webSearch.execute( + "tool-call-1", + { query: "" }, + undefined, + undefined, + minimalContext(cwd, sessionID), + ); + + const textContent = getTextContent(result); + const text = textContent.text as string; + expect(text).toContain("Error"); + expect(text).toContain("requires a query parameter"); + }); + + it("returns details with query for error case", async () => { + const cwd = await tmp("pi-websearch-cwd-"); + const { webSearch } = loadTools(); + const sessionID = "websearch-details-session"; + sessionIds.add(sessionID); + + const result = await webSearch.execute( + "tool-call-1", + { query: "" }, + undefined, + undefined, + minimalContext(cwd, sessionID), + ); + + expect(result.details).toMatchObject({ + query: "", + error: expect.any(String), + }); + }); +}); + +// ============================================================================ +// web_fetch tests +// ============================================================================ + +describe("web_fetch Pi extension adapter", () => { + it("registers the web_fetch tool with prompt guidance", () => { + const { webFetch } = loadTools(); + + expect(webFetch.name).toBe("web_fetch"); + expect(webFetch.label).toBe("Web Fetch"); + expect(webFetch.promptSnippet).toContain("curl"); + expect(webFetch.promptGuidelines?.some((line) => line.includes("web_search"))).toBe(true); + }); + + it("returns text content with error message for empty url", async () => { + const cwd = await tmp("pi-webfetch-cwd-"); + const { webFetch } = loadTools(); + const sessionID = "webfetch-session"; + sessionIds.add(sessionID); + + const result = await webFetch.execute( + "tool-call-1", + { url: "" }, + undefined, + undefined, + minimalContext(cwd, sessionID), + ); + + const textContent = getTextContent(result); + const text = textContent.text as string; + expect(text).toContain("Error"); + expect(text).toContain("requires a url parameter"); + }); + + it("returns details with url for error case", async () => { + const cwd = await tmp("pi-webfetch-cwd-"); + const { webFetch } = loadTools(); + const sessionID = "webfetch-details-session"; + sessionIds.add(sessionID); + + const result = await webFetch.execute( + "tool-call-1", + { url: "" }, + undefined, + undefined, + minimalContext(cwd, sessionID), + ); + + expect(result.details).toMatchObject({ + url: "", + error: expect.any(String), + }); + }); +}); + +// ============================================================================ +// Combined tests +// ============================================================================ + +describe("web_search + web_fetch together", () => { + it("both tools are registered by the extension", () => { + let registeredNames: string[] = []; + + browserExecuteWebExtension({ + registerTool(tool: ToolDefinition) { + registeredNames.push(tool.name); + }, + } as ExtensionAPI); + + expect(registeredNames).toContain("web_search"); + expect(registeredNames).toContain("web_fetch"); + expect(registeredNames).toHaveLength(2); + }); + + it("web_search has guidance referencing web_fetch", () => { + const { webSearch } = loadTools(); + const guidance = webSearch.promptGuidelines ?? []; + expect(guidance.some((g) => g.toLowerCase().includes("web_fetch"))).toBe(true); + }); + + it("web_fetch has guidance referencing web_search", () => { + const { webFetch } = loadTools(); + const guidance = webFetch.promptGuidelines ?? []; + expect(guidance.some((g) => g.toLowerCase().includes("web_search"))).toBe(true); + }); +}); diff --git a/test/browser-execute.test.ts b/test/browser-execute.test.ts index 88ac378..0a42f68 100644 --- a/test/browser-execute.test.ts +++ b/test/browser-execute.test.ts @@ -39,7 +39,7 @@ describe("browser_execute core", () => { description: "Exercise console capture", code: `console.log("hello", { ok: true }); console.debug("debug-line"); return { n: 1n };`, }, - { sessionID: trackSession("console-session"), workspaceDir }, + { sessionID: trackSession("console-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(result.output).toContain("hello"); @@ -57,7 +57,7 @@ describe("browser_execute core", () => { description: "Check workspace exists", code: `const fs = await import("node:fs/promises"); return (await fs.stat(${JSON.stringify(workspaceDir)})).isDirectory();`, }, - { sessionID: trackSession("workspace-session"), workspaceDir }, + { sessionID: trackSession("workspace-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(JSON.parse(result.result)).toBe(true); @@ -72,7 +72,7 @@ describe("browser_execute core", () => { description: "Stream output chunks", code: `console.log("first"); console.warn("second"); return "ok";`, }, - { sessionID: trackSession("chunk-session"), workspaceDir, onChunk: (output) => chunks.push(output) }, + { sessionID: trackSession("chunk-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined, onChunk: (output) => chunks.push(output) }, ); expect(chunks).toEqual(["first\n", "first\nsecond\n"]); @@ -87,7 +87,7 @@ describe("browser_execute core", () => { description: "Set session state", code: `session.__testValue = 42; return null;`, }, - { sessionID, workspaceDir }, + { sessionID, workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); const result = await executeBrowserCode( @@ -95,7 +95,7 @@ describe("browser_execute core", () => { description: "Read session state", code: `return session.__testValue;`, }, - { sessionID, workspaceDir }, + { sessionID, workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(JSON.parse(result.result)).toBe(42); @@ -109,7 +109,7 @@ describe("browser_execute core", () => { description: "Set isolated state", code: `session.__testValue = "left"; return null;`, }, - { sessionID: trackSession("left-session"), workspaceDir }, + { sessionID: trackSession("left-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); const result = await executeBrowserCode( @@ -117,7 +117,7 @@ describe("browser_execute core", () => { description: "Read isolated state", code: `return session.__testValue ?? null;`, }, - { sessionID: trackSession("right-session"), workspaceDir }, + { sessionID: trackSession("right-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(JSON.parse(result.result)).toBeNull(); @@ -138,7 +138,7 @@ describe("browser_execute core", () => { return "done"; `, }, - { sessionID, workspaceDir }, + { sessionID, workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(result.screenshots).toEqual([ @@ -158,7 +158,7 @@ describe("browser_execute core", () => { return "done"; `, }, - { sessionID: trackSession("malformed-screenshot-session"), workspaceDir }, + { sessionID: trackSession("malformed-screenshot-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(result.screenshots).toEqual([]); @@ -175,7 +175,7 @@ describe("browser_execute core", () => { description: "Dump screenshot", code: `for (const fn of session.callResultListeners) fn("Page.captureScreenshot", { format: "webp" }, { data: ${JSON.stringify(base64)} });`, }, - { sessionID: trackSession("dump-session"), workspaceDir }, + { sessionID: trackSession("dump-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); await flushAsyncFileWrites(); @@ -201,7 +201,7 @@ describe("browser_execute core", () => { await executeBrowserCode( { description: "Return successfully", code: `return "ok";` }, - { sessionID, workspaceDir }, + { sessionID, workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(unsub).toHaveBeenCalledOnce(); @@ -222,7 +222,7 @@ describe("browser_execute core", () => { }); await expect( - executeBrowserCode({ description: "Throw failure", code: `throw new Error("boom");` }, { sessionID, workspaceDir }), + executeBrowserCode({ description: "Throw failure", code: `throw new Error("boom");` }, { sessionID, workspaceDir, profileDir: undefined, launchBrowser: undefined }), ).rejects.toThrow(/boom/); expect(unsub).toHaveBeenCalledOnce(); @@ -236,12 +236,12 @@ describe("browser_execute core", () => { description: "Trigger syntax error", code: "const x = (", }, - { sessionID: trackSession("syntax-session"), workspaceDir }, + { sessionID: trackSession("syntax-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ), ).rejects.toThrow(/syntax error in browser_execute snippet/); }); - it("surfaces runtime failures with stack context", async () => { + it("surfaces runtime failures with clean error messages", async () => { const workspaceDir = await tmp("pi-browser-workspace-"); await expect( executeBrowserCode( @@ -249,9 +249,9 @@ describe("browser_execute core", () => { description: "Trigger runtime error", code: `throw new Error("runtime-boom")`, }, - { sessionID: trackSession("runtime-session"), workspaceDir }, + { sessionID: trackSession("runtime-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ), - ).rejects.toThrow(/browser_execute snippet threw: .*runtime-boom/s); + ).rejects.toThrow(/browser_execute snippet threw: runtime-boom/); }); it("times out snippets that yield", async () => { @@ -263,7 +263,7 @@ describe("browser_execute core", () => { timeout: 10, code: "await new Promise((resolve) => setTimeout(resolve, 100)); return 'late';", }, - { sessionID: trackSession("timeout-session"), workspaceDir }, + { sessionID: trackSession("timeout-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ), ).rejects.toThrow(/browser_execute timed out/); }); @@ -283,7 +283,7 @@ describe("browser_execute core", () => { timeout: MAX_TIMEOUT_MS + 1, code: `return "ok";`, }, - { sessionID: trackSession("max-timeout-session"), workspaceDir }, + { sessionID: trackSession("max-timeout-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(observedDelay).toBe(MAX_TIMEOUT_MS); @@ -306,9 +306,46 @@ describe("browser_execute core", () => { description: "Import workspace helper", code: `const mod = await import(${JSON.stringify(helperPath)} + "?t=" + Date.now()); return mod.answer();`, }, - { sessionID: trackSession("import-session"), workspaceDir }, + { sessionID: trackSession("import-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, ); expect(JSON.parse(result.result)).toBe(42); }); + + it("surfaces ReferenceErrors inside unawaited promises as clean failures", async () => { + const workspaceDir = await tmp("pi-browser-workspace-"); + await expect( + executeBrowserCode( + { + description: "Trigger unawaited promise rejection", + code: ` + new Promise((resolve, reject) => { + const x = document; + }); + return "done"; + `, + }, + { sessionID: trackSession("unawaited-rejection-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, + ), + ).rejects.toThrow(/browser_execute snippet threw: .*document is not defined/); + }); + + it("surfaces ReferenceErrors inside setTimeout as clean failures", async () => { + const workspaceDir = await tmp("pi-browser-workspace-"); + await expect( + executeBrowserCode( + { + description: "Trigger asynchronous timeout error", + code: ` + setTimeout(() => { + const x = MutationObserver; + }, 0); + await new Promise((resolve) => setTimeout(resolve, 10)); + return "done"; + `, + }, + { sessionID: trackSession("async-timeout-error-session"), workspaceDir, profileDir: undefined, launchBrowser: undefined }, + ), + ).rejects.toThrow(/browser_execute snippet threw: .*MutationObserver is not defined/); + }); }); diff --git a/test/cdp-session.test.ts b/test/cdp-session.test.ts index 14a95e2..77b31ce 100644 --- a/test/cdp-session.test.ts +++ b/test/cdp-session.test.ts @@ -1,9 +1,14 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { detectBrowsers, listPageTargets, resolveWsUrl, Session } from "../src/cdp/session.js"; +// Mock child_process for ESM compatibility +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + const tempDirs: string[] = []; async function tmp(prefix: string): Promise { @@ -99,4 +104,62 @@ describe("CDP session helpers", () => { await expect(pagePromise).rejects.toThrow(/CDP socket closed/); await expect(browserPromise).rejects.toThrow(/CDP socket closed/); }); + + it("connect auto-launches Chrome when launchBrowser is true and profileDir is empty", async () => { + const profileDir = await tmp("pi-browser-launch-"); + const session = new Session(); + + // Mock spawn to simulate Chrome starting and writing the port file + const { spawn } = await import("node:child_process"); + const mockChild: any = { + on: vi.fn(), + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + }; + vi.mocked(spawn).mockReturnValue(mockChild); + + // Mock WebSocket connection so it doesn't try to open a real WS connection + const openWsSpy = vi.spyOn(session as any, "openWs").mockResolvedValue(undefined); + + // Simulate Chrome writing the file after a short delay + setTimeout(async () => { + await writeFile(path.join(profileDir, "DevToolsActivePort"), "9222\n/devtools/browser/abc\n", "utf8"); + }, 50); + + await session.connect({ + profileDir, + launchBrowser: true, + timeoutMs: 1000, + }); + + expect(spawn).toHaveBeenCalled(); + expect(openWsSpy).toHaveBeenCalledWith("ws://127.0.0.1:9222/devtools/browser/abc", 1000); + + vi.mocked(spawn).mockReset(); + openWsSpy.mockRestore(); + }); + + it("connect reuses existing Chrome instance if profileDir has active port", async () => { + const profileDir = await tmp("pi-browser-reuse-"); + const session = new Session(); + + const { spawn } = await import("node:child_process"); + const openWsSpy = vi.spyOn(session as any, "openWs").mockResolvedValue(undefined); + + // Write DevToolsActivePort beforehand to simulate a running browser + await writeFile(path.join(profileDir, "DevToolsActivePort"), "9225\n/devtools/browser/xyz\n", "utf8"); + + await session.connect({ + profileDir, + launchBrowser: true, + timeoutMs: 1000, + }); + + // It should connect directly without calling spawn + expect(spawn).not.toHaveBeenCalled(); + expect(openWsSpy).toHaveBeenCalledWith("ws://127.0.0.1:9225/devtools/browser/xyz", 1000); + + vi.mocked(spawn).mockReset(); + openWsSpy.mockRestore(); + }); }); diff --git a/test/integration-web-tools.test.ts b/test/integration-web-tools.test.ts new file mode 100644 index 0000000..6d53ea1 --- /dev/null +++ b/test/integration-web-tools.test.ts @@ -0,0 +1,114 @@ +/** + * Integration tests that connect to a real Chrome browser via CDP. + * + * Chrome must be running with --remote-debugging-port=9333: + * chromium --remote-debugging-port=9333 + * + * Each test navigates to a page and leaves the tab visible for 15 seconds + * so you can visually verify the tab actually opened. + */ + +import { describe, it, expect } from "vitest"; +import { Session } from "../src/cdp/session.js"; + +function getChromeWsUrl(): Promise { + return fetch("http://127.0.0.1:9333/json/version") + .then((res) => res.json()) + .then((data) => { + const wsUrl = (data as { webSocketDebuggerUrl: string }).webSocketDebuggerUrl; + if (!wsUrl) throw new Error("no webSocketDebuggerUrl from Chrome"); + return wsUrl; + }); +} + +describe("integration: real Chrome via CDP", () => { + it( + "connects to Chrome, opens visible tab, navigates to Google, leaves open 15s", + async () => { + const session = new Session(); + const wsUrl = await getChromeWsUrl(); + await session.connect({ wsUrl }); + + // Verify Chrome is alive + const version = await session.domains.Browser.getVersion(); + const productName = (version as { product?: string })?.product; + expect(productName).toMatch(/Chrome|Chromium/i); + + // Open a new visible tab (background: false by default) + const target = await session.domains.Target.createTarget({ url: "about:blank" }); + + // Attach to the new tab with flatten: true so page-level calls work + await session.use(target.targetId); + + // Enable Page domain + await session.domains.Page.enable(); + + // Navigate to Google search + await session.domains.Page.navigate({ url: "https://www.google.com/search?q=ds4+antirez" }); + + // Wait for page to load + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get page title to verify navigation worked + const titleResult = await session.domains.Runtime.evaluate({ + expression: "document.title", + }); + const title = (titleResult as { result?: { value?: string } })?.result?.value; + expect(title).toMatch(/Google/i); + + // Leave tab visible for 15 seconds so you can see it + console.log("[integration] Tab is now visible for 15 seconds..."); + await new Promise((resolve) => setTimeout(resolve, 15000)); + + // Clean up - close the tab + await session.domains.Target.closeTarget({ targetId: target.targetId }); + session.close(); + }, + 60000, // 60s timeout: 2s load + 15s visible + margin + ); + + it( + "navigates to example.com, extracts content, leaves tab open 15s", + async () => { + const session = new Session(); + const wsUrl = await getChromeWsUrl(); + await session.connect({ wsUrl }); + + // Open a new visible tab + const target = await session.domains.Target.createTarget({ url: "about:blank" }); + await session.use(target.targetId); + + // Enable domains + await session.domains.Page.enable(); + + // Navigate to example.com + await session.domains.Page.navigate({ url: "https://example.com" }); + + // Wait for page to load + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get page title + const titleResult = await session.domains.Runtime.evaluate({ + expression: "document.title", + }); + const title = (titleResult as { result?: { value?: string } })?.result?.value; + expect(title).toContain("Example Domain"); + + // Get page content + const contentResult = await session.domains.Runtime.evaluate({ + expression: "document.body.innerText", + }); + const content = (contentResult as { result?: { value?: string } })?.result?.value; + expect(content).toContain("Example Domain"); + + // Leave tab visible for 15 seconds so you can see it + console.log("[integration] Tab is now visible for 15 seconds..."); + await new Promise((resolve) => setTimeout(resolve, 15000)); + + // Clean up - close the tab + await session.domains.Target.closeTarget({ targetId: target.targetId }); + session.close(); + }, + 60000, // 60s timeout + ); +}); diff --git a/test/web-fetch.test.ts b/test/web-fetch.test.ts new file mode 100644 index 0000000..c9ac564 --- /dev/null +++ b/test/web-fetch.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { Session } from "../src/cdp/session.js"; +import { webSearch, webFetch, runPage } from "../src/web-fetch.js"; + +// ============================================================================ +// Constants +// ============================================================================ + +const PROBE_EXPR = "location.href+'\\n'+document.readyState+'\\n'+((document.body&&document.body.innerText)||'').length"; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +type CallRecord = { method: string; params?: Record | undefined }; + +function createMockSession(): { + session: Session & { + _mockCalls: CallRecord[]; + _mockRuntime: { + results: Map; + exceptions: Set; + undefineds: Set; + defaultProbe: (href?: string, ready?: string, textLen?: number) => void; + }; + }; + calls: CallRecord[]; + setProbeResult: (href: string, ready: string, textLen: number) => void; + setRuntimeResult: (expr: string, value: string) => void; + setRuntimeException: (expr: string) => void; + setRuntimeUndefined: (expr: string) => void; +} { + const calls: CallRecord[] = []; + const runtimeResults = new Map(); + const runtimeExceptions = new Set(); + const runtimeUndefineds = new Set(); + let _connected = false; + + const session = new Session(); + const mockSession = session as unknown as Session & { + _mockCalls: CallRecord[]; + _mockRuntime: { + results: Map; + exceptions: Set; + undefineds: Set; + defaultProbe: (href?: string, ready?: string, textLen?: number) => void; + }; + }; + + (session as unknown as Record).isConnected = () => _connected; + (session as unknown as Record).connect = async () => { _connected = true; }; + (session as unknown as Record).use = async (targetId: string) => targetId; + (session as unknown as Record).setActiveSession = () => {}; + + vi.spyOn(session, "_call").mockImplementation(async (method: string, params?: unknown) => { + const rec: CallRecord = { method, params: params as Record | undefined }; + calls.push(rec); + mockSession._mockCalls.push(rec); + + if (method === "Target.createTarget") { + return { targetId: `mock-target-${calls.length}` }; + } + if (method === "Target.closeTarget") { + return {}; + } + if (method === "Page.navigate") { + return { frameId: "mock-frame" }; + } + if (method === "Page.enable" || method === "Runtime.enable") { + return {}; + } + if (method === "Runtime.evaluate") { + const expr = (params as { expression?: string })?.expression ?? ""; + if (runtimeExceptions.has(expr)) { + return { exceptionDetails: { text: "mock error" } }; + } + if (runtimeUndefineds.has(expr)) { + return { result: { type: "undefined" } }; + } + const val = runtimeResults.get(expr); + if (val !== undefined) { + return { result: { type: "string", value: val } }; + } + return { result: { type: "string", value: "" } }; + } + if (method === "Browser.getVersion") { + return { protocolVersion: "1.3", product: "Chrome/124.0" }; + } + return {}; + }); + + function setProbeResult(href: string, ready: string, textLen: number): void { + const raw = `${href}\n${ready}\n${textLen}`; + runtimeResults.set(PROBE_EXPR, raw); + } + + function defaultProbe(href = "https://example.com", ready = "complete", textLen = 100): void { + setProbeResult(href, ready, textLen); + } + + function setRuntimeResult(expr: string, value: string): void { + runtimeResults.set(expr, value); + } + + function setRuntimeException(expr: string): void { + runtimeExceptions.add(expr); + } + + function setRuntimeUndefined(expr: string): void { + runtimeUndefineds.add(expr); + } + + mockSession._mockCalls = calls; + mockSession._mockRuntime = { + results: runtimeResults, + exceptions: runtimeExceptions, + undefineds: runtimeUndefineds, + defaultProbe, + }; + + return { + session: mockSession, + calls, + setProbeResult, + setRuntimeResult, + setRuntimeException, + setRuntimeUndefined, + }; +} + +// ============================================================================ +// runPage tests +// ============================================================================ + +describe("runPage", () => { + it("navigates, extracts content, and closes the tab", async () => { + const { session, calls } = createMockSession(); + const mockExtractJs = `(() => "# Title\\n\\nURL: https://example.com\\n\\n## Content\\nHello world")()`; + session._mockRuntime.results.set(mockExtractJs, "Hello world"); + session._mockRuntime.defaultProbe(); + + const result = await runPage({ + session, + url: "https://example.com", + extractJs: mockExtractJs, + scrollDynamic: false, + keepTabVisibleMs: 0, + }); + + expect(result).toBe("Hello world"); + expect(calls.some((c) => c.method === "Target.createTarget")).toBe(true); + expect(calls.some((c) => c.method === "Page.navigate")).toBe(true); + // closeTarget is scheduled fire-and-forget via setTimeout; not checked here + }); + + it("throws when extraction returns null (undefined result)", async () => { + const { session } = createMockSession(); + session._mockRuntime.undefineds.add("null-extract"); + session._mockRuntime.defaultProbe(); + + await expect( + runPage({ + session, + url: "https://example.com", + extractJs: "null-extract", + scrollDynamic: false, + keepTabVisibleMs: 0, + }), + ).rejects.toThrow("Page extraction returned null"); + }); + + it("throws on JavaScript evaluation error", async () => { + const { session } = createMockSession(); + session._mockRuntime.exceptions.add("boom-extract"); + session._mockRuntime.defaultProbe(); + + await expect( + runPage({ + session, + url: "https://example.com", + extractJs: "boom-extract", + scrollDynamic: false, + keepTabVisibleMs: 0, + }), + ).rejects.toThrow("JavaScript evaluation failed"); + }); + + it("handles consent dialog clicks gracefully", async () => { + const { session } = createMockSession(); + const consentJs = `(() => { + const clean=s=>(s||'').replace(/\\s+/g,' ').trim(); + const pats=[/accept all/i,/i agree/i,/agree/i,/accetta tutto/i,/tout accepter/i,/aceptar todo/i,/alle akzeptieren/i]; + const els=[...document.querySelectorAll('button,[role=button],input[type=submit],a')]; + for (const el of els){const t=clean(el.innerText||el.value||el.textContent); + if(!t)continue; if(pats.some(p=>p.test(t))){el.click(); return 'clicked '+t;}} + return ''; +})()`; + session._mockRuntime.results.set(consentJs, "clicked Accept all"); + session._mockRuntime.defaultProbe("https://www.google.com/search?q=test", "complete", 100); + session._mockRuntime.defaultProbe("https://www.google.com/search?q=test", "complete", 100); + const mockExtractJs = `(() => "# Results")()`; + session._mockRuntime.results.set(mockExtractJs, "# Results"); + + await runPage({ + session, + url: "https://www.google.com/search?q=test", + extractJs: mockExtractJs, + scrollDynamic: false, + keepTabVisibleMs: 0, + }); + + expect(session._mockCalls.some((c) => + (c.params as { expression?: string })?.expression?.includes("accept all"), + )).toBe(true); + }); +}); + +// ============================================================================ +// webSearch tests +// ============================================================================ + +describe("webSearch", () => { + it("returns error when query is empty", async () => { + const result = await webSearch({ query: "" }); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.message).toContain("requires a query parameter"); + }); + + it("returns error when query is whitespace only", async () => { + const result = await webSearch({ query: " " }); + expect("error" in result).toBe(true); + }); + + it("returns error from CDP when browser is unavailable", async () => { + // Mock Session.connect to fail immediately so we don't connect to real Chrome + const { Session } = await import("../src/cdp/session.js"); + const connectSpy = vi.spyOn(Session.prototype, "connect").mockRejectedValue(new Error("CDP not available")); + + const result = await webSearch({ query: "hello" }); + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.message).toContain("failed"); + } + + connectSpy.mockRestore(); + }); + + it("builds correct Google search URL with encoding", async () => { + // Mock Session.connect to fail immediately so we don't connect to real Chrome + const { Session } = await import("../src/cdp/session.js"); + const connectSpy = vi.spyOn(Session.prototype, "connect").mockRejectedValue(new Error("CDP not available")); + + const result = await webSearch({ query: "hello world & foo" }); + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.message).toContain("web_search"); + } + + connectSpy.mockRestore(); + }); +}); + +// ============================================================================ +// webFetch tests +// ============================================================================ + +describe("webFetch", () => { + it("returns error when url is empty", async () => { + const result = await webFetch({ url: "" }); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.message).toContain("requires a url parameter"); + }); + + it("returns error when url is missing", async () => { + const result = await webFetch({ url: "" }); + expect("error" in result).toBe(true); + }); + + it("returns error from CDP when browser is unavailable", async () => { + // Mock Session.connect to fail immediately so we don't connect to real Chrome + const { Session } = await import("../src/cdp/session.js"); + const connectSpy = vi.spyOn(Session.prototype, "connect").mockRejectedValue(new Error("CDP not available")); + + const result = await webFetch({ url: "https://example.com" }); + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.message).toContain("failed"); + } + + connectSpy.mockRestore(); + }); +}); + +// ============================================================================ +// Metadata extraction tests (pure string manipulation, no browser needed) +// ============================================================================ + +describe("metadata extraction", () => { + it("extracts title from heading", () => { + const result = "# My Page Title\n\nURL: https://example.com\n\n## Content"; + const match = result.match(/^# (.+)$/m); + expect(match?.[1]).toBe("My Page Title"); + }); + + it("extracts final URL", () => { + const result = "# Title\n\nURL: https://example.com/page\n\n## Content"; + const match = result.match(/^URL: (.+)$/m); + expect(match?.[1]).toBe("https://example.com/page"); + }); + + it("falls back to original URL when no URL header found", () => { + const result = "# Title\n\n## Content"; + const match = result.match(/^URL: (.+)$/m); + expect(match?.[1] ?? "https://fallback.com").toBe("https://fallback.com"); + }); + + it("counts link lines correctly", () => { + const result = "- [Link 1](http://a.com)\n- [Link 2](http://b.com)\n- [Link 3](http://c.com)"; + const count = (result.match(/^- \[/gm) || []).length; + expect(count).toBe(3); + }); + + it("returns zero links when no links present", () => { + const result = "# Title\n\n## Content\nNo links here"; + const count = (result.match(/^- \[/gm) || []).length; + expect(count).toBe(0); + }); + + it("counts lines", () => { + const result = "line 1\nline 2\nline 3"; + expect(result.split('\n').length).toBe(3); + }); + + it("detects scrolled content", () => { + const result = "scrolled 4 text=1200"; + expect(result.includes("scrolled")).toBe(true); + }); + + it("detects truncated content", () => { + const result = "[Content truncated by browser extractor.]"; + expect(result.includes("Content truncated")).toBe(true); + }); + + it("handles missing title gracefully", () => { + const result = "## Content\nNo title"; + const match = result.match(/^# (.+)$/m); + expect(match?.[1]).toBe(undefined); + expect(match?.[1] ?? "").toBe(""); + }); +});