diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e46d3d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + biome: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + # Picks up the Biome version pinned in pnpm-lock.yaml. + - uses: biomejs/setup-biome@v2 + - run: biome ci --error-on-warnings . + + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + # Installs the pnpm pinned in packageManager, then runs pnpm install. + # Tests run .ts files directly, which needs Node 22.18 or newer. + - uses: pnpm/setup@v3 + with: + runtime: node@22 + cache: true + require-lockfile: true + # The web app's type-check imports routeTree.gen.ts, which the build generates. + - run: pnpm build + - run: pnpm -r check + - run: pnpm test diff --git a/README.md b/README.md index 8785057..9c58f64 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,13 @@ pnpm --filter @browse-code-mode/mcp-server test:worker pnpm test:e2e # existing browser on BROWSE_CDP_URL or port 9222 ``` +`pnpm check` runs [Biome](https://biomejs.dev) (lint, formatting, import order) +before type-checking every package. `pnpm lint:fix` applies Biome's safe fixes +and formatting; `pnpm format` only formats. Biome warnings fail the check. + +`pnpm install` adds a pre-push hook that runs `pnpm lint`, and CI runs the same +check on every pull request. Set `SKIP_SIMPLE_GIT_HOOKS=1` to skip the hook. + The runtime suites start an isolated Chrome and exercise real MCP execution, TypeScript, snapshot-ref clicks, loop interruption, screenshots, and lifecycle cleanup against Node and local workerd. They shut down their test processes. diff --git a/apps/web/src/lib/browser-api.ts b/apps/web/src/lib/browser-api.ts index 4837842..16584b4 100644 --- a/apps/web/src/lib/browser-api.ts +++ b/apps/web/src/lib/browser-api.ts @@ -1,12 +1,15 @@ -import { mcpHeaders } from "./env.ts"; import { fetchBrowserApp } from "./browser-app.ts"; +import { mcpHeaders } from "./env.ts"; /** Browser API calls are bounded; navigation/HMR cancellation is not a 500. */ -export async function proxyBrowserRequest(request: Request, path: "/browser" | "/screen", dispatch = fetchBrowserApp): Promise { +export async function proxyBrowserRequest( + request: Request, + path: "/browser" | "/screen", + dispatch = fetchBrowserApp, +): Promise { try { - const body = path === "/screen" || request.method === "DELETE" - ? await request.text() - : undefined; + const body = + path === "/screen" || request.method === "DELETE" ? await request.text() : undefined; const response = await dispatch(new URL(path, "http://browse.internal"), { method: request.method, headers: { ...mcpHeaders(), "content-type": "application/json" }, @@ -26,10 +29,13 @@ export async function proxyBrowserRequest(request: Request, path: "/browser" | " } catch (error) { if (request.signal.aborted) return new Response(null, { status: 499 }); const timedOut = error instanceof Error && error.name === "TimeoutError"; - return Response.json({ - error: timedOut - ? "The browser service took too long to respond. Try again." - : "Cannot reach the browser service. Make sure pnpm dev is running, then try again.", - }, { status: timedOut ? 504 : 502 }); + return Response.json( + { + error: timedOut + ? "The browser service took too long to respond. Try again." + : "Cannot reach the browser service. Make sure pnpm dev is running, then try again.", + }, + { status: timedOut ? 504 : 502 }, + ); } } diff --git a/apps/web/src/lib/browser-app.ts b/apps/web/src/lib/browser-app.ts index 877e9d0..5c06fdc 100644 --- a/apps/web/src/lib/browser-app.ts +++ b/apps/web/src/lib/browser-app.ts @@ -1,9 +1,13 @@ import { createConfiguredApp } from "@browse-code-mode/mcp-server/runtime"; /** Request-local Hono app; no listener, loopback HTTP, or stored browser session. */ -export async function fetchBrowserApp(input: RequestInfo | URL, init?: RequestInit): Promise { - return createConfiguredApp({ ...process.env, MCP_ENDPOINT: "/mcp" }) - .fetch(new Request(input, init)); +export async function fetchBrowserApp( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + return createConfiguredApp({ ...process.env, MCP_ENDPOINT: "/mcp" }).fetch( + new Request(input, init), + ); } /** Mount the complete Hono router beneath /api/browse. */ diff --git a/apps/web/src/lib/mcp.ts b/apps/web/src/lib/mcp.ts index 25b3c4a..233c3a4 100644 --- a/apps/web/src/lib/mcp.ts +++ b/apps/web/src/lib/mcp.ts @@ -8,10 +8,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { dynamicTool, jsonSchema, type JSONSchema7, type ToolSet } from "ai"; - -import { mcpHeaders } from "./env.ts"; +import { dynamicTool, type JSONSchema7, jsonSchema, type ToolSet } from "ai"; import { fetchBrowserApp } from "./browser-app.ts"; +import { mcpHeaders } from "./env.ts"; export interface McpSession { tools: ToolSet; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 873fe36..f07023a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; +import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router"; import styles from "../styles.css?url"; @@ -12,7 +12,12 @@ export const Route = createRootRoute({ links: [{ rel: "stylesheet", href: styles }], }), component: RootDocument, - notFoundComponent: () =>

Page not found

