From 114f727705229fbef614b1a53d45adadb40684be Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Wed, 15 Apr 2026 11:40:05 -0700 Subject: [PATCH] feat: add WebDriver BiDi browser backend (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Depends on #378** (AI SDK crash recovery + element ref map). Merge that first. - **BiDiBrowser**: Generic WebDriver BiDi `AriaBrowser` implementation that controls any BiDi-capable browser over WebSocket. Uses JS evaluation for element interactions (like ExtensionBrowser), not Playwright-specific APIs. - **FoxcloudBrowser**: Extends BiDiBrowser with foxcloud broker session lifecycle (create VM, poll until ready, connect BiDi, park/resume, cleanup). - **BiDiConnection**: Minimal WebSocket client for BiDi protocol (~130 lines) — command/response correlation, event emission, timeouts. - **CLI**: `--browser bidi --bidi-url ws://...` and `--browser foxcloud --foxcloud-url http://...` **Note:** BiDi/foxcloud support is CLI-only for now. The server package still uses PlaywrightBrowser exclusively — adding FoxcloudBrowser as a server backend (one VM per request) is a natural follow-up. PR #182 added support for using WebDriver BiDi *through Playwright* — when `--channel moz-firefox` is specified, Playwright uses the system Firefox and communicates via BiDi under the hood, but Playwright is still the intermediary. This PR is completely independent of Playwright. `BiDiBrowser` speaks raw WebDriver BiDi directly over WebSocket — no Playwright in the chain. This enables connecting to any BiDi endpoint: local Firefox, remote browsers, or cloud browser services like foxcloud-bidi. | | `--channel moz-firefox` (PR #182) | `--browser bidi` (this PR) | |---|---|---| | **Dependency** | Playwright required | No Playwright | | **BiDi connection** | Managed by Playwright | Direct WebSocket | | **Element interaction** | Playwright locator APIs | JS evaluation | | **Browser launch** | Playwright manages it | External (caller provides URL) | | **Use case** | Use system Firefox instead of Playwright's bundled one | Connect to any BiDi endpoint (local, remote, foxcloud) | Two-layer architecture: BiDiBrowser speaks standard W3C WebDriver BiDi (works with any browser), FoxcloudBrowser layers session management on top. The BiDi layer remains useful regardless of what happens to the experimental foxcloud project. Spec: `docs/superpowers/specs/2026-03-27-bidi-browser-design.md` (not committed to this PR) - [x] 29 new unit tests (bidiConnection, bidiBrowser, foxcloudBrowser) — all passing - [x] All tests passing, no regressions - [x] Smoke tested against local Firefox (headless + visible window) - [x] Smoke tested against foxcloud Docker provider - [x] Smoke tested against foxcloud Firecracker provider on GCP - [x] CI green 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 20 + packages/cli/src/commands/run.ts | 87 ++- packages/cli/src/utils.ts | 6 +- packages/cli/test/utils.test.ts | 13 +- packages/core/package.json | 2 + packages/core/src/browser/bidiBrowser.ts | 569 ++++++++++++++++++ packages/core/src/browser/bidiConnection.ts | 196 ++++++ packages/core/src/browser/foxcloudBrowser.ts | 160 +++++ packages/core/src/config/defaults.ts | 42 +- packages/core/src/config/index.ts | 1 + packages/core/src/index.ts | 5 + packages/core/src/telemetry/tracing.ts | 2 + packages/core/test/bidiBrowser.test.ts | 442 ++++++++++++++ packages/core/test/bidiConnection.test.ts | 139 +++++ packages/core/test/config.test.ts | 3 + packages/core/test/foxcloudBrowser.test.ts | 219 +++++++ packages/core/test/smoke-bidi.ts | 54 ++ packages/server/src/taskRunner.test.ts | 1 + packages/server/src/taskRunner.ts | 10 +- .../server/test/sensitive-data-leak.test.ts | 2 + pnpm-lock.yaml | 6 + 21 files changed, 1942 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/browser/bidiBrowser.ts create mode 100644 packages/core/src/browser/bidiConnection.ts create mode 100644 packages/core/src/browser/foxcloudBrowser.ts create mode 100644 packages/core/test/bidiBrowser.test.ts create mode 100644 packages/core/test/bidiConnection.test.ts create mode 100644 packages/core/test/foxcloudBrowser.test.ts create mode 100644 packages/core/test/smoke-bidi.ts diff --git a/README.md b/README.md index e0efb303..bfb7ec29 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,26 @@ try { } ``` +### WebDriver BiDi (experimental) + +Pilo can connect directly to any browser that supports the [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/) protocol, without requiring Playwright. This is distinct from Pilo's `--channel moz-firefox` option, which uses BiDi _through_ Playwright — the `--browser bidi` mode speaks the BiDi protocol directly over WebSocket with no Playwright dependency in the chain. + +Start Firefox with remote debugging enabled: + +```bash +# Headless +firefox --remote-debugging-port 9222 --headless --no-remote --profile "$(mktemp -d)" + +# Visible (watch the agent work) +firefox --remote-debugging-port 9222 --no-remote --profile "$(mktemp -d)" +``` + +Then point Pilo at it: + +```bash +pilo run --browser bidi --bidi-url "ws://127.0.0.1:9222/session" "what's the weather in Tokyo?" +``` + ## Features - 🤖 **Natural Language Control**: Just describe what you want to do in plain English diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index f5bbde3a..6ef02db1 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -199,37 +199,64 @@ async function executeRunCommand(task: string, options: any): Promise { // Create browser instance with navigation retry config // CLI options take precedence over config values - const browser = new PlaywrightBrowser({ - browser: browserOption, - bypassCSP: options.bypassCsp ?? cfg.bypass_csp, - channel: options.channel ?? cfg.channel, - executablePath: options.executablePath ?? cfg.executable_path, - blockAds: options.blockAds ?? cfg.block_ads, - blockResources, - headless: options.headless ?? cfg.headless, - proxyServer: options.proxy ?? cfg.proxy, - proxyUsername: options.proxyUsername ?? cfg.proxy_username, - proxyPassword: options.proxyPassword ?? cfg.proxy_password, - pwEndpoint: options.pwEndpoint ?? cfg.pw_endpoint, - pwCdpEndpoint: options.pwCdpEndpoint ?? cfg.pw_cdp_endpoint, - pwCdpEndpoints: - (options.pwCdpEndpoints as string[] | undefined) ?? - cfg.pw_cdp_endpoints ?? - (cfg.pw_cdp_endpoint ? [cfg.pw_cdp_endpoint] : undefined), - actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms, - navigationRetry: { - baseTimeoutMs: options.navigationTimeoutMs ?? cfg.navigation_timeout_ms, - maxTimeoutMs: options.navigationMaxTimeoutMs ?? cfg.navigation_max_timeout_ms, - maxAttempts: options.navigationMaxAttempts ?? cfg.navigation_max_attempts, - timeoutMultiplier: options.navigationTimeoutMultiplier ?? cfg.navigation_timeout_multiplier, - onRetry: (attempt: number, error: Error, nextTimeout: number) => { - console.log( - chalk.yellow(`⚠️ Navigation retry ${attempt}: ${error.message}`), - chalk.gray(`(next timeout: ${Math.round(nextTimeout / 1000)}s)`), - ); + let browser; + if (browserOption === "bidi") { + const { BiDiBrowser } = await import("pilo-core"); + const bidiUrl = options.bidiUrl ?? cfg.bidi_url; + if (!bidiUrl) { + throw new Error("--bidi-url or PILO_BIDI_URL is required when using --browser bidi"); + } + browser = new BiDiBrowser({ + bidiUrl, + actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms, + }); + } else if (browserOption === "foxcloud") { + const { FoxcloudBrowser } = await import("pilo-core"); + const foxcloudUrl = options.foxcloudUrl ?? cfg.foxcloud_url; + if (!foxcloudUrl) { + throw new Error( + "--foxcloud-url or PILO_FOXCLOUD_URL is required when using --browser foxcloud", + ); + } + browser = new FoxcloudBrowser({ + brokerUrl: foxcloudUrl, + proxyUrl: options.foxcloudProxyUrl ?? cfg.foxcloud_proxy_url, + actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms, + }); + } else { + browser = new PlaywrightBrowser({ + browser: browserOption, + bypassCSP: options.bypassCsp ?? cfg.bypass_csp, + channel: options.channel ?? cfg.channel, + executablePath: options.executablePath ?? cfg.executable_path, + blockAds: options.blockAds ?? cfg.block_ads, + blockResources, + headless: options.headless ?? cfg.headless, + proxyServer: options.proxy ?? cfg.proxy, + proxyUsername: options.proxyUsername ?? cfg.proxy_username, + proxyPassword: options.proxyPassword ?? cfg.proxy_password, + pwEndpoint: options.pwEndpoint ?? cfg.pw_endpoint, + pwCdpEndpoint: options.pwCdpEndpoint ?? cfg.pw_cdp_endpoint, + pwCdpEndpoints: + (options.pwCdpEndpoints as string[] | undefined) ?? + cfg.pw_cdp_endpoints ?? + (cfg.pw_cdp_endpoint ? [cfg.pw_cdp_endpoint] : undefined), + actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms, + navigationRetry: { + baseTimeoutMs: options.navigationTimeoutMs ?? cfg.navigation_timeout_ms, + maxTimeoutMs: options.navigationMaxTimeoutMs ?? cfg.navigation_max_timeout_ms, + maxAttempts: options.navigationMaxAttempts ?? cfg.navigation_max_attempts, + timeoutMultiplier: + options.navigationTimeoutMultiplier ?? cfg.navigation_timeout_multiplier, + onRetry: (attempt: number, error: Error, nextTimeout: number) => { + console.log( + chalk.yellow(`⚠️ Navigation retry ${attempt}: ${error.message}`), + chalk.gray(`(next timeout: ${Math.round(nextTimeout / 1000)}s)`), + ); + }, }, - }, - }); + }); + } // Create AI provider with CLI overrides (only pass if explicitly set on CLI) // Unlike other options, we use explicit undefined checks here because diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 3073728e..caa87867 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; +import { BROWSERS } from "pilo-core"; /** * CLI-specific utilities and helpers @@ -47,15 +48,14 @@ export function getPackageInfo(): { version: string; name: string; description: * Validate browser option */ export function validateBrowser(browser: string): boolean { - const validBrowsers = ["firefox", "chrome", "chromium", "safari", "webkit", "edge"]; - return validBrowsers.includes(browser); + return (BROWSERS as readonly string[]).includes(browser); } /** * Get list of valid browsers */ export function getValidBrowsers(): string[] { - return ["firefox", "chrome", "chromium", "safari", "webkit", "edge"]; + return [...BROWSERS]; } /** diff --git a/packages/cli/test/utils.test.ts b/packages/cli/test/utils.test.ts index 78288f9c..d42410eb 100644 --- a/packages/cli/test/utils.test.ts +++ b/packages/cli/test/utils.test.ts @@ -28,8 +28,17 @@ describe("CLI Utils", () => { describe("getValidBrowsers", () => { it("should return array of valid browsers", () => { const browsers = getValidBrowsers(); - expect(browsers).toEqual(["firefox", "chrome", "chromium", "safari", "webkit", "edge"]); - expect(browsers.length).toBe(6); + expect(browsers).toEqual([ + "firefox", + "chrome", + "chromium", + "safari", + "webkit", + "edge", + "bidi", + "foxcloud", + ]); + expect(browsers.length).toBe(8); }); }); diff --git a/packages/core/package.json b/packages/core/package.json index f0a56f00..4dc122d0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,6 +48,7 @@ "ollama-ai-provider-v2": "^3.5.0", "playwright": "1.60.0", "turndown": "^7.2.2", + "ws": "^8.20.0", "zod": "^4.3.6" }, "peerDependencies": { @@ -63,6 +64,7 @@ "@types/jsdom": "^28.0.1", "@types/node": "^25.7.0", "@types/turndown": "^5.0.6", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.6", "esbuild": "^0.28.0", "jsdom": "^29.0.2", diff --git a/packages/core/src/browser/bidiBrowser.ts b/packages/core/src/browser/bidiBrowser.ts new file mode 100644 index 00000000..a17c6296 --- /dev/null +++ b/packages/core/src/browser/bidiBrowser.ts @@ -0,0 +1,569 @@ +import TurndownService from "turndown"; +import { AriaBrowser, PageAction, LoadState, TemporaryTab } from "./ariaBrowser.js"; +import { BiDiConnection } from "./bidiConnection.js"; +import { ARIA_TREE_SCRIPT } from "./ariaTree/bundle.js"; +import { BrowserActionException, InvalidRefException } from "../errors.js"; +import { withSpan, SpanStatusCode, SpanName } from "../telemetry/tracing.js"; + +const PAGE_SETTLE_TIME_MS = 1000; +const NETWORKIDLE_DELAY_MS = 500; + +export interface BiDiBrowserOptions { + /** WebDriver BiDi WebSocket URL (can be provided later in start()) */ + bidiUrl?: string; + /** Timeout for browser actions in milliseconds (default: 30000) */ + actionTimeoutMs?: number; +} + +/** + * Unwraps a WebDriver BiDi typed value to its JavaScript equivalent. + * e.g. {type: "string", value: "hello"} → "hello" + */ +export function unwrapBiDiValue(val: unknown): unknown { + if (typeof val !== "object" || val === null) return val; + const typed = val as Record; + switch (typed.type) { + case "string": + case "number": + case "boolean": + return typed.value; + case "null": + return null; + case "undefined": + return undefined; + default: + return val; + } +} + +/** + * BiDiBrowser — AriaBrowser implementation using WebDriver BiDi protocol. + */ +export class BiDiBrowser implements AriaBrowser { + public readonly browserName: string = "bidi"; + + protected connection: BiDiConnection; + protected currentContext: string | null = null; + + private readonly actionTimeoutMs: number; + private bidiUrl: string | undefined; + protected turndown: TurndownService; + + constructor(options: BiDiBrowserOptions = {}) { + this.bidiUrl = options.bidiUrl; + this.actionTimeoutMs = options.actionTimeoutMs ?? 30_000; + this.turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + emDelimiter: "_", + strongDelimiter: "**", + }); + // BiDiConnection requires a URL at construction time; we may not have it yet. + // We construct it with a placeholder and call setUrl() in start() if needed. + this.connection = new BiDiConnection(this.bidiUrl ?? "", this.actionTimeoutMs); + } + + async start(bidiUrl?: string): Promise { + // Accept a URL override (used by FoxcloudBrowser which sets URL after session creation) + if (bidiUrl) { + this.bidiUrl = bidiUrl; + this.connection.setUrl(bidiUrl); + } + + if (!this.bidiUrl) { + throw new Error("BiDiBrowser: bidiUrl must be provided at construction or in start()"); + } + + await this.connection.connect(); + await this.connection.sendCommand("session.new", { capabilities: {} }); + + const treeResult = (await this.connection.sendCommand("browsingContext.getTree", {})) as { + contexts: Array<{ context: string; url: string; children: unknown[] }>; + }; + + if (treeResult?.contexts?.length > 0) { + this.currentContext = treeResult.contexts[0].context; + } else { + throw new Error("BiDiBrowser: no browsing contexts available after session.new"); + } + } + + async shutdown(): Promise { + // End the BiDi session before closing the WebSocket. + // Without this, Firefox keeps the session alive and blocks new ones. + try { + await this.connection.sendCommand("session.end", {}); + } catch { + // Best effort — connection may already be closed + } + this.connection.close(); + this.currentContext = null; + } + + async goto(url: string): Promise { + return withSpan( + SpanName.BROWSER_NAVIGATE, + { attributes: { "pilo.browser.url": url, "pilo.browser.backend": "bidi" } }, + async (span) => { + try { + const context = this.requireContext(); + await this.connection.sendCommand("browsingContext.navigate", { + context, + url, + wait: "complete", + }); + await this.ensureOptimizedPageLoad(); + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + span.recordException(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }, + ); + } + + async goBack(): Promise { + const context = this.requireContext(); + await this.connection.sendCommand("browsingContext.traverseHistory", { + context, + delta: -1, + }); + await this.ensureOptimizedPageLoad(); + } + + async goForward(): Promise { + const context = this.requireContext(); + await this.connection.sendCommand("browsingContext.traverseHistory", { + context, + delta: 1, + }); + await this.ensureOptimizedPageLoad(); + } + + async getUrl(): Promise { + const result = await this.evaluate("document.location.href"); + return unwrapBiDiValue(result) as string; + } + + async getTitle(): Promise { + const result = await this.evaluate("document.title"); + return unwrapBiDiValue(result) as string; + } + + async getTreeWithRefs(): Promise { + return withSpan(SpanName.BROWSER_SNAPSHOT, {}, async (span) => { + try { + this.requireContext(); + + const result = await this.evaluate(` + (() => { + const win = window; + if (!win.__piloAriaTree) { + const fn = new Function(${JSON.stringify(ARIA_TREE_SCRIPT)}); + fn(); + win.__piloAriaTree = globalThis.__piloAriaTree; + } + if (typeof win.__piloAriaTree?.generateAndRenderAriaTree !== 'function') { + throw new Error('ARIA tree script not available'); + } + return win.__piloAriaTree.generateAndRenderAriaTree(document.body); + })() + `); + + const yaml = unwrapBiDiValue(result); + if (typeof yaml !== "string") { + throw new BrowserActionException( + "getTreeWithRefs", + `ARIA tree generation did not return a string (got ${typeof yaml}: ${JSON.stringify(result).substring(0, 200)})`, + ); + } + return yaml; + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + span.recordException(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }); + } + + async getMarkdown(): Promise { + this.requireContext(); + + const result = await this.evaluate(` + (() => { + const clone = (document.body || document.documentElement).cloneNode(true); + clone.querySelectorAll('head, script, style, noscript').forEach(el => el.remove()); + return clone.innerHTML; + })() + `); + + const html = unwrapBiDiValue(result); + if (typeof html !== "string") { + return ""; + } + return this.turndown.turndown(html); + } + + async getScreenshot(options?: { withMarks?: boolean }): Promise { + return withSpan(SpanName.BROWSER_SCREENSHOT, {}, async (span) => { + try { + this.requireContext(); + + if (options?.withMarks) { + try { + await this.evaluate(` + (() => { + const win = window; + win.__piloAriaTree?.applySetOfMarks?.(); + })() + `); + } catch { + // Non-fatal + } + } + + try { + const result = (await this.connection.sendCommand("browsingContext.captureScreenshot", { + context: this.currentContext, + })) as { data?: string }; + + if (!result.data) { + throw new BrowserActionException("getScreenshot", "captureScreenshot returned no data"); + } + + return Buffer.from(result.data, "base64"); + } finally { + if (options?.withMarks) { + try { + await this.evaluate(` + (() => { + const win = window; + win.__piloAriaTree?.removeSetOfMarks?.(); + })() + `); + } catch { + // Non-fatal + } + } + } + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + span.recordException(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }); + } + + async performAction(ref: string, action: PageAction, value?: string): Promise { + return withSpan( + SpanName.BROWSER_PERFORM, + { + attributes: { + "pilo.browser.action_type": String(action), + ...(ref && { "pilo.browser.element_ref": ref }), + }, + }, + async (span) => { + try { + await this.performActionImpl(ref, action, value); + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + span.recordException(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }, + ); + } + + private async performActionImpl(ref: string, action: PageAction, value?: string): Promise { + // Non-element actions + switch (action) { + case PageAction.Wait: { + if (value == null) { + throw new BrowserActionException("wait", "PageAction.Wait requires a number of seconds"); + } + const seconds = parseFloat(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new BrowserActionException( + "wait", + `PageAction.Wait requires a positive number of seconds, got "${value}"`, + ); + } + await this.evaluate(`new Promise(resolve => setTimeout(resolve, ${seconds * 1000}))`); + return; + } + case PageAction.Goto: { + const trimmedUrl = value?.trim(); + if (!trimmedUrl) { + throw new BrowserActionException("goto", "PageAction.Goto requires a non-empty URL"); + } + await this.goto(trimmedUrl); + return; + } + case PageAction.Back: + await this.goBack(); + return; + case PageAction.Forward: + await this.goForward(); + return; + case PageAction.Done: + case PageAction.Abort: + case PageAction.Extract: + return; // Handled by agent layer + } + + // Element actions — resolve the element first, then dispatch the specific action. + const jsRef = JSON.stringify(ref); + const jsValue = JSON.stringify(value ?? ""); + + // Find element using ref map (survives DOM re-renders) with attribute fallback + const found = unwrapBiDiValue( + await this.evaluate(` + (() => { + const refMap = globalThis.__piloRefMap; + let el = refMap?.get(${jsRef}); + // Verify the ref map element is still in the document + if (el && !el.isConnected) el = null; + // Fallback to attribute selector + if (!el) el = document.querySelector('[data-pilo-ref=' + ${jsRef} + ']'); + if (!el) return false; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return true; + })() + `), + ); + if (found !== true) { + throw new InvalidRefException(ref); + } + + // Build the action-specific JS to run on the already-located element. + // Uses the same ref map → attribute fallback strategy. + const elQuery = `(globalThis.__piloRefMap?.get(${jsRef}) ?? document.querySelector('[data-pilo-ref=' + ${jsRef} + ']'))`; + const actionScripts: Record = { + [PageAction.Click]: `${elQuery}.click()`, + [PageAction.Hover]: `${elQuery}.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))`, + [PageAction.Fill]: `(() => { + const el = ${elQuery}; + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + el.value = ${jsValue}; + } else { + el.textContent = ${jsValue}; + } + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + })()`, + [PageAction.Focus]: `${elQuery}.focus()`, + [PageAction.Check]: `(() => { + const el = ${elQuery}; + if (el instanceof HTMLInputElement) { el.checked = true; el.dispatchEvent(new Event('change', { bubbles: true })); } + })()`, + [PageAction.Uncheck]: `(() => { + const el = ${elQuery}; + if (el instanceof HTMLInputElement) { el.checked = false; el.dispatchEvent(new Event('change', { bubbles: true })); } + })()`, + [PageAction.Select]: `(() => { + const el = ${elQuery}; + if (el instanceof HTMLSelectElement) { el.value = ${jsValue}; el.dispatchEvent(new Event('change', { bubbles: true })); } + })()`, + [PageAction.Enter]: `(() => { + const el = ${elQuery}; + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true })); + })()`, + }; + + const script = actionScripts[action]; + if (!script) { + throw new BrowserActionException(action, `Unknown element action: ${action}`); + } + + await this.evaluate(script); + + // Post-action page load for interactive actions + if ( + action === PageAction.Click || + action === PageAction.Select || + action === PageAction.Enter + ) { + await this.ensureOptimizedPageLoad(); + } + } + + async getRefIdentity(ref: string): Promise<{ role: string; name: string } | null> { + if (!this.currentContext) return null; + try { + const jsRef = JSON.stringify(ref); + const raw = unwrapBiDiValue( + await this.evaluate(` + (() => { + const entry = globalThis.__piloIdentityMap?.get(${jsRef}); + return entry ? JSON.stringify({ role: entry.role, name: entry.name }) : null; + })() + `), + ); + if (typeof raw !== "string") return null; + const parsed = JSON.parse(raw) as { role?: unknown; name?: unknown }; + if (typeof parsed.role === "string" && typeof parsed.name === "string") { + return { role: parsed.role, name: parsed.name }; + } + return null; + } catch { + // The page may have navigated or torn down the identity map. Identity + // is advisory for repetition detection — log nothing, just bail out. + return null; + } + } + + async waitForLoadState(state: LoadState, options?: { timeout?: number }): Promise { + this.requireContext(); + await this.waitForLoadStateInContext(state, this.currentContext!, options); + } + + /** + * Shared waitForLoadState logic that accepts an explicit context. + * Used by both the main waitForLoadState and runInTemporaryTab. + */ + private async waitForLoadStateInContext( + state: LoadState, + context: string, + options?: { timeout?: number }, + ): Promise { + const timeout = options?.timeout ?? this.actionTimeoutMs; + + await this.evaluate( + ` + new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => reject(new Error('Timeout waiting for ${state} after ${timeout}ms')), ${timeout}); + const finish = () => { clearTimeout(timeoutId); resolve(true); }; + + ${ + state === LoadState.DOMContentLoaded + ? ` + if (document.readyState === 'interactive' || document.readyState === 'complete') { + finish(); + } else { + document.addEventListener('DOMContentLoaded', finish, { once: true }); + }` + : state === LoadState.Load + ? ` + if (document.readyState === 'complete') { + finish(); + } else { + window.addEventListener('load', finish, { once: true }); + }` + : /* NetworkIdle */ ` + if (document.readyState === 'complete') { + setTimeout(finish, ${NETWORKIDLE_DELAY_MS}); + } else { + window.addEventListener('load', () => setTimeout(finish, ${NETWORKIDLE_DELAY_MS}), { once: true }); + }` + } + }) + `, + context, + ); + } + + async runInTemporaryTab(fn: (tab: TemporaryTab) => Promise): Promise { + this.requireContext(); + + const createResult = (await this.connection.sendCommand("browsingContext.create", { + type: "tab", + })) as { context: string }; + + const tempContext = createResult.context; + + try { + const tab: TemporaryTab = { + goto: async (url: string) => { + await this.connection.sendCommand("browsingContext.navigate", { + context: tempContext, + url, + wait: "complete", + }); + }, + getMarkdown: async () => { + const result = await this.evaluate( + `(() => { + const clone = (document.body || document.documentElement).cloneNode(true); + clone.querySelectorAll('head, script, style, noscript').forEach(el => el.remove()); + return clone.innerHTML; + })()`, + tempContext, + ); + const html = unwrapBiDiValue(result); + return typeof html === "string" ? this.turndown.turndown(html) : ""; + }, + waitForLoadState: async (state: LoadState, options?: { timeout?: number }) => { + await this.waitForLoadStateInContext(state, tempContext, options); + }, + }; + + return await fn(tab); + } finally { + try { + await this.connection.sendCommand("browsingContext.close", { + context: tempContext, + }); + } catch { + // Ignore close errors + } + } + } + + /** + * Evaluates a JavaScript expression in the given browsing context (defaults to current context). + */ + protected async evaluate(expression: string, context?: string): Promise { + const targetContext = context ?? this.requireContext(); + const result = (await this.connection.sendCommand("script.evaluate", { + expression, + target: { context: targetContext }, + awaitPromise: true, + })) as { result: unknown }; + return result?.result; + } + + /** + * Returns the current browsing context ID, throwing if none is set. + */ + protected requireContext(): string { + if (!this.currentContext) { + throw new Error("BiDiBrowser: no active browsing context"); + } + return this.currentContext; + } + + /** + * Waits for the page to settle after navigation. + */ + protected async ensureOptimizedPageLoad(): Promise { + try { + await this.waitForLoadState(LoadState.DOMContentLoaded, { + timeout: this.actionTimeoutMs, + }); + } catch { + // Continue anyway + } + try { + await this.waitForLoadState(LoadState.Load, { + timeout: this.actionTimeoutMs, + }); + } catch { + // Continue anyway + } + await new Promise((resolve) => setTimeout(resolve, PAGE_SETTLE_TIME_MS)); + } +} diff --git a/packages/core/src/browser/bidiConnection.ts b/packages/core/src/browser/bidiConnection.ts new file mode 100644 index 00000000..f638e621 --- /dev/null +++ b/packages/core/src/browser/bidiConnection.ts @@ -0,0 +1,196 @@ +import WebSocket from "ws"; +import { EventEmitter } from "node:events"; +import { withSpan, SpanStatusCode, SpanName } from "../telemetry/tracing.js"; + +interface PendingCommand { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +interface BiDiResponse { + id: number; + type: "success" | "error"; + result?: unknown; + error?: string; + message?: string; +} + +interface BiDiEvent { + type: "event"; + method: string; + params: Record; +} + +/** + * Minimal WebDriver BiDi WebSocket client. + * + * Sends commands as {id, method, params}, correlates responses by id, + * and emits unsolicited events for listeners. + */ +export class BiDiConnection extends EventEmitter { + private ws: WebSocket | null = null; + private nextId = 1; + private pending = new Map(); + private readonly defaultTimeoutMs: number; + + constructor( + private url: string, + defaultTimeoutMs = 30_000, + ) { + super(); + this.defaultTimeoutMs = defaultTimeoutMs; + } + + /** Open the WebSocket connection. */ + connect(timeoutMs = 10_000): Promise { + return withSpan(SpanName.BIDI_CONNECT, { attributes: { "pilo.bidi.url": this.url } }, (span) => + this.connectImpl(timeoutMs, span), + ); + } + + private connectImpl( + timeoutMs: number, + span: { + setStatus: (s: { code: number; message?: string }) => void; + recordException: (e: Error | string) => void; + }, + ): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.url); + let settled = false; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + + const rejectWithSpan = (error: Error) => { + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); + span.recordException(error); + reject(error); + }; + + const timer = setTimeout(() => { + ws.close(); + settle(() => rejectWithSpan(new Error(`WebSocket connect timeout after ${timeoutMs}ms`))); + }, timeoutMs); + + ws.on("open", () => { + settle(() => { + this.ws = ws; + this.setupHandlers(); + resolve(); + }); + }); + + ws.on("error", (err) => { + settle(() => rejectWithSpan(err instanceof Error ? err : new Error(String(err)))); + }); + + ws.on("close", () => { + settle(() => rejectWithSpan(new Error("WebSocket closed before connection established"))); + }); + }); + } + + /** Send a BiDi command and await the correlated response. */ + sendCommand( + method: string, + params: Record = {}, + timeoutMs?: number, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("WebSocket is not connected")); + } + + const id = this.nextId++; + const timeout = timeoutMs ?? this.defaultTimeoutMs; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject( + new Error(`Timeout waiting for response to ${method} (id=${id}) after ${timeout}ms`), + ); + }, timeout); + + this.pending.set(id, { resolve, reject, timer }); + try { + this.ws!.send(JSON.stringify({ id, method, params })); + } catch (err) { + clearTimeout(timer); + this.pending.delete(id); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + /** Close the connection and reject all pending commands. */ + close(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.rejectAllPending("Connection closed"); + } + + get isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + /** Update the URL for reconnection (used by BiDiBrowser). */ + setUrl(url: string): void { + this.url = url; + } + + private setupHandlers(): void { + const ws = this.ws!; + + ws.on("message", (data) => { + let msg: Record; + try { + msg = JSON.parse(data.toString()); + } catch { + return; + } + + if (typeof msg.id !== "number") { + this.emit("event", msg as unknown as BiDiEvent); + return; + } + + const response = msg as unknown as BiDiResponse; + const entry = this.pending.get(response.id); + if (!entry) return; + + clearTimeout(entry.timer); + this.pending.delete(response.id); + + if (response.type === "error") { + entry.reject(new Error(`BiDi error: ${response.error} — ${response.message}`)); + } else { + entry.resolve(response.result); + } + }); + + ws.on("close", () => { + this.rejectAllPending("WebSocket closed unexpectedly"); + this.ws = null; + }); + + ws.on("error", (err) => { + this.rejectAllPending(`WebSocket error: ${err.message}`); + }); + } + + private rejectAllPending(reason: string): void { + for (const [, entry] of this.pending) { + clearTimeout(entry.timer); + entry.reject(new Error(reason)); + } + this.pending.clear(); + } +} diff --git a/packages/core/src/browser/foxcloudBrowser.ts b/packages/core/src/browser/foxcloudBrowser.ts new file mode 100644 index 00000000..c2e5340d --- /dev/null +++ b/packages/core/src/browser/foxcloudBrowser.ts @@ -0,0 +1,160 @@ +/** + * Client for foxcloud-bidi, an experimental proof-of-concept service that runs + * headless Firefox instances controlled via WebDriver BiDi. There are no plans + * for public release or availability of the foxcloud service. But, this client + * could serve as a useful reference for implementing something similar. + */ + +import { BiDiBrowser } from "./bidiBrowser.js"; +import type { BiDiBrowserOptions } from "./bidiBrowser.js"; +import { withSpan, SpanStatusCode, SpanName } from "../telemetry/tracing.js"; + +export interface FoxcloudBrowserOptions extends Omit { + /** foxcloud broker REST endpoint, e.g. "http://localhost:8080" */ + brokerUrl: string; + /** HTTP proxy URL for Firefox to use, e.g. "http://user:pass@proxy:8080" */ + proxyUrl?: string; + /** How long to wait for session to reach RUNNING state. Default 60000ms. */ + sessionPollTimeoutMs?: number; + /** Polling interval when waiting for session. Default 1000ms. */ + sessionPollIntervalMs?: number; +} + +export class FoxcloudBrowser extends BiDiBrowser { + override readonly browserName = "foxcloud"; + + private readonly brokerUrl: string; + private readonly proxyUrl?: string; + private readonly sessionPollTimeoutMs: number; + private readonly sessionPollIntervalMs: number; + private sessionId: string | null = null; + + constructor(options: FoxcloudBrowserOptions) { + super({ actionTimeoutMs: options.actionTimeoutMs }); + // Strip a single trailing slash if present + this.brokerUrl = options.brokerUrl.endsWith("/") + ? options.brokerUrl.slice(0, -1) + : options.brokerUrl; + this.proxyUrl = options.proxyUrl; + this.sessionPollTimeoutMs = options.sessionPollTimeoutMs ?? 60_000; + this.sessionPollIntervalMs = options.sessionPollIntervalMs ?? 1_000; + } + + override async start(): Promise { + return withSpan( + SpanName.FOXCLOUD_START, + { attributes: { "pilo.foxcloud.broker_url": this.brokerUrl } }, + async (span) => { + try { + const fetchOptions: RequestInit = { method: "POST" }; + if (this.proxyUrl) { + fetchOptions.headers = { "Content-Type": "application/json" }; + fetchOptions.body = JSON.stringify({ proxy_url: this.proxyUrl }); + } + + const createResp = await fetch(`${this.brokerUrl}/v1/sessions`, fetchOptions); + if (!createResp.ok) { + const body = await createResp.text(); + throw new Error(`Failed to create foxcloud session (${createResp.status}): ${body}`); + } + const session = (await createResp.json()) as { id: string; state: string }; + this.sessionId = session.id; + span.setAttribute("pilo.foxcloud.session_id", session.id); + + if (session.state !== "RUNNING") { + await this.pollUntilRunning(); + } + + const brokerUrlObj = new URL(this.brokerUrl); + const wsProtocol = brokerUrlObj.protocol === "https:" ? "wss:" : "ws:"; + const bidiUrl = `${wsProtocol}//${brokerUrlObj.host}/v1/sessions/${this.sessionId}/bidi`; + + try { + await super.start(bidiUrl); + } catch (error) { + // BiDi connection failed — clean up the foxcloud session to avoid leaks + await this.deleteSession(); + throw error; + } + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + span.recordException(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }, + ); + } + + override async shutdown(): Promise { + await super.shutdown(); + await this.deleteSession(); + } + + /** Best-effort session cleanup via REST API. */ + private async deleteSession(): Promise { + if (!this.sessionId) return; + const id = this.sessionId; + this.sessionId = null; + try { + await fetch(`${this.brokerUrl}/v1/sessions/${id}`, { method: "DELETE" }); + } catch { + // Best effort — broker may be unreachable + } + } + + async park(): Promise { + if (!this.sessionId) throw new Error("No active session to park"); + + const resp = await fetch(`${this.brokerUrl}/v1/sessions/${this.sessionId}/park`, { + method: "POST", + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Park failed (${resp.status}): ${body}`); + } + + this.connection.close(); + } + + async resume(): Promise { + if (!this.sessionId) throw new Error("No active session to resume"); + + const resp = await fetch(`${this.brokerUrl}/v1/sessions/${this.sessionId}/resume`, { + method: "POST", + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Resume failed (${resp.status}): ${body}`); + } + + const brokerUrlObj = new URL(this.brokerUrl); + const wsProtocol = brokerUrlObj.protocol === "https:" ? "wss:" : "ws:"; + const bidiUrl = `${wsProtocol}//${brokerUrlObj.host}/v1/sessions/${this.sessionId}/bidi`; + + await super.start(bidiUrl); + } + + private async pollUntilRunning(): Promise { + const deadline = Date.now() + this.sessionPollTimeoutMs; + + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, this.sessionPollIntervalMs)); + + const resp = await fetch(`${this.brokerUrl}/v1/sessions/${this.sessionId}`); + if (!resp.ok) continue; + + const session = (await resp.json()) as { state: string }; + if (session.state === "RUNNING") return; + if (session.state === "TERMINATED") { + throw new Error("foxcloud session terminated during creation"); + } + } + + throw new Error( + `foxcloud session did not reach RUNNING state within ${this.sessionPollTimeoutMs}ms`, + ); + } +} diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index a38a1091..29da4b5b 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -23,7 +23,17 @@ export const PROVIDERS = [ ] as const; export type Provider = (typeof PROVIDERS)[number]; -export const BROWSERS = ["firefox", "chrome", "chromium", "safari", "webkit", "edge"] as const; +export const PLAYWRIGHT_BROWSERS = [ + "firefox", + "chrome", + "chromium", + "safari", + "webkit", + "edge", +] as const; +export type PlaywrightBrowser = (typeof PLAYWRIGHT_BROWSERS)[number]; + +export const BROWSERS = [...PLAYWRIGHT_BROWSERS, "bidi", "foxcloud"] as const; export type Browser = (typeof BROWSERS)[number]; export const REASONING_LEVELS = ["none", "low", "medium", "high"] as const; @@ -70,6 +80,9 @@ export interface PiloConfig { // Browser Configuration browser?: Browser; + bidi_url?: string; + foxcloud_url?: string; + foxcloud_proxy_url?: string; channel?: string; executable_path?: string; headless?: boolean; @@ -139,6 +152,9 @@ export interface PiloConfigResolved { // Browser Configuration browser: Browser; + bidi_url?: string; + foxcloud_url?: string; + foxcloud_proxy_url?: string; channel?: string; executable_path?: string; headless: boolean; @@ -316,6 +332,30 @@ export const FIELDS: Record = { description: "Browser to use", category: "browser", }, + bidi_url: { + type: "string", + cli: "--bidi-url", + placeholder: "url", + env: ["PILO_BIDI_URL"], + description: "WebSocket URL for BiDi browser (use with --browser bidi)", + category: "browser", + }, + foxcloud_url: { + type: "string", + cli: "--foxcloud-url", + placeholder: "url", + env: ["PILO_FOXCLOUD_URL"], + description: "foxcloud broker URL (use with --browser foxcloud)", + category: "browser", + }, + foxcloud_proxy_url: { + type: "string", + cli: "--foxcloud-proxy-url", + placeholder: "url", + env: ["PILO_FOXCLOUD_PROXY_URL"], + description: "HTTP proxy URL for foxcloud Firefox instances", + category: "browser", + }, channel: { type: "string", cli: "--channel", diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 0bb3d129..c0cb0232 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -9,6 +9,7 @@ export { PROVIDERS, BROWSERS, + PLAYWRIGHT_BROWSERS, REASONING_LEVELS, LOGGERS, SEARCH_PROVIDERS, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8657a0b2..4a08bdf5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,10 @@ export type { PlaywrightBrowserOptions, ExtendedPlaywrightBrowserOptions, } from "./browser/playwrightBrowser.js"; +export { BiDiBrowser } from "./browser/bidiBrowser.js"; +export type { BiDiBrowserOptions } from "./browser/bidiBrowser.js"; +export { FoxcloudBrowser } from "./browser/foxcloudBrowser.js"; +export type { FoxcloudBrowserOptions } from "./browser/foxcloudBrowser.js"; export { ChalkConsoleLogger } from "./loggers/chalkConsole.js"; // Additional loggers (not in core.ts re-exports) @@ -40,6 +44,7 @@ export { DEFAULTS, PROVIDERS, BROWSERS, + PLAYWRIGHT_BROWSERS, REASONING_LEVELS, LOGGERS, getSchemaField, diff --git a/packages/core/src/telemetry/tracing.ts b/packages/core/src/telemetry/tracing.ts index e46153d4..fbda3901 100644 --- a/packages/core/src/telemetry/tracing.ts +++ b/packages/core/src/telemetry/tracing.ts @@ -47,6 +47,8 @@ export const SpanName = { BROWSER_PERFORM: "pilo.browser.perform", BROWSER_ACTION: "pilo.browser.action", BROWSER_RECONNECT: "pilo.browser.reconnect", + BIDI_CONNECT: "pilo.bidi.connect", + FOXCLOUD_START: "pilo.foxcloud.start", SEARCH_EXECUTE: "pilo.search.execute", } as const; diff --git a/packages/core/test/bidiBrowser.test.ts b/packages/core/test/bidiBrowser.test.ts new file mode 100644 index 00000000..a9da4741 --- /dev/null +++ b/packages/core/test/bidiBrowser.test.ts @@ -0,0 +1,442 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { BiDiBrowser, unwrapBiDiValue } from "../src/browser/bidiBrowser.js"; +import { PageAction, LoadState } from "../src/browser/ariaBrowser.js"; + +vi.mock("../src/browser/bidiConnection.js", () => { + const MockBiDiConnection = vi.fn(function (this: any) { + this.connect = vi.fn().mockResolvedValue(undefined); + this.close = vi.fn(); + this.sendCommand = vi.fn().mockResolvedValue(undefined); + this.isConnected = true; + this.setUrl = vi.fn(); + this.on = vi.fn(); + this.off = vi.fn(); + this.removeAllListeners = vi.fn(); + }); + return { BiDiConnection: MockBiDiConnection }; +}); + +function getMockConnection(browser: BiDiBrowser) { + return (browser as any).connection; +} + +// Helper to start a browser with a mocked sendCommand that handles session.new and browsingContext.getTree +async function startBrowser(browser: BiDiBrowser) { + const conn = getMockConnection(browser); + conn.sendCommand.mockImplementation((method: string) => { + if (method === "session.new") return Promise.resolve({}); + if (method === "browsingContext.getTree") + return Promise.resolve({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + return Promise.resolve(undefined); + }); + await browser.start(); + conn.sendCommand.mockReset(); +} + +describe("BiDiBrowser", () => { + describe("constructor", () => { + it("sets browserName to 'bidi'", () => { + const browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + expect(browser.browserName).toBe("bidi"); + }); + }); + + describe("start", () => { + it("connects and discovers browsing context", async () => { + const browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + const conn = getMockConnection(browser); + conn.sendCommand.mockImplementation((method: string) => { + if (method === "session.new") return Promise.resolve({}); + if (method === "browsingContext.getTree") + return Promise.resolve({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + return Promise.resolve(undefined); + }); + + await browser.start(); + + expect(conn.connect).toHaveBeenCalled(); + expect(conn.sendCommand).toHaveBeenCalledWith("session.new", { capabilities: {} }); + expect(conn.sendCommand).toHaveBeenCalledWith("browsingContext.getTree", {}); + expect((browser as any).currentContext).toBe("ctx-1"); + }); + + it("accepts bidiUrl override in start()", async () => { + const browser = new BiDiBrowser(); + const conn = getMockConnection(browser); + conn.sendCommand.mockImplementation((method: string) => { + if (method === "session.new") return Promise.resolve({}); + if (method === "browsingContext.getTree") + return Promise.resolve({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + return Promise.resolve(undefined); + }); + + await browser.start("ws://localhost:9222"); + + expect(conn.setUrl).toHaveBeenCalledWith("ws://localhost:9222"); + expect(conn.connect).toHaveBeenCalled(); + }); + + it("throws if no URL provided at construction or start()", async () => { + const browser = new BiDiBrowser(); + await expect(browser.start()).rejects.toThrow(); + }); + }); + + describe("navigation", () => { + let browser: BiDiBrowser; + + beforeEach(async () => { + browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + await startBrowser(browser); + }); + + it("goto sends browsingContext.navigate", async () => { + const conn = getMockConnection(browser); + conn.sendCommand.mockResolvedValue({}); + await browser.goto("https://example.com"); + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.navigate", + expect.objectContaining({ + context: "ctx-1", + url: "https://example.com", + wait: "complete", + }), + ); + }); + + it("getUrl evaluates document.location.href and returns string", async () => { + const conn = getMockConnection(browser); + conn.sendCommand.mockResolvedValue({ + result: { type: "string", value: "https://example.com/page" }, + }); + const url = await browser.getUrl(); + expect(url).toBe("https://example.com/page"); + }); + + it("getTitle evaluates document.title and returns string", async () => { + const conn = getMockConnection(browser); + conn.sendCommand.mockResolvedValue({ + result: { type: "string", value: "Example Page" }, + }); + const title = await browser.getTitle(); + expect(title).toBe("Example Page"); + }); + + it("goBack sends browsingContext.traverseHistory with delta -1", async () => { + const conn = getMockConnection(browser); + conn.sendCommand.mockResolvedValue({}); + await browser.goBack(); + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.traverseHistory", + expect.objectContaining({ + context: "ctx-1", + delta: -1, + }), + ); + }); + + it("goForward sends browsingContext.traverseHistory with delta 1", async () => { + const conn = getMockConnection(browser); + conn.sendCommand.mockResolvedValue({}); + await browser.goForward(); + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.traverseHistory", + expect.objectContaining({ + context: "ctx-1", + delta: 1, + }), + ); + }); + }); + + describe("getTreeWithRefs", () => { + let browser: BiDiBrowser; + let conn: ReturnType; + + beforeEach(async () => { + browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + await startBrowser(browser); + conn = getMockConnection(browser); + }); + + it("injects ARIA tree script and returns YAML", async () => { + conn.sendCommand.mockResolvedValue({ + result: { + type: "string", + value: "- heading E1: Hello World\n- button E2: Click me", + }, + }); + + const tree = await browser.getTreeWithRefs(); + expect(tree).toContain("heading E1"); + expect(tree).toContain("button E2"); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "script.evaluate", + expect.objectContaining({ + target: { context: "ctx-1" }, + awaitPromise: true, + }), + ); + }); + }); + + describe("waitForLoadState", () => { + let browser: BiDiBrowser; + let conn: ReturnType; + + beforeEach(async () => { + browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + await startBrowser(browser); + conn = getMockConnection(browser); + }); + + it("evaluates a load state check script", async () => { + conn.sendCommand.mockResolvedValue({ + result: { type: "boolean", value: true }, + }); + + await browser.waitForLoadState(LoadState.Load); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "script.evaluate", + expect.objectContaining({ + target: { context: "ctx-1" }, + awaitPromise: true, + }), + ); + }); + }); + + describe("getScreenshot", () => { + let browser: BiDiBrowser; + let conn: ReturnType; + + beforeEach(async () => { + browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + await startBrowser(browser); + conn = getMockConnection(browser); + }); + + it("sends browsingContext.captureScreenshot and returns Buffer", async () => { + const testData = Buffer.from("fake-png-data").toString("base64"); + conn.sendCommand.mockResolvedValue({ data: testData }); + + const result = await browser.getScreenshot(); + + expect(result).toBeInstanceOf(Buffer); + expect(result.toString()).toBe("fake-png-data"); + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.captureScreenshot", + expect.objectContaining({ context: "ctx-1" }), + ); + }); + }); + + describe("getMarkdown", () => { + let browser: BiDiBrowser; + let conn: ReturnType; + + beforeEach(async () => { + browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + await startBrowser(browser); + conn = getMockConnection(browser); + }); + + it("extracts HTML via script and converts to markdown", async () => { + conn.sendCommand.mockResolvedValue({ + result: { + type: "string", + value: "

Hello

World

", + }, + }); + + const md = await browser.getMarkdown(); + + expect(md).toContain("Hello"); + expect(md).toContain("World"); + }); + }); + + describe("performAction", () => { + let browser: BiDiBrowser; + let conn: ReturnType; + + beforeEach(async () => { + browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + await startBrowser(browser); + conn = getMockConnection(browser); + }); + + it("click finds element then executes click script", async () => { + // First evaluate: element lookup returns true + conn.sendCommand.mockResolvedValueOnce({ + result: { type: "boolean", value: true }, + }); + // Second evaluate: click action + conn.sendCommand.mockResolvedValueOnce({ result: { type: "undefined" } }); + + await browser.performAction("E1", PageAction.Click); + + // Verify element lookup + expect(conn.sendCommand).toHaveBeenCalledWith( + "script.evaluate", + expect.objectContaining({ + expression: expect.stringContaining("data-pilo-ref"), + }), + ); + // Verify click script was sent separately + expect(conn.sendCommand).toHaveBeenCalledWith( + "script.evaluate", + expect.objectContaining({ + expression: expect.stringContaining(".click()"), + }), + ); + }); + + it("fill finds element then executes fill script", async () => { + // First evaluate: element lookup + conn.sendCommand.mockResolvedValueOnce({ + result: { type: "boolean", value: true }, + }); + // Second evaluate: fill action + conn.sendCommand.mockResolvedValueOnce({ result: { type: "undefined" } }); + + await browser.performAction("E3", PageAction.Fill, "hello world"); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "script.evaluate", + expect.objectContaining({ + expression: expect.stringContaining("hello world"), + }), + ); + }); + + it("throws InvalidRefException when element not found", async () => { + conn.sendCommand.mockResolvedValueOnce({ + result: { type: "boolean", value: false }, + }); + + await expect(browser.performAction("E99", PageAction.Click)).rejects.toThrow( + "Invalid element reference", + ); + }); + + it("wait action uses setTimeout, not element lookup", async () => { + conn.sendCommand.mockResolvedValue({ + result: { type: "boolean", value: true }, + }); + + await browser.performAction("", PageAction.Wait, "1"); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "script.evaluate", + expect.objectContaining({ + expression: expect.stringContaining("setTimeout"), + }), + ); + }); + + it("goto action delegates to this.goto()", async () => { + conn.sendCommand.mockResolvedValue({ + url: "https://example.com", + navigation: "nav-1", + }); + + await browser.performAction("", PageAction.Goto, "https://example.com"); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.navigate", + expect.objectContaining({ url: "https://example.com" }), + ); + }); + }); + + describe("runInTemporaryTab", () => { + let browser: BiDiBrowser; + let conn: ReturnType; + + beforeEach(async () => { + browser = new BiDiBrowser({ bidiUrl: "ws://localhost:9222" }); + await startBrowser(browser); + conn = getMockConnection(browser); + conn.sendCommand.mockReset(); + }); + + it("creates a new context, runs the function, then closes it", async () => { + // browsingContext.create + conn.sendCommand + .mockResolvedValueOnce({ context: "temp-ctx-1" }) + // goto -> browsingContext.navigate + .mockResolvedValue({ + result: { type: "string", value: "

content

" }, + }); + + const result = await browser.runInTemporaryTab(async (tab) => { + await tab.goto("https://example.com"); + return "done"; + }); + + expect(result).toBe("done"); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.create", + expect.objectContaining({ type: "tab" }), + ); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.close", + expect.objectContaining({ context: "temp-ctx-1" }), + ); + }); + + it("closes context even if function throws", async () => { + conn.sendCommand.mockResolvedValueOnce({ context: "temp-ctx-2" }); + conn.sendCommand.mockResolvedValue({}); + + await expect( + browser.runInTemporaryTab(async () => { + throw new Error("oops"); + }), + ).rejects.toThrow("oops"); + + expect(conn.sendCommand).toHaveBeenCalledWith( + "browsingContext.close", + expect.objectContaining({ context: "temp-ctx-2" }), + ); + }); + }); +}); + +describe("unwrapBiDiValue", () => { + it("unwraps string typed values", () => { + expect(unwrapBiDiValue({ type: "string", value: "hello" })).toBe("hello"); + }); + + it("unwraps number typed values", () => { + expect(unwrapBiDiValue({ type: "number", value: 42 })).toBe(42); + }); + + it("unwraps boolean typed values", () => { + expect(unwrapBiDiValue({ type: "boolean", value: true })).toBe(true); + }); + + it("unwraps null typed values", () => { + expect(unwrapBiDiValue({ type: "null" })).toBeNull(); + }); + + it("unwraps undefined typed values", () => { + expect(unwrapBiDiValue({ type: "undefined" })).toBeUndefined(); + }); + + it("passes through unknown types", () => { + const val = { type: "object", value: { foo: "bar" } }; + expect(unwrapBiDiValue(val)).toBe(val); + }); +}); diff --git a/packages/core/test/bidiConnection.test.ts b/packages/core/test/bidiConnection.test.ts new file mode 100644 index 00000000..15feaecd --- /dev/null +++ b/packages/core/test/bidiConnection.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { WebSocketServer, WebSocket as WsWebSocket } from "ws"; +import { BiDiConnection } from "../src/browser/bidiConnection.js"; + +// Helper to find a free port and start a WS server +function createMockBiDiServer(): { + wss: WebSocketServer; + url: string; + lastClient: () => WsWebSocket | undefined; + close: () => Promise; +} { + const wss = new WebSocketServer({ port: 0 }); + const addr = wss.address() as { port: number }; + let client: WsWebSocket | undefined; + wss.on("connection", (ws) => { + client = ws; + }); + return { + wss, + url: `ws://127.0.0.1:${addr.port}`, + lastClient: () => client, + close: () => new Promise((resolve) => wss.close(() => resolve())), + }; +} + +describe("BiDiConnection", () => { + let server: ReturnType; + let conn: BiDiConnection; + + beforeEach(() => { + server = createMockBiDiServer(); + }); + + afterEach(async () => { + conn?.close(); + await server.close(); + }); + + describe("connect", () => { + it("connects to a WebSocket server", async () => { + conn = new BiDiConnection(server.url); + await conn.connect(); + expect(conn.isConnected).toBe(true); + }); + + it("rejects on connection timeout", async () => { + conn = new BiDiConnection("ws://192.0.2.1:1"); // non-routable + await expect(conn.connect(500)).rejects.toThrow(); + }); + }); + + describe("sendCommand", () => { + it("sends a command and receives correlated response", async () => { + conn = new BiDiConnection(server.url); + await conn.connect(); + + server.lastClient()!.on("message", (data) => { + const msg = JSON.parse(data.toString()); + server.lastClient()!.send( + JSON.stringify({ + id: msg.id, + type: "success", + result: { echo: msg.method }, + }), + ); + }); + + const result = await conn.sendCommand("session.status", {}); + expect(result).toEqual({ echo: "session.status" }); + }); + + it("rejects on BiDi error response", async () => { + conn = new BiDiConnection(server.url); + await conn.connect(); + + server.lastClient()!.on("message", (data) => { + const msg = JSON.parse(data.toString()); + server.lastClient()!.send( + JSON.stringify({ + id: msg.id, + type: "error", + error: "unknown command", + message: "No such method", + }), + ); + }); + + await expect(conn.sendCommand("bad.method")).rejects.toThrow("unknown command"); + }); + + it("rejects on command timeout", async () => { + conn = new BiDiConnection(server.url, 200); + await conn.connect(); + + // Server never responds + await expect(conn.sendCommand("session.status")).rejects.toThrow("Timeout"); + }); + + it("rejects if not connected", async () => { + conn = new BiDiConnection(server.url); + await expect(conn.sendCommand("session.status")).rejects.toThrow("not connected"); + }); + }); + + describe("events", () => { + it("emits unsolicited BiDi events", async () => { + conn = new BiDiConnection(server.url); + await conn.connect(); + + const events: unknown[] = []; + conn.on("event", (evt) => events.push(evt)); + + server.lastClient()!.send( + JSON.stringify({ + type: "event", + method: "browsingContext.load", + params: { context: "ctx-1" }, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ method: "browsingContext.load" }); + }); + }); + + describe("close", () => { + it("rejects all pending commands on close", async () => { + conn = new BiDiConnection(server.url, 5000); + await conn.connect(); + + const promise = conn.sendCommand("session.status"); + conn.close(); + + await expect(promise).rejects.toThrow("closed"); + expect(conn.isConnected).toBe(false); + }); + }); +}); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index cc7da5b1..4539b4b8 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -156,6 +156,9 @@ describe("ConfigManager", () => { "openai_compatible_base_url", "openai_compatible_name", "browser", + "bidi_url", + "foxcloud_url", + "foxcloud_proxy_url", "channel", "executable_path", "headless", diff --git a/packages/core/test/foxcloudBrowser.test.ts b/packages/core/test/foxcloudBrowser.test.ts new file mode 100644 index 00000000..70fb2533 --- /dev/null +++ b/packages/core/test/foxcloudBrowser.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { FoxcloudBrowser } from "../src/browser/foxcloudBrowser.js"; + +// Mock BiDiConnection (same pattern as bidiBrowser tests) +vi.mock("../src/browser/bidiConnection.js", () => { + const MockBiDiConnection = vi.fn(function (this: any) { + this.connect = vi.fn().mockResolvedValue(undefined); + this.close = vi.fn(); + this.sendCommand = vi.fn().mockResolvedValue(undefined); + this.isConnected = true; + this.setUrl = vi.fn(); + this.on = vi.fn(); + this.off = vi.fn(); + this.removeAllListeners = vi.fn(); + }); + return { BiDiConnection: MockBiDiConnection }; +}); + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("FoxcloudBrowser", () => { + let browser: FoxcloudBrowser; + + afterEach(() => { + mockFetch.mockReset(); + }); + + describe("start", () => { + it("creates a session, polls until RUNNING, then connects BiDi", async () => { + const sessionId = "test-session-123"; + + // POST /v1/sessions → 201 + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: sessionId, state: "CREATING" }), + }); + + // GET poll → CREATING + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: sessionId, state: "CREATING" }), + }); + + // GET poll → RUNNING + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: sessionId, state: "RUNNING" }), + }); + + browser = new FoxcloudBrowser({ + brokerUrl: "http://localhost:8080", + sessionPollIntervalMs: 10, + }); + + const conn = (browser as any).connection; + conn.sendCommand + .mockResolvedValueOnce({}) // session.new + .mockResolvedValueOnce({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + + await browser.start(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8080/v1/sessions", + expect.objectContaining({ method: "POST" }), + ); + + expect(conn.setUrl).toHaveBeenCalledWith(`ws://localhost:8080/v1/sessions/${sessionId}/bidi`); + }); + + it("passes proxy_url in session creation request", async () => { + const sessionId = "test-session-proxy"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: sessionId, state: "RUNNING" }), + }); + + browser = new FoxcloudBrowser({ + brokerUrl: "http://localhost:8080", + proxyUrl: "http://user:pass@proxy.example.com:8080", + }); + + const conn = (browser as any).connection; + conn.sendCommand.mockResolvedValueOnce({}).mockResolvedValueOnce({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + + await browser.start(); + + expect(mockFetch).toHaveBeenCalledWith("http://localhost:8080/v1/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxy_url: "http://user:pass@proxy.example.com:8080" }), + }); + }); + + it("does not send body when no proxy is configured", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: "sess-no-proxy", state: "RUNNING" }), + }); + + browser = new FoxcloudBrowser({ brokerUrl: "http://localhost:8080" }); + + const conn = (browser as any).connection; + conn.sendCommand.mockResolvedValueOnce({}).mockResolvedValueOnce({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + + await browser.start(); + + expect(mockFetch).toHaveBeenCalledWith("http://localhost:8080/v1/sessions", { + method: "POST", + }); + }); + }); + + describe("shutdown", () => { + it("closes BiDi connection and deletes session", async () => { + browser = new FoxcloudBrowser({ brokerUrl: "http://localhost:8080" }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: "sess-1", state: "RUNNING" }), + }); + + const conn = (browser as any).connection; + conn.sendCommand.mockResolvedValueOnce({}).mockResolvedValueOnce({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + + await browser.start(); + mockFetch.mockClear(); + + mockFetch.mockResolvedValueOnce({ ok: true, status: 204 }); + + await browser.shutdown(); + + expect(conn.close).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8080/v1/sessions/sess-1", + expect.objectContaining({ method: "DELETE" }), + ); + }); + }); + + describe("park", () => { + it("posts to park endpoint and disconnects", async () => { + browser = new FoxcloudBrowser({ brokerUrl: "http://localhost:8080" }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: "sess-1", state: "RUNNING" }), + }); + const conn = (browser as any).connection; + conn.sendCommand.mockResolvedValueOnce({}).mockResolvedValueOnce({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + await browser.start(); + mockFetch.mockClear(); + + mockFetch.mockResolvedValueOnce({ ok: true }); + + await browser.park(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8080/v1/sessions/sess-1/park", + expect.objectContaining({ method: "POST" }), + ); + expect(conn.close).toHaveBeenCalled(); + }); + }); + + describe("resume", () => { + it("posts to resume endpoint and reconnects BiDi", async () => { + browser = new FoxcloudBrowser({ brokerUrl: "http://localhost:8080" }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: "sess-1", state: "RUNNING" }), + }); + const conn = (browser as any).connection; + conn.sendCommand.mockResolvedValueOnce({}).mockResolvedValueOnce({ + contexts: [{ context: "ctx-1", url: "about:blank", children: [] }], + }); + await browser.start(); + + mockFetch.mockResolvedValueOnce({ ok: true }); // park + await browser.park(); + mockFetch.mockClear(); + conn.sendCommand.mockClear(); + + mockFetch.mockResolvedValueOnce({ ok: true }); // resume + conn.sendCommand + .mockResolvedValueOnce({}) // session.new + .mockResolvedValueOnce({ + contexts: [{ context: "ctx-2", url: "https://example.com", children: [] }], + }); + + await browser.resume(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8080/v1/sessions/sess-1/resume", + expect.objectContaining({ method: "POST" }), + ); + expect(conn.connect).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/smoke-bidi.ts b/packages/core/test/smoke-bidi.ts new file mode 100644 index 00000000..6b2c2b50 --- /dev/null +++ b/packages/core/test/smoke-bidi.ts @@ -0,0 +1,54 @@ +/** + * Smoke test: BiDiBrowser against a real Firefox instance. + * + * Prerequisites: + * firefox --remote-debugging-port 9222 --headless --no-remote --profile "$(mktemp -d)" + * + * Run: + * pnpm --filter pilo-core exec tsx test/smoke-bidi.ts + */ +import { BiDiBrowser } from "../src/browser/bidiBrowser.js"; + +async function main() { + const browser = new BiDiBrowser({ bidiUrl: "ws://127.0.0.1:9222/session" }); + + try { + console.log("Starting BiDiBrowser..."); + await browser.start(); + console.log(" browserName:", browser.browserName); + + console.log("\n--- Navigation ---"); + await browser.goto("https://example.com"); + console.log(" URL:", await browser.getUrl()); + console.log(" Title:", await browser.getTitle()); + + console.log("\n--- ARIA Tree ---"); + const tree = await browser.getTreeWithRefs(); + console.log(tree.substring(0, 600)); + + console.log("\n--- Markdown ---"); + const md = await browser.getMarkdown(); + console.log(md.substring(0, 400)); + + console.log("\n--- Screenshot ---"); + const screenshot = await browser.getScreenshot(); + console.log(" Size:", screenshot.length, "bytes"); + + console.log("\n--- History Navigation ---"); + await browser.goBack(); + console.log(" After goBack, URL:", await browser.getUrl()); + await browser.goForward(); + console.log(" After goForward, URL:", await browser.getUrl()); + + console.log("\n=== All smoke tests passed! ==="); + } catch (e: unknown) { + const err = e as Error; + console.error("\nFAILED:", err.message); + console.error(err.stack?.split("\n").slice(0, 5).join("\n")); + process.exitCode = 1; + } finally { + await browser.shutdown(); + } +} + +main(); diff --git a/packages/server/src/taskRunner.test.ts b/packages/server/src/taskRunner.test.ts index e06deee1..b20c2315 100644 --- a/packages/server/src/taskRunner.test.ts +++ b/packages/server/src/taskRunner.test.ts @@ -47,6 +47,7 @@ vi.mock("pilo-core", () => { timeoutMultiplier: overrides?.timeoutMultiplier ?? 2, })), SEARCH_PROVIDERS: ["none", "duckduckgo", "google", "bing", "parallel-api"], + PLAYWRIGHT_BROWSERS: ["firefox", "chrome", "chromium", "safari", "webkit", "edge"], }; }); diff --git a/packages/server/src/taskRunner.ts b/packages/server/src/taskRunner.ts index 1cf41835..7495100e 100644 --- a/packages/server/src/taskRunner.ts +++ b/packages/server/src/taskRunner.ts @@ -10,6 +10,7 @@ import { createNavigationRetryConfig, RecoverableError, SEARCH_PROVIDERS, + PLAYWRIGHT_BROWSERS, } from "pilo-core"; import type { TaskExecutionResult, UserDataCallback } from "pilo-core"; import { StreamLogger } from "./StreamLogger.js"; @@ -300,8 +301,15 @@ export async function runTask(options: TaskRunnerOptions): Promise { getConfig: vi.fn(() => ({ provider: "openai", openai_api_key: "sk-test123", + browser: "chromium", })), }, createAIProvider: vi.fn(() => ({})), @@ -55,6 +56,7 @@ vi.mock("pilo-core", () => { timeoutMultiplier: 2, })), SEARCH_PROVIDERS: ["none", "duckduckgo", "google", "bing", "parallel-api"], + PLAYWRIGHT_BROWSERS: ["firefox", "chrome", "chromium", "safari", "webkit", "edge"], withRemoteContext: vi.fn((_headers: unknown, fn: () => unknown) => fn()), }; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1618f8e6..b00fc20a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: turndown: specifier: ^7.2.2 version: 7.2.4 + ws: + specifier: ^8.20.0 + version: 8.20.1 zod: specifier: 4.3.6 version: 4.3.6 @@ -206,6 +209,9 @@ importers: '@types/turndown': specifier: ^5.0.6 version: 5.0.6 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vitest/coverage-v8': specifier: ^4.1.6 version: 4.1.6(vitest@4.1.6)