Return to browser
, + notFoundComponent: () => ( +
+

Page not found

+ Return to browser +
+ ), }); function RootDocument() { diff --git a/apps/web/src/routes/api.chat.ts b/apps/web/src/routes/api.chat.ts index e0aded6..3253095 100644 --- a/apps/web/src/routes/api.chat.ts +++ b/apps/web/src/routes/api.chat.ts @@ -1,14 +1,9 @@ import { anthropic } from "@ai-sdk/anthropic"; import { createFileRoute } from "@tanstack/react-router"; -import { - convertToModelMessages, - stepCountIs, - streamText, - type UIMessage, -} from "ai"; +import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from "ai"; import { env } from "../lib/env.ts"; -import { openMcpSession } from "../lib/mcp.ts"; +import { type McpSession, openMcpSession } from "../lib/mcp.ts"; const SYSTEM = `You drive a real web browser by writing TypeScript programs and running them with the browser_run tool. @@ -40,12 +35,9 @@ export const Route = createFileRoute("/api/chat")({ cdpUrl?: string; }; if (!cdpUrl || typeof cdpUrl !== "string") - return Response.json( - { error: "Start a browser first." }, - { status: 400 }, - ); + return Response.json({ error: "Start a browser first." }, { status: 400 }); - let session; + let session: McpSession; try { session = await openMcpSession(cdpUrl); } catch (error) { diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e78492e..3abb655 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -16,7 +16,7 @@ interface BrowserHandle { } async function responseError(response: Response): Promise { - const body = await response.json().catch(() => null) as { error?: string } | null; + const body = (await response.json().catch(() => null)) as { error?: string } | null; return body?.error ?? `Browser request failed (HTTP ${response.status}).`; } @@ -47,7 +47,9 @@ function Home() { try { if (next) sessionStorage.setItem("browse.browser", JSON.stringify(next)); else sessionStorage.removeItem("browse.browser"); - } catch { /* A blocked storage API must not prevent using the browser. */ } + } catch { + /* A blocked storage API must not prevent using the browser. */ + } } catch (error) { setError(error instanceof Error ? error.message : String(error)); } finally { @@ -56,28 +58,31 @@ function Home() { }; return (
-
+
- - {browser ? Browser connected : null} - {error ?
{error}
: null} + {browser ? ( + + Browser connected + + ) : null} + {error ? ( +
+ {error} +
+ ) : null}
{browser ? ( ) : ( -
Start a browser to begin.
+
+ Start a browser to begin. +
)}
- {browser ? ( - - ) : ( -
- )} + {browser ? :
}
); } @@ -154,9 +159,19 @@ function LiveView({ browser }: { browser: BrowserHandle }) { {src ? ( ) : ( -
{error ? "Browser preview unavailable" : "Waiting for the browser…"}
+
+ {error ? "Browser preview unavailable" : "Waiting for the browser…"} +
)} - {error ?
{error} Retrying…
: null} + {error ? ( +
+ {error} Retrying… +
+ ) : null}
); } @@ -172,6 +187,7 @@ function Chat({ cdpUrl }: { cdpUrl: string }) { // Scroll the list itself. `scrollIntoView` walks up the ancestor chain and // will scroll the document too, which drags the whole two-pane layout. + // biome-ignore lint/correctness/useExhaustiveDependencies: the dependencies are the triggers; the effect reads only the ref. useEffect(() => { const list = listRef.current; if (list) list.scrollTop = list.scrollHeight; @@ -207,11 +223,7 @@ function Chat({ cdpUrl }: { cdpUrl: string }) { placeholder="Ask the browser to do something" rows={1} /> -
@@ -222,32 +234,41 @@ function Chat({ cdpUrl }: { cdpUrl: string }) { function Message({ message }: { message: UIMessage }) { return (
- {message.parts.map((part, index) => { - if (part.type === "text") { - if (!part.text.trim()) return null; - // Assistant replies use tables and lists, so they render as markdown; - // a user's own words are shown verbatim rather than reinterpreted as - // markup. Streamdown rather than react-markdown because it renders - // markdown that is still arriving, so a half-written table or fence - // does not flash as raw text mid-stream. - return message.role === "assistant" ? ( -
- {part.text} -
- ) : ( -
- {part.text} -
- ); - } - - const call = asToolCall(part); - return call ? : null; - })} + {message.parts.map((part, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: parts only append and text parts have no id, so the index is stable. + + ))}
); } +function MessagePart({ + role, + part, +}: { + role: UIMessage["role"]; + part: UIMessage["parts"][number]; +}) { + if (part.type === "text") { + if (!part.text.trim()) return null; + // Assistant replies use tables and lists, so they render as markdown; + // a user's own words are shown verbatim rather than reinterpreted as + // markup. Streamdown rather than react-markdown because it renders + // markdown that is still arriving, so a half-written table or fence + // does not flash as raw text mid-stream. + return role === "assistant" ? ( +
+ {part.text} +
+ ) : ( +
{part.text}
+ ); + } + + const call = asToolCall(part); + return call ? : null; +} + interface ToolCallView { name: string; state: string; @@ -261,8 +282,7 @@ interface ToolCallView { * a tool-specific part type that only exists for statically declared tools. */ function asToolCall(part: UIMessage["parts"][number]): ToolCallView | null { - if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-")) - return null; + if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-")) return null; const record = part as unknown as { type: string; toolName?: string; @@ -284,8 +304,7 @@ function ToolCall({ call }: { call: ToolCallView }) { const code = codeOf(call.input); const output = call.errorText ?? textOf(call.output); const failed = Boolean(call.errorText) || call.state === "output-error"; - const running = - call.state === "input-streaming" || call.state === "input-available"; + const running = call.state === "input-streaming" || call.state === "input-available"; return (
:first-child { margin-top: 0; } -.markdown > :last-child { margin-bottom: 0; } +.markdown > :first-child { + margin-top: 0; +} +.markdown > :last-child { + margin-bottom: 0; +} /* A wide table or code fence scrolls itself rather than widening the column. */ .markdown table, @@ -142,7 +166,9 @@ body { font: inherit; } -.composer textarea:focus { outline: 1px solid var(--accent); } +.composer textarea:focus { + outline: 1px solid var(--accent); +} .composer button { align-self: flex-end; @@ -155,7 +181,10 @@ body { cursor: pointer; } -.composer button:disabled { opacity: 0.5; cursor: default; } +.composer button:disabled { + opacity: 0.5; + cursor: default; +} .error { margin: 0 16px 12px; diff --git a/apps/web/tests/browser-api.test.ts b/apps/web/tests/browser-api.test.ts index 7f640c6..71f49ba 100644 --- a/apps/web/tests/browser-api.test.ts +++ b/apps/web/tests/browser-api.test.ts @@ -11,10 +11,20 @@ test("TanStack mount exposes the Hono router and preserves authentication", asyn const previous = process.env.MCP_AUTH_TOKEN; try { process.env.MCP_AUTH_TOKEN = "test-token"; - assert.equal((await mountedBrowserApp(new Request("http://localhost/api/browse/health"))).status, 401); - assert.equal((await mountedBrowserApp(new Request("http://localhost/api/browse/health", { - headers: { authorization: "Bearer test-token" }, - }))).status, 200); + assert.equal( + (await mountedBrowserApp(new Request("http://localhost/api/browse/health"))).status, + 401, + ); + assert.equal( + ( + await mountedBrowserApp( + new Request("http://localhost/api/browse/health", { + headers: { authorization: "Bearer test-token" }, + }), + ) + ).status, + 200, + ); } finally { if (previous === undefined) delete process.env.MCP_AUTH_TOKEN; else process.env.MCP_AUTH_TOKEN = previous; @@ -25,7 +35,9 @@ test("chat discovers tools through in-process MCP without an HTTP server", async const session = await openMcpSession(); try { assert.deepEqual(Object.keys(session.tools).sort(), ["browser_api", "browser_run"]); - } finally { await session.close(); } + } finally { + await session.close(); + } }); test("cancelled preview body reads return 499 instead of an unhandled 500", async (t) => { @@ -39,15 +51,27 @@ test("cancelled preview body reads return 499 instead of an unhandled 500", asyn }); return new Response(stream); }); - const response = await proxyBrowserRequest(new Request("http://localhost/api/screen", { - method: "POST", body: '{"cdpUrl":"9222"}', signal: controller.signal, - }), "/screen", dispatch); + const response = await proxyBrowserRequest( + new Request("http://localhost/api/screen", { + method: "POST", + body: '{"cdpUrl":"9222"}', + signal: controller.signal, + }), + "/screen", + dispatch, + ); assert.equal(response.status, 499); }); test("an unreachable backend returns a useful start error", async (t) => { - const dispatch = t.mock.fn(async () => { throw new TypeError("fetch failed"); }); - const response = await proxyBrowserRequest(new Request("http://localhost/api/browser", { method: "POST" }), "/browser", dispatch); + const dispatch = t.mock.fn(async () => { + throw new TypeError("fetch failed"); + }); + const response = await proxyBrowserRequest( + new Request("http://localhost/api/browser", { method: "POST" }), + "/browser", + dispatch, + ); assert.equal(response.status, 502); assert.match((await response.json()).error, /pnpm dev/); }); @@ -55,11 +79,22 @@ test("an unreachable backend returns a useful start error", async (t) => { test("successful create and preview responses preserve their bodies", async (t) => { const handle = { provider: "cdp", cdpUrl: "9222" }; const fetch = t.mock.fn(async () => Response.json(handle, { status: 201 })); - const response = await proxyBrowserRequest(new Request("http://localhost/api/browser", { method: "POST" }), "/browser", fetch); + const response = await proxyBrowserRequest( + new Request("http://localhost/api/browser", { method: "POST" }), + "/browser", + fetch, + ); assert.equal(response.status, 201); assert.deepEqual(await response.json(), handle); - fetch.mock.mockImplementation(async () => new Response(new Uint8Array([255, 216, 255]), { headers: { "content-type": "image/jpeg" } })); - const preview = await proxyBrowserRequest(new Request("http://localhost/api/screen", { method: "POST", body: JSON.stringify(handle) }), "/screen", fetch); + fetch.mock.mockImplementation( + async () => + new Response(new Uint8Array([255, 216, 255]), { headers: { "content-type": "image/jpeg" } }), + ); + const preview = await proxyBrowserRequest( + new Request("http://localhost/api/screen", { method: "POST", body: JSON.stringify(handle) }), + "/screen", + fetch, + ); assert.equal(preview.headers.get("content-type"), "image/jpeg"); assert.deepEqual(new Uint8Array(await preview.arrayBuffer()), new Uint8Array([255, 216, 255])); }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8979a54..a78f369 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,12 +1,11 @@ -import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; - -import { defineConfig, loadEnv } from "vite"; -import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; +import { cloudflare } from "@cloudflare/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import { cloudflare } from "@cloudflare/vite-plugin"; +import react from "@vitejs/plugin-react"; import { nitro } from "nitro/vite"; +import { defineConfig, loadEnv } from "vite"; const workspaceRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -21,28 +20,36 @@ export default defineConfig(({ mode, command }) => { return { resolve: { - alias: [{ - find: "@browse-code-mode/mcp-server/runtime", - replacement: mode === "cloudflare" - ? "@browse-code-mode/mcp-server/worker-runtime" - : "@browse-code-mode/mcp-server/bundled-runtime", - }], + alias: [ + { + find: "@browse-code-mode/mcp-server/runtime", + replacement: + mode === "cloudflare" + ? "@browse-code-mode/mcp-server/worker-runtime" + : "@browse-code-mode/mcp-server/bundled-runtime", + }, + ], }, server: { port: 3000, strictPort: true }, plugins: [ { name: "browse-node-wasm", - resolveId(id) { if (id === "virtual:browse-wasm") return "\0browse-wasm"; }, + resolveId(id) { + if (id === "virtual:browse-wasm") return "\0browse-wasm"; + }, load(id) { if (id !== "\0browse-wasm") return; - const binary = readFileSync(new URL("../../packages/mcp-server/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm", import.meta.url)); + const binary = readFileSync( + new URL( + "../../packages/mcp-server/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm", + import.meta.url, + ), + ); return `export default Uint8Array.from(atob(${JSON.stringify(binary.toString("base64"))}), c => c.charCodeAt(0)).buffer;`; }, }, tailwindcss(), - ...(mode === "cloudflare" - ? [cloudflare({ viteEnvironment: { name: "ssr" } })] - : []), + ...(mode === "cloudflare" ? [cloudflare({ viteEnvironment: { name: "ssr" } })] : []), tanstackStart(), ...(mode === "cloudflare" || command !== "build" ? [] diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f056836 --- /dev/null +++ b/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!**/worker-configuration.d.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/examples/01-hello-world/index.ts b/examples/01-hello-world/index.ts index f369836..816a7b8 100644 --- a/examples/01-hello-world/index.ts +++ b/examples/01-hello-world/index.ts @@ -6,10 +6,10 @@ */ import { anthropic } from "@ai-sdk/anthropic"; -import { generateText, stepCountIs } from "ai"; +import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; import { browserTools, instructions } from "@browse-code-mode/tools/ai-sdk"; import { providerBrowser } from "@browse-code-mode/tools/browser"; -import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; +import { generateText, stepCountIs } from "ai"; const prompt = process.argv[2] ?? "What's the weather in San Francisco right now?"; diff --git a/examples/02-mcp-server/index.ts b/examples/02-mcp-server/index.ts index ae5b477..7d40401 100644 --- a/examples/02-mcp-server/index.ts +++ b/examples/02-mcp-server/index.ts @@ -10,11 +10,14 @@ */ import { createServer } from "node:http"; - +import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; +import { + createBrowserToolkit, + providerBrowser, + type SessionToolkit, +} from "@browse-code-mode/tools"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { createBrowserToolkit, providerBrowser } from "@browse-code-mode/tools"; -import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; const port = Number(process.env.PORT ?? 3000); const host = "127.0.0.1"; @@ -23,7 +26,8 @@ const host = "127.0.0.1"; // Sessions outlive each stateless request because they live in this process. const browser = providerBrowser(localProvider()); const toolkit = createBrowserToolkit({ browser }); -const sessions = toolkit.sessions!; +if (!toolkit.sessions) throw new Error("A toolkit created with a browser has session tools."); +const sessions: SessionToolkit = toolkit.sessions; const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], @@ -56,7 +60,10 @@ function createMcpServer(): McpServer { server.registerTool( sessions.start.name, - { description: sessions.start.description, annotations: { readOnlyHint: false, openWorldHint: true } }, + { + description: sessions.start.description, + annotations: { readOnlyHint: false, openWorldHint: true }, + }, async ({ signal }) => json(await sessions.start.execute(signal)), ); diff --git a/package.json b/package.json index 78ef0e8..bddae41 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "packageManager": "pnpm@11.10.0", "scripts": { "build": "pnpm -r build", - "check": "pnpm -r check", + "check": "pnpm lint && pnpm -r check", + "lint": "biome check --error-on-warnings .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", "test": "pnpm -r --if-present test", "test:e2e": "pnpm --filter @browse-code-mode/mcp-server test:e2e", "dev": "node scripts/dev.mjs", @@ -18,10 +21,16 @@ "dev:server": "pnpm --filter @browse-code-mode/mcp-server dev", "dev:web": "pnpm --filter @browse-code-mode/web dev", "chrome": "node scripts/chrome.mjs", - "example": "node scripts/example.mjs" + "example": "node scripts/example.mjs", + "prepare": "simple-git-hooks" + }, + "simple-git-hooks": { + "pre-push": "pnpm lint" }, "devDependencies": { - "typescript": "^5.9.3", - "@types/node": "^22.19.0" + "@biomejs/biome": "2.5.14", + "@types/node": "^22.19.0", + "simple-git-hooks": "2.14.0", + "typescript": "^5.9.3" } } diff --git a/packages/mcp-server/api/index.ts b/packages/mcp-server/api/index.ts index 23b9dfe..b5ab561 100644 --- a/packages/mcp-server/api/index.ts +++ b/packages/mcp-server/api/index.ts @@ -1,8 +1,8 @@ -import { handle } from "hono/vercel"; import { Hono } from "hono"; +import { handle } from "hono/vercel"; import { createApp } from "../src/app.ts"; -import { loadConfig } from "../src/config.ts"; import { providerFromEnv } from "../src/browse/providers.ts"; +import { loadConfig } from "../src/config.ts"; const app = createApp({ config: loadConfig(process.env), provider: providerFromEnv(process.env) }); const router = new Hono(); diff --git a/packages/mcp-server/src/app.ts b/packages/mcp-server/src/app.ts index b481d53..128aed4 100644 --- a/packages/mcp-server/src/app.ts +++ b/packages/mcp-server/src/app.ts @@ -10,17 +10,16 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { Hono } from "hono"; import { z } from "zod"; -import { staticProvider } from "./browse/provider.ts"; import { BrowserSession } from "./browse/driver.ts"; -import { captureScreenshot } from "./core.ts"; - -import { renderApiDts, renderCheatsheet, PROGRAM_GUIDE } from "./browse/dts.ts"; +import { PROGRAM_GUIDE, renderApiDts, renderCheatsheet } from "./browse/dts.ts"; +import { staticProvider } from "./browse/provider.ts"; import type { ServerConfig } from "./config.ts"; +import { captureScreenshot } from "./core.ts"; import { + type CreateServerOptions, createMcpServer, SERVER_NAME, SERVER_VERSION, - type CreateServerOptions, } from "./mcp/server.ts"; export interface AppDeps { @@ -48,9 +47,21 @@ export function createApp({ app.onError((error, c) => { if (c.req.raw.signal.aborted) return new Response(null, { status: 499 }); if (error.name === "NoBrowserError") - return c.json({ error: "No browser configured. Run pnpm dev locally, or configure BROWSE_PROVIDER and its credentials." }, 503); + return c.json( + { + error: + "No browser configured. Run pnpm dev locally, or configure BROWSE_PROVIDER and its credentials.", + }, + 503, + ); log(`Browser operation failed: ${error.message}`); - return c.json({ error: "Browser operation failed. Check the server logs and browser provider configuration." }, 500); + return c.json( + { + error: + "Browser operation failed. Check the server logs and browser provider configuration.", + }, + 500, + ); }); // ---------------------------------------------------------------- middleware @@ -84,11 +95,8 @@ export function createApp({ }); app.delete("/browser", async (c) => { - const parsed = browserHandleSchema.safeParse( - await c.req.json().catch(() => null), - ); - if (!parsed.success) - return c.json({ error: "A browser handle is required." }, 400); + const parsed = browserHandleSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "A browser handle is required." }, 400); if (parsed.data.provider !== provider.name) return c.json({ error: "Wrong browser provider." }, 400); await provider.shutdown(parsed.data); @@ -127,11 +135,7 @@ export function createApp({ /** Inspect any local or hosted CDP browser without HTTP discovery endpoints. */ app.get("/browser", async (c) => { const endpoint = c.req.query("cdpUrl") ?? config.defaultCdpUrl; - if (!endpoint) - return c.json( - { error: "No cdpUrl given and no default configured." }, - 400, - ); + if (!endpoint) return c.json({ error: "No cdpUrl given and no default configured." }, 400); try { const session = await (connect ?? BrowserSession.connect)({ @@ -146,10 +150,7 @@ export function createApp({ await session.close(); } } catch (error) { - return c.json( - { error: error instanceof Error ? error.message : String(error) }, - 502, - ); + return c.json({ error: error instanceof Error ? error.message : String(error) }, 502); } }); @@ -178,8 +179,7 @@ export function createApp({ jsonrpc: "2.0", error: { code: -32000, - message: - "This server is stateless. Send requests as POSTs; no session is kept.", + message: "This server is stateless. Send requests as POSTs; no session is kept.", }, id: null, }, @@ -187,15 +187,12 @@ export function createApp({ ); } + const cdpUrl = c.req.header("x-browse-cdp-url"); const server = createMcpServer({ - config: c.req.header("x-browse-cdp-url") - ? { ...config, defaultCdpUrl: c.req.header("x-browse-cdp-url")! } - : config, + config: cdpUrl ? { ...config, defaultCdpUrl: cdpUrl } : config, ...(run ? { run } : {}), ...(connect ? { connect } : {}), - provider: c.req.header("x-browse-cdp-url") - ? staticProvider(c.req.header("x-browse-cdp-url")) - : provider, + provider: cdpUrl ? staticProvider(cdpUrl) : provider, }); // No `sessionIdGenerator`: the transport runs in stateless mode. const transport = new WebStandardStreamableHTTPServerTransport(); @@ -265,9 +262,7 @@ async function closeQuietly( } function exposedOption(config: ServerConfig): { exposed?: readonly string[] } { - return config.allowCommands.length > 0 - ? { exposed: config.allowCommands } - : {}; + return config.allowCommands.length > 0 ? { exposed: config.allowCommands } : {}; } /** Constant-time-ish comparison, to keep token checks from leaking length. */ diff --git a/packages/mcp-server/src/browse/cdp.ts b/packages/mcp-server/src/browse/cdp.ts index d310624..3e9c75e 100644 --- a/packages/mcp-server/src/browse/cdp.ts +++ b/packages/mcp-server/src/browse/cdp.ts @@ -45,8 +45,7 @@ export async function resolveCdpUrl( } const payload = await fetchJson(new URL("/json/version", origin), timeoutMs, signal); - const webSocketDebuggerUrl = (payload as { webSocketDebuggerUrl?: unknown }) - .webSocketDebuggerUrl; + const webSocketDebuggerUrl = (payload as { webSocketDebuggerUrl?: unknown }).webSocketDebuggerUrl; if (typeof webSocketDebuggerUrl !== "string") { throw new CdpEndpointError( `${origin} answered /json/version without a webSocketDebuggerUrl. Is it a Chrome DevTools endpoint?`, diff --git a/packages/mcp-server/src/browse/commands.ts b/packages/mcp-server/src/browse/commands.ts index e609564..bbb8358 100644 --- a/packages/mcp-server/src/browse/commands.ts +++ b/packages/mcp-server/src/browse/commands.ts @@ -149,8 +149,7 @@ export const COMMANDS: CommandSpec[] = [ fn: "snapshot", wire: "snapshot", cli: "browse snapshot", - summary: - "Capture the accessibility tree and refresh the `@ref` map used by element commands.", + summary: "Capture the accessibility tree and refresh the `@ref` map used by element commands.", options: [ { name: "full", @@ -244,9 +243,7 @@ export const COMMANDS: CommandSpec[] = [ cli: "browse highlight ", summary: "Flash a visual highlight around an element.", args: [{ name: "target", param: "selector", type: "string", doc: SELECTOR_DOC }], - options: [ - { name: "durationMs", type: "number", doc: "Defaults to 2000." }, - ], + options: [{ name: "durationMs", type: "number", doc: "Defaults to 2000." }], returns: "{ highlighted: true }", }, @@ -324,8 +321,8 @@ export const COMMANDS: CommandSpec[] = [ name: "frame", type: "string", doc: - "Run inside an iframe instead of the top page: a snapshot frame index (`\"2\"` for refs `[2-…]`), " + - "the iframe's ref (`\"@0-10\"`), or an iframe selector (`\"#checkout\"`, `\"#outer >> iframe\"`). " + + 'Run inside an iframe instead of the top page: a snapshot frame index (`"2"` for refs `[2-…]`), ' + + 'the iframe\'s ref (`"@0-10"`), or an iframe selector (`"#checkout"`, `"#outer >> iframe"`). ' + "Cross-site frames work too.", }, ], @@ -364,9 +361,7 @@ export const COMMANDS: CommandSpec[] = [ { name: "width", type: "number" }, { name: "height", type: "number" }, ], - options: [ - { name: "scale", type: "number", doc: "Device pixel ratio. Defaults to 1." }, - ], + options: [{ name: "scale", type: "number", doc: "Device pixel ratio. Defaults to 1." }], returns: "{ viewport: { height: number; width: number } }", }, { @@ -483,8 +478,7 @@ export const COMMANDS: CommandSpec[] = [ cli: "browse tab new [url]", summary: "Open a new tab and make it active.", args: [{ name: "url", type: "string", optional: true }], - returns: - "{ active: true; index: number; targetId?: string; title?: string; url: string }", + returns: "{ active: true; index: number; targetId?: string; title?: string; url: string }", }, { group: "tab", @@ -500,8 +494,7 @@ export const COMMANDS: CommandSpec[] = [ doc: "Prefer the `targetId` from `tab.list()`; indexes shift as tabs open and close.", }, ], - returns: - "{ index: number; switched: true; targetId?: string; title?: string; url: string }", + returns: "{ index: number; switched: true; targetId?: string; title?: string; url: string }", }, { group: "tab", @@ -509,11 +502,8 @@ export const COMMANDS: CommandSpec[] = [ wire: "tab.close", cli: "browse tab close [tab]", summary: "Close a tab. Defaults to the active tab; refuses to close the last one.", - args: [ - { name: "tab", type: "string | number", optional: true, coerce: "string" }, - ], - returns: - "{ closed: true; index: number; selectedTargetId?: string; targetId?: string }", + args: [{ name: "tab", type: "string | number", optional: true, coerce: "string" }], + returns: "{ closed: true; index: number; selectedTargetId?: string; targetId?: string }", }, // ------------------------------------------------------------------- session @@ -608,10 +598,7 @@ export function commandGroups(): GroupName[] { * Build driver params from the positional arguments and trailing options object * a sandbox program passed, following the spec's arg/option declarations. */ -export function buildParams( - spec: CommandSpec, - args: readonly unknown[], -): Record { +export function buildParams(spec: CommandSpec, args: readonly unknown[]): Record { const params: Record = {}; const argSpecs = spec.args ?? []; diff --git a/packages/mcp-server/src/browse/driver.ts b/packages/mcp-server/src/browse/driver.ts index 99894d7..ee2f674 100644 --- a/packages/mcp-server/src/browse/driver.ts +++ b/packages/mcp-server/src/browse/driver.ts @@ -13,17 +13,21 @@ import { NodeHtmlMarkdown } from "node-html-markdown"; import { - connectBrowser, type BrowserContext, + connectBrowser, type LoadState, type MouseButton, type Page, } from "./page.ts"; +import { + emptyRefMaps, + type FrameTarget, + type RefMaps, + type ResolvedSelector, + resolveSelector, +} from "./selectors.ts"; import type { OpenSocket } from "./transport.ts"; - -import { emptyRefMaps, resolveSelector, type FrameTarget, type RefMaps, type ResolvedSelector } from "./selectors.ts"; - /** Raised for command-level failures, mirroring the CLI's error surface. */ export class BrowseCommandError extends Error { readonly code: string | undefined; @@ -76,7 +80,12 @@ export class BrowserSession implements CommandRunner { } /** Attach to a browser that already exists. */ - static async connect({ cdpUrl, signal, onEvent, openSocket }: ConnectOptions): Promise { + static async connect({ + cdpUrl, + signal, + onEvent, + openSocket, + }: ConnectOptions): Promise { const context = await connectBrowser(cdpUrl, signal, openSocket); onEvent?.("attached over CDP"); return new BrowserSession(context, cdpUrl); @@ -89,7 +98,10 @@ export class BrowserSession implements CommandRunner { async run(command: string, params: Params): Promise { const handler = HANDLERS[command]; if (!handler) throw new BrowseCommandError(`Unknown command "${command}".`); - if (["open", "reload", "back", "forward", "tab.new", "tab.switch", "tab.close"].includes(command)) this.setRefMaps({}); + if ( + ["open", "reload", "back", "forward", "tab.new", "tab.switch", "tab.close"].includes(command) + ) + this.setRefMaps({}); return handler(this, params); } @@ -340,13 +352,18 @@ const HANDLERS: Record = { const quality = optNum(params, "quality"); const clip = params.clip; const buffer = await page.screenshot({ - ...(optBool(params, "fullPage") === undefined ? {} : { fullPage: optBool(params, "fullPage") }), + ...(optBool(params, "fullPage") === undefined + ? {} + : { fullPage: optBool(params, "fullPage") }), ...(type === undefined ? {} : { type: type as "png" | "jpeg" }), ...(quality === undefined ? {} : { quality }), ...(clip === undefined ? {} : { clip: clip as never }), }); - if (optStr(params, "path")) throw new BrowseCommandError("Screenshot paths are unavailable; use the returned base64 data."); + if (optStr(params, "path")) + throw new BrowseCommandError( + "Screenshot paths are unavailable; use the returned base64 data.", + ); return { base64: buffer }; }, @@ -377,7 +394,9 @@ const HANDLERS: Record = { } else if (type === "timeout") { const ms = Number(arg ?? 0); if (!Number.isInteger(ms) || ms < 0) { - throw new BrowseCommandError("wait timeout requires a non-negative integer of milliseconds."); + throw new BrowseCommandError( + "wait timeout requires a non-negative integer of milliseconds.", + ); } await page.waitForTimeout(ms); } else { @@ -487,7 +506,14 @@ const HANDLERS: Record = { const active = await context.activePage(); const resolved = tab ? await resolveTab(session, tab) - : { index: Math.max(0, pages.findIndex((p) => p.pageId === active?.pageId)), page: active ?? pages[0]! }; + : { + index: Math.max( + 0, + pages.findIndex((p) => p.pageId === active?.pageId), + ), + page: active ?? pages[0], + }; + if (!resolved.page) throw new BrowseCommandError("No tab is open."); const closedTargetId = resolved.page.pageId; await resolved.page.close(); @@ -538,10 +564,11 @@ async function resolveTab( } const index = pages.findIndex((page) => page.pageId === tab); - if (index === -1) { + const page = pages[index]; + if (!page) { throw new BrowseCommandError(`Tab "${tab}" was not found. Call tab.list() for current tabs.`); } - return { index, page: pages[index]! }; + return { index, page }; } async function safeTitle(page: Page): Promise { @@ -607,17 +634,15 @@ export function formatTree( if (filter) { const match = matcher(filter); const keep = new Set(); - const ancestors: number[] = []; + const ancestors: Array<{ index: number; depth: number }> = []; lines.forEach((line, index) => { const depth = indentWidth(line); - while (ancestors.length > 0 && indentWidth(lines[ancestors.at(-1)!]!) >= depth) { - ancestors.pop(); - } + while ((ancestors.at(-1)?.depth ?? -1) >= depth) ancestors.pop(); if (match(line)) { keep.add(index); - for (const ancestor of ancestors) keep.add(ancestor); + for (const ancestor of ancestors) keep.add(ancestor.index); } - ancestors.push(index); + ancestors.push({ index, depth }); }); lines = lines.filter((_, index) => keep.has(index)); } diff --git a/packages/mcp-server/src/browse/dts.ts b/packages/mcp-server/src/browse/dts.ts index aecb1b9..2db011c 100644 --- a/packages/mcp-server/src/browse/dts.ts +++ b/packages/mcp-server/src/browse/dts.ts @@ -6,7 +6,7 @@ * types, and the sandbox globals at the same time. */ -import { COMMANDS, commandGroups, SHARED_TYPES, type CommandSpec } from "./commands.ts"; +import { COMMANDS, type CommandSpec, commandGroups, SHARED_TYPES } from "./commands.ts"; export interface RenderOptions { /** Restrict output to these `group.fn` keys. Defaults to all. */ @@ -15,7 +15,8 @@ export interface RenderOptions { const GROUP_DOCS: Record = { browse: "Top-level `browse` commands: navigation, snapshots, elements, and page state.", - mouse: "Raw coordinate input (`browse mouse ...`). Prefer refs when the element is in a snapshot.", + mouse: + "Raw coordinate input (`browse mouse ...`). Prefer refs when the element is in a snapshot.", tab: "Tab management (`browse tab ...`).", network: "Network capture (`browse network ...`).", }; @@ -89,7 +90,9 @@ export function renderCheatsheet(options: RenderOptions = {}): string { if (specs.length === 0) continue; lines.push(`## ${group}`); for (const spec of specs) { - lines.push(`- \`${spec.group}.${spec.fn}${signature(spec)}\` — ${spec.summary} (\`${spec.cli}\`)`); + lines.push( + `- \`${spec.group}.${spec.fn}${signature(spec)}\` — ${spec.summary} (\`${spec.cli}\`)`, + ); } lines.push(""); } @@ -115,9 +118,7 @@ function docComment(spec: CommandSpec): string { const lines: string[] = [spec.summary, "", `CLI: \`${spec.cli}\``]; const documented = [ - ...(spec.args ?? []) - .filter((arg) => arg.doc) - .map((arg) => `@param ${arg.name} ${arg.doc}`), + ...(spec.args ?? []).filter((arg) => arg.doc).map((arg) => `@param ${arg.name} ${arg.doc}`), ...(spec.options ?? []) .filter((option) => option.doc) .map((option) => `@param options.${option.name} ${option.doc}`), @@ -134,9 +135,7 @@ function signature(spec: CommandSpec): string { ); if (spec.options?.length) { - const fields = spec.options - .map((option) => `${option.name}?: ${option.type}`) - .join("; "); + const fields = spec.options.map((option) => `${option.name}?: ${option.type}`).join("; "); params.push(`options?: { ${fields} }`); } diff --git a/packages/mcp-server/src/browse/host-functions.ts b/packages/mcp-server/src/browse/host-functions.ts index 56b11f2..93b0fbf 100644 --- a/packages/mcp-server/src/browse/host-functions.ts +++ b/packages/mcp-server/src/browse/host-functions.ts @@ -12,8 +12,8 @@ type HostFunctions = Record Promi import { buildParams, COMMANDS, - commandGroups, type CommandSpec, + commandGroups, type GroupName, } from "./commands.ts"; import type { CommandRunner } from "./driver.ts"; @@ -71,7 +71,9 @@ export function createHostFunctions(options: HostFunctionOptions): HostFunctionB const key = `${spec.group}.${spec.fn}`; if (!isPermitted(key, options)) continue; exposed.push(key); - hostFunctions[spec.group]![spec.fn] = (...args: unknown[]) => invoke(spec, args, calls, counter, options); + const group = hostFunctions[spec.group] ?? {}; + hostFunctions[spec.group] = group; + group[spec.fn] = (...args: unknown[]) => invoke(spec, args, calls, counter, options); } return { hostFunctions, calls, exposed }; @@ -146,10 +148,7 @@ function isPermitted(key: string, options: HostFunctionOptions): boolean { * Render a call as the shell command it stands for, so a program's trace reads * like the `browse` invocations an agent would otherwise have typed. */ -export function formatCall( - spec: CommandSpec, - params: Record, -): string { +export function formatCall(spec: CommandSpec, params: Record): string { const words = cliWords(spec.cli); const positional = (spec.args ?? []) .map((arg) => params[arg.param ?? arg.name]) diff --git a/packages/mcp-server/src/browse/page.ts b/packages/mcp-server/src/browse/page.ts index 89545a5..3724ff5 100644 --- a/packages/mcp-server/src/browse/page.ts +++ b/packages/mcp-server/src/browse/page.ts @@ -1,13 +1,13 @@ /** Direct CDP implementation: no browser extension, filesystem, or Node APIs. */ -import { CdpConnection, openWebSocket, type OpenSocket } from "./transport.ts"; + import { resolveCdpUrl } from "./cdp.ts"; import type { FrameTarget, ResolvedSelector } from "./selectors.ts"; +import { CdpConnection, type CdpPayload, type OpenSocket, openWebSocket } from "./transport.ts"; export type LoadState = "load" | "domcontentloaded" | "networkidle"; export type MouseButton = "left" | "middle" | "right"; type NavigationOptions = { timeout?: number; waitUntil?: LoadState }; -const pause = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export async function connectBrowser( cdpUrl: string, @@ -57,8 +57,7 @@ export class BrowserContext { const selected = pages.find((p) => p.pageId === this.selected); if (selected) return selected; for (const page of pages) { - if (await page.evaluate("document.hasFocus()").catch(() => false)) - return page; + if (await page.evaluate("document.hasFocus()").catch(() => false)) return page; } return pages[0]; } @@ -93,12 +92,7 @@ export class Page { /** Each session's frames → the id of their main-world execution context. */ private worlds = new Map>(); private runtimeEnabled = new Map>(); - constructor( - cdp: CdpConnection, - id: string, - sessionId: string, - signal?: AbortSignal, - ) { + constructor(cdp: CdpConnection, id: string, sessionId: string, signal?: AbortSignal) { this.cdp = cdp; this.pageId = id; this.sessionId = sessionId; @@ -108,7 +102,7 @@ export class Page { if (params.targetInfo?.type !== "iframe") return; this.oopifs.set(params.targetInfo.targetId, { sessionId: params.sessionId, - parentSessionId: session!, + parentSessionId: session, }); // A cross-site frame can hold cross-site frames of its own. this.watch(this.autoAttach(params.sessionId)); @@ -120,17 +114,14 @@ export class Page { return; } if (method.startsWith("Runtime.executionContext") && this.ownsSession(session)) { - this.trackWorld(session!, method, params); + this.trackWorld(session, method, params); return; } if (session !== sessionId) return; if (method === "Network.requestWillBeSent") { this.requests.add(params.requestId); this.networkChangedAt = Date.now(); - } else if ( - method === "Network.loadingFinished" || - method === "Network.loadingFailed" - ) { + } else if (method === "Network.loadingFinished" || method === "Network.loadingFailed") { this.requests.delete(params.requestId); this.networkChangedAt = Date.now(); } @@ -166,15 +157,17 @@ export class Page { const tracked = pending.catch(() => {}).finally(() => this.attaching.delete(tracked)); this.attaching.add(tracked); } - private ownsSession(session: string | undefined): boolean { + private ownsSession(session: string | undefined): session is string { if (session === this.sessionId) return true; - for (const frame of this.oopifs.values()) - if (frame.sessionId === session) return true; + for (const frame of this.oopifs.values()) if (frame.sessionId === session) return true; return false; } - private trackWorld(session: string, method: string, params: any) { + private trackWorld(session: string, method: string, params: CdpPayload) { let worlds = this.worlds.get(session); - if (!worlds) this.worlds.set(session, (worlds = new Map())); + if (!worlds) { + worlds = new Map(); + this.worlds.set(session, worlds); + } if (method === "Runtime.executionContextsCleared") return worlds.clear(); if (method === "Runtime.executionContextDestroyed") { for (const [frameId, id] of worlds) @@ -190,7 +183,7 @@ export class Page { * only reports context ids after Runtime.enable, so enable it per session on * first use; existing contexts arrive before enable returns. */ - async evaluateIn(frame: FrameTarget, expression: string): Promise { + async evaluateIn(frame: FrameTarget, expression: string): Promise { let enabling = this.runtimeEnabled.get(frame.sessionId); if (!enabling) { enabling = this.sendTo(frame.sessionId, "Runtime.enable"); @@ -207,7 +200,9 @@ export class Page { awaitPromise: true, }); if (response.exceptionDetails) - throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text); + throw new Error( + response.exceptionDetails.exception?.description ?? response.exceptionDetails.text, + ); return response.result.value; } /** Wait until every nested iframe target has been attached. */ @@ -230,7 +225,9 @@ export class Page { const entry = [...this.oopifs].find(([, frame]) => frame.sessionId === sessionId); if (!entry) throw new Error("The element's frame is no longer attached."); const [frameId, { parentSessionId }] = entry; - const { backendNodeId } = await this.sendTo(parentSessionId, "DOM.getFrameOwner", { frameId }); + const { backendNodeId } = await this.sendTo(parentSessionId, "DOM.getFrameOwner", { + frameId, + }); const { model } = await this.sendTo(parentSessionId, "DOM.getBoxModel", { backendNodeId }); x += model.content[0]; y += model.content[1]; @@ -247,11 +244,18 @@ export class Page { ]; const perSession = await Promise.all( sessions.map(async ({ sessionId, parentSessionId }) => { - const { frameTree } = await this.sendTo(sessionId, "Page.getFrameTree").catch(() => ({ frameTree: undefined })); + const { frameTree } = await this.sendTo(sessionId, "Page.getFrameTree").catch(() => ({ + frameTree: undefined, + })); if (!frameTree) return []; const found: SnapshotFrame[] = []; - const walk = (node: any, root: boolean) => { - found.push({ frameId: node.frame.id, sessionId, ownerSession: root ? parentSessionId : sessionId, nodes: [] }); + const walk = (node: FrameTreeNode, root: boolean) => { + found.push({ + frameId: node.frame.id, + sessionId, + ownerSession: root ? parentSessionId : sessionId, + nodes: [], + }); for (const child of node.childFrames ?? []) walk(child, false); }; walk(frameTree, true); @@ -263,9 +267,13 @@ export class Page { frames.map(async (frame) => { // A frame can navigate or detach mid-snapshot; leave it out rather than fail. const [tree, owner] = await Promise.all([ - this.sendTo(frame.sessionId, "Accessibility.getFullAXTree", { frameId: frame.frameId }).catch(() => undefined), + this.sendTo(frame.sessionId, "Accessibility.getFullAXTree", { + frameId: frame.frameId, + }).catch(() => undefined), frame.ownerSession - ? this.sendTo(frame.ownerSession, "DOM.getFrameOwner", { frameId: frame.frameId }).catch(() => undefined) + ? this.sendTo(frame.ownerSession, "DOM.getFrameOwner", { + frameId: frame.frameId, + }).catch(() => undefined) : undefined, ]); frame.nodes = tree?.nodes ?? []; @@ -274,7 +282,7 @@ export class Page { ); return frames; } - async evaluate(expression: string): Promise { + async evaluate(expression: string): Promise { const response = await this.send("Runtime.evaluate", { expression, returnByValue: true, @@ -282,8 +290,7 @@ export class Page { }); if (response.exceptionDetails) throw new Error( - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text, + response.exceptionDetails.exception?.description ?? response.exceptionDetails.text, ); return response.result.value; } @@ -305,8 +312,7 @@ export class Page { private async history(delta: number, options: NavigationOptions) { const history = await this.send("Page.getNavigationHistory"); const entry = history.entries[history.currentIndex + delta]; - if (entry) - await this.send("Page.navigateToHistoryEntry", { entryId: entry.id }); + if (entry) await this.send("Page.navigateToHistoryEntry", { entryId: entry.id }); await this.waitForLoadState(options.waitUntil ?? "load", options.timeout); } goBack(options: NavigationOptions = {}) { @@ -326,18 +332,13 @@ export class Page { const started = Date.now(); return this.poll( async () => - this.requests.size === 0 && - Date.now() - Math.max(started, this.networkChangedAt) >= 500, + this.requests.size === 0 && Date.now() - Math.max(started, this.networkChangedAt) >= 500, timeout, ); } await this.poll(async () => { - const ready = await this.evaluate("document.readyState").catch( - () => "loading", - ); - return state === "domcontentloaded" - ? ready !== "loading" - : ready === "complete"; + const ready = await this.evaluate("document.readyState").catch(() => "loading"); + return state === "domcontentloaded" ? ready !== "loading" : ready === "complete"; }, timeout); } async poll(check: () => Promise, timeout: number) { @@ -377,10 +378,9 @@ export class Page { } async keyPress(combo: string) { const parts = combo.split("+"); - const key = parts.pop()!; + const key = parts.pop() ?? ""; const modifiers = parts.reduce( - (n, p) => - n | ({ Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 }[p] ?? 0), + (n, p) => n | ({ Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 }[p] ?? 0), 0, ); const codes: Record = { @@ -399,12 +399,7 @@ export class Page { PageDown: 34, }; const code = codes[key] ?? key.toUpperCase().charCodeAt(0); - const text = - key === "Enter" - ? "\r" - : key.length === 1 && !(modifiers & 7) - ? key - : undefined; + const text = key === "Enter" ? "\r" : key.length === 1 && !(modifiers & 7) ? key : undefined; await this.send("Input.dispatchKeyEvent", { type: "keyDown", key, @@ -419,10 +414,7 @@ export class Page { windowsVirtualKeyCode: code, }); } - async type( - text: string, - options?: { delay?: number; withMistakes?: boolean }, - ) { + async type(text: string, options?: { delay?: number; withMistakes?: boolean }) { if (options?.delay || options?.withMistakes) for (const char of text) { if (options.withMistakes && Math.random() < 0.08) { @@ -438,10 +430,7 @@ export class Page { async click( x: number, y: number, - { - button = "left", - clickCount = 1, - }: { button?: MouseButton; clickCount?: number } = {}, + { button = "left", clickCount = 1 }: { button?: MouseButton; clickCount?: number } = {}, ) { await this.hover(x, y); await this.send("Input.dispatchMouseEvent", { @@ -524,9 +513,7 @@ export class Page { const { data } = await this.send("Page.captureScreenshot", { format: options.type ?? "png", ...(options.quality === undefined ? {} : { quality: options.quality }), - ...(clip - ? { clip: { ...clip, scale: 1 }, captureBeyondViewport: true } - : {}), + ...(clip ? { clip: { ...clip, scale: 1 }, captureBeyondViewport: true } : {}), }); return data as string; } @@ -565,8 +552,8 @@ export class Page { const renderFrame = (frame: SnapshotFrame, depth: number) => { const index = nextFrame++; frameMap[index] = { sessionId: frame.sessionId, frameId: frame.frameId }; - const byId = new Map(frame.nodes.map((node: any) => [node.nodeId, node])); - const visit = (node: any, depth: number, parentName: string | undefined) => { + const byId = new Map(frame.nodes.map((node) => [node.nodeId, node])); + const visit = (node: AXNode | undefined, depth: number, parentName: string | undefined) => { if (!node) return; const role = node.role?.value ?? "node"; const name = node.name?.value ?? ""; @@ -578,8 +565,8 @@ export class Page { if (shown && ref) { // Resolve refs using backend node IDs, never a server-side page registry. xpathMap[ref] = `backend=${node.backendDOMNodeId}`; - const url = node.properties?.find((p: { name: string }) => p.name === "url")?.value?.value; - if (url) urlMap[ref] = url; + const url = node.properties?.find((p) => p.name === "url")?.value?.value; + if (typeof url === "string" && url) urlMap[ref] = url; } if (shown) lines.push( @@ -588,7 +575,8 @@ export class Page { const childDepth = depth + (shown ? 1 : 0); for (const child of node.childIds ?? []) visit(byId.get(child), childDepth, node.ignored ? parentName : name); - const inner = node.backendDOMNodeId && hosted.get(`${frame.sessionId}:${node.backendDOMNodeId}`); + const inner = + node.backendDOMNodeId && hosted.get(`${frame.sessionId}:${node.backendDOMNodeId}`); if (inner) renderFrame(inner, childDepth); }; for (const node of frame.nodes) @@ -607,9 +595,31 @@ interface SnapshotFrame { /** Session holding the