diff --git a/benchmarks/page-token-benchmark/config/conditions.yaml b/benchmarks/page-token-benchmark/config/conditions.yaml index fc2acae..c2fc576 100644 --- a/benchmarks/page-token-benchmark/config/conditions.yaml +++ b/benchmarks/page-token-benchmark/config/conditions.yaml @@ -1,6 +1,7 @@ # Each condition defines how to fetch a page snapshot. # tool_mode: cli → runs ` [--raw]` as a subprocess # tool_mode: bridge → calls navigate_page via the bridge HTTP API +# tool_mode: lightpanda → runs `lightpanda fetch --dump --log-level error ` as a subprocess - id: opera-compact description: opera-browser-cli compact snapshot, full page (--full) @@ -37,3 +38,49 @@ - "chrome-devtools-axi stop" - "chrome-devtools-axi start" stop: "chrome-devtools-axi stop" + +- id: lightpanda-fetch-markdown + description: lightpanda fetch --dump markdown (one-shot, no CDP/bridge) + tool_mode: lightpanda + cli_bin: lightpanda + dump: markdown + +- id: lightpanda-fetch-html + description: lightpanda fetch --dump html (one-shot, raw DOM) + tool_mode: lightpanda + cli_bin: lightpanda + dump: html + +- id: lightpanda-fetch-semantic-tree + description: lightpanda fetch --dump semantic_tree_text (pruned plain-text tree) + tool_mode: lightpanda + cli_bin: lightpanda + dump: semantic_tree_text + +- id: panda-compact + description: opera-browser-cli with panda backend, compact snapshot, full page (--full) + tool_mode: cli + cli_bin: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js + nav_cmd: open + raw: false + full: true + env: + OPERA_CLI_BROWSER_BACKEND: panda + OPERA_CLI_LIGHTPANDA_BIN: /Users/opera_user/work/browser/zig-out/bin/lightpanda + start: + - /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop + stop: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop + +- id: panda-raw + description: opera-browser-cli with panda backend, uncompressed MCP output (--raw --full) + tool_mode: cli + cli_bin: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js + nav_cmd: open + raw: true + full: true + env: + OPERA_CLI_BROWSER_BACKEND: panda + OPERA_CLI_LIGHTPANDA_BIN: /Users/opera_user/work/browser/zig-out/bin/lightpanda + start: + - /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop + stop: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop diff --git a/benchmarks/page-token-benchmark/src/cli_runner.py b/benchmarks/page-token-benchmark/src/cli_runner.py index c3f8b03..9a23051 100644 --- a/benchmarks/page-token-benchmark/src/cli_runner.py +++ b/benchmarks/page-token-benchmark/src/cli_runner.py @@ -129,6 +129,46 @@ def run_bridge(url: str, bridge_url: str, timeout: int) -> RunResult: return RunResult(stdout="", stderr="", returncode=-1, wall_seconds=wall, error=str(exc)) +def run_lightpanda_fetch( + url: str, + cli_bin: str, + dump: str, + timeout: int, +) -> RunResult: + """One-shot Lightpanda fetch: `lightpanda fetch --dump --log-level error `. + + Dump renders to stdout; logs go to stderr; no server/bridge/CDP involved. + `dump` is one of `markdown | html | semantic_tree | semantic_tree_text`. + + Note: Lightpanda `fetch` exits 0 even when navigation fails (it prints + ``# Navigation failed`` to stdout), so failure is detected separately. + """ + cmd = [cli_bin, "fetch", "--dump", dump, "--log-level", "error", url] + start = time.monotonic() + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + wall = time.monotonic() - start + error = _detect_error(proc.stdout, proc.returncode) + if error is None: + stripped = proc.stdout.lstrip() + if stripped.startswith("# Navigation failed") or "CouldntConnect" in stripped: + error = stripped.splitlines()[0] if stripped.splitlines() else "navigation failed" + return RunResult( + stdout=proc.stdout, + stderr=proc.stderr, + returncode=proc.returncode, + wall_seconds=wall, + error=error, + ) + except subprocess.TimeoutExpired: + wall = time.monotonic() - start + return RunResult(stdout="", stderr="", returncode=-1, wall_seconds=wall, + error=f"timeout after {timeout}s: {shlex.join(cmd)}") + except Exception as exc: + wall = time.monotonic() - start + return RunResult(stdout="", stderr="", returncode=-1, wall_seconds=wall, error=str(exc)) + + def run_condition(url: str, condition: dict, timeout: int = 60) -> RunResult: """Dispatch to the right runner based on condition tool_mode.""" mode = condition["tool_mode"] @@ -145,4 +185,12 @@ def run_condition(url: str, condition: dict, timeout: int = 60) -> RunResult: ) if mode == "bridge": return run_bridge(url, bridge_url=condition.get("bridge_url", "http://localhost:9224"), timeout=timeout) + if mode == "lightpanda": + return run_lightpanda_fetch( + url, + cli_bin=condition.get("cli_bin", "lightpanda"), + dump=condition.get("dump", "markdown"), + timeout=timeout, + ) + raise ValueError(f"Unknown tool_mode: {mode}") diff --git a/bin/panda-mcp-adapter.ts b/bin/panda-mcp-adapter.ts new file mode 100755 index 0000000..f26583c --- /dev/null +++ b/bin/panda-mcp-adapter.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import { main } from "../src/panda-mcp-adapter.js"; + +main(process.argv.slice(2)).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[panda-mcp-adapter] Fatal: ${message}\n`); + process.exit(1); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 500624f..30ccd9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "opera-devtools-mcp": "^0.5.0" }, "bin": { - "opera-browser-cli": "dist/bin/opera-browser-cli.js" + "opera-browser-cli": "dist/bin/opera-browser-cli.js", + "panda-mcp-adapter": "dist/bin/panda-mcp-adapter.js" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/package.json b/package.json index 948eca8..a77b749 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "mcp" ], "bin": { - "opera-browser-cli": "dist/bin/opera-browser-cli.js" + "opera-browser-cli": "dist/bin/opera-browser-cli.js", + "panda-mcp-adapter": "dist/bin/panda-mcp-adapter.js" }, "files": [ "dist", diff --git a/src/bridge.ts b/src/bridge.ts index 7c7ac76..3bb5e5e 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -36,6 +36,7 @@ import { type BridgeHealth, } from "./identity.js"; import { getPackageVersion } from "./version.js"; +import { detectLightpanda } from "./detect.js"; const DEFAULT_PORT = Number.parseInt( process.env.OPERA_CLI_PORT ?? "9225", @@ -666,6 +667,7 @@ export function shouldRunHeaded(): boolean { } export function buildTransportArgs(): string[] { + if (isPandaBackend()) return buildPandaTransportArgs(); const args: string[] = []; const browserUrl = process.env.OPERA_CLI_BROWSER_URL; @@ -713,6 +715,44 @@ export function buildTransportArgs(): string[] { return args; } +/** True when the Lightpanda ("panda") backend is selected instead of Chrome/Opera. */ +export function isPandaBackend(): boolean { + return process.env.OPERA_CLI_BROWSER_BACKEND === "panda"; +} + +/** + * Args for the panda adapter shim. Unlike Chrome, there are no browser-launch + * flags to forward — the shim resolves the lightpanda binary itself. We hand it + * the resolved path so `doctor` and the shim agree on the same binary. + */ +function buildPandaTransportArgs(): string[] { + const lightpanda = detectLightpanda(); + return lightpanda ? [`--lightpanda-bin=${lightpanda}`] : []; +} + +/** Resolve the adapter shim script path (OPERA_CLI_MCP_BIN override, else bundled). */ +function resolvePandaAdapterScript(): string { + if (process.env.OPERA_CLI_MCP_BIN) return process.env.OPERA_CLI_MCP_BIN; + const built = resolve(import.meta.dirname, "../bin/panda-mcp-adapter.js"); + const source = built.replace(/\.js$/, ".ts"); + return existsSync(source) ? source : built; +} + +/** Decide how to launch the panda adapter shim, mirroring resolveBridgeLauncher. */ +function resolvePandaAdapterLauncher(): BridgeLauncher { + const built = resolve(import.meta.dirname, "../bin/panda-mcp-adapter.js"); + const source = built.replace(/\.js$/, ".ts"); + const preferSource = + process.env.OPERA_CLI_DEV === "1" || !existsSync(built); + if (preferSource && existsSync(source)) { + const tsx = resolveTsxCli(); + if (tsx === null) return { ok: false, reason: "tsx-not-installed" }; + return { ok: true, command: process.execPath, args: [tsx, source] }; + } + if (!existsSync(built)) return { ok: false, reason: "panda-adapter-not-built" }; + return { ok: true, command: process.execPath, args: [built] }; +} + export interface McpBinStatus { bin: string; found: boolean; @@ -742,6 +782,14 @@ function existsOnPath(command: string): boolean { * bridge start. */ export function resolveMcpBinStatus(): McpBinStatus { + if (isPandaBackend()) { + const bin = resolveOperaMcpBin(); + return { + bin, + found: existsSync(bin) || existsOnPath(bin), + source: process.env.OPERA_CLI_MCP_BIN ? "env" : "dependency", + }; + } const bin = resolveOperaMcpBin(); if (process.env.OPERA_CLI_MCP_BIN) { return { bin, found: existsSync(bin) || existsOnPath(bin), source: "env" }; @@ -753,6 +801,7 @@ export function resolveMcpBinStatus(): McpBinStatus { } function resolveOperaMcpBin(): string { + if (isPandaBackend()) return resolvePandaAdapterScript(); if (process.env.OPERA_CLI_MCP_BIN) return process.env.OPERA_CLI_MCP_BIN; try { const require = createRequire(import.meta.url); @@ -776,6 +825,16 @@ function resolveOperaMcpBin(): string { } function createTransport(): StdioClientTransport { + if (isPandaBackend()) { + const launcher = resolvePandaAdapterLauncher(); + if (!launcher.ok) { + throw new Error(`Cannot launch the panda adapter: ${launcher.reason}`); + } + return new StdioClientTransport({ + command: launcher.command, + args: [...launcher.args, ...buildPandaTransportArgs()], + }); + } const bin = resolveOperaMcpBin(); const args = buildTransportArgs(); if (bin.endsWith(".js")) { diff --git a/src/browser-target.ts b/src/browser-target.ts index f273323..d1f485e 100644 --- a/src/browser-target.ts +++ b/src/browser-target.ts @@ -140,6 +140,7 @@ export async function launchAttachableBrowser( executablePath: string | undefined, userDataDir: string, extraArgs: string[] = [], + headless = false, timeoutMs = 30_000, ): Promise { if (!executablePath || !existsSync(executablePath)) { @@ -166,6 +167,10 @@ export async function launchAttachableBrowser( ...extraArgs, ]; + if (headless) { + args.push("--headless"); + } + let child; try { child = spawn(executablePath, args, { stdio: "ignore", detached: true }); diff --git a/src/cli.ts b/src/cli.ts index ab1c55b..0bbb18a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,7 +37,7 @@ import { type StopResult, } from "./client.js"; import { getPackageVersion } from "./version.js"; -import { resolveMcpBinStatus, shouldRunHeaded } from "./bridge.js"; +import { isPandaBackend, resolveMcpBinStatus, shouldRunHeaded } from "./bridge.js"; import { autoConfigure, findUnknownConfigKeys, @@ -48,6 +48,7 @@ import { import { browserDisplayName, detectBrowsers, + detectLightpanda, neonCandidatePaths, operaCandidatePaths, } from "./detect.js"; @@ -70,6 +71,7 @@ import { extractTitle, truncateSnapshot, truncateText, + compactMarkdown, compactSnapshot, applyUrlLut, resolveUrl, @@ -141,6 +143,9 @@ environment: OPERA_CLI_ENABLE_HOOKS Set to 1 to auto-install session hooks on startup OPERA_CLI_TAKEOVER Set to 1 to allow restarting a running Opera without asking (same as the --takeover flag) + OPERA_CLI_BROWSER_BACKEND Select the browser backend: "chrome" (default) or "panda" + panda uses the Lightpanda text-only browser + OPERA_CLI_LIGHTPANDA_BIN Path to the lightpanda binary (auto-detected on PATH) Environment variables can also be set in ~/.opera-browser-cli/config (KEY=VALUE, one per line). Run \`opera-browser-cli setup\` to configure interactively. @@ -1246,13 +1251,13 @@ function parseSnapshotFromResponse(response: string): string | null { } /** Format page metadata (TOON) + snapshot + suggestions. */ -function formatPageOutput( +async function formatPageOutput( snapshot: string, command: string, url?: string, full = false, raw = false, -): string { +): Promise { const tree = raw ? snapshot : compactSnapshot(snapshot); const title = extractTitle(tree); @@ -1289,6 +1294,16 @@ function formatPageOutput( } blocks.push(snapshotBlock); + // Panda backend: append the compacted page markdown, since panda's tree is + // structure-only (roles + refs) and omits prose. Chrome/Opera carry text in + // the tree itself, so this trailer is panda-only. + if (isPandaBackend() && !raw) { + const markdown = await fetchPandaMarkdown(); + if (markdown !== null) { + blocks.push(`text:\n${markdown}`); + } + } + // Contextual suggestions const suggestions = getSuggestions({ command, url, snapshot: tree }); if (tr.truncated) { @@ -1303,6 +1318,19 @@ function formatPageOutput( return renderOutput(blocks); } +/** Read the current page's compacted markdown on the panda backend, or null when unavailable. */ +async function fetchPandaMarkdown(): Promise { + try { + const result = await callTool("page_markdown", {}); + if (typeof result !== "string" || result.length === 0) return null; + const compact = compactMarkdown(result); + return compact.length > 0 ? compact : null; + } catch { + // Markdown is an optional trailer: never fail the whole command over it. + return null; + } +} + /** Strip everything before the actual accessibility tree (MCP may prepend status lines and headers). */ function stripSnapshotHeader(text: string): string { // Find the first line that looks like a tree node (uid= or RootWebArea) @@ -2032,6 +2060,7 @@ export interface SetupArgs { executable: string | undefined; profile: string | undefined; headed: boolean | undefined; + backend: "chrome" | "panda" | undefined; } export function parseSetupArgs(args: string[]): SetupArgs { @@ -2039,6 +2068,7 @@ export function parseSetupArgs(args: string[]): SetupArgs { let executable: string | undefined; let profile: string | undefined; let headed: boolean | undefined; + let backend: "chrome" | "panda" | undefined; for (let i = 0; i < args.length; i++) { switch (args[i]) { @@ -2059,6 +2089,13 @@ export function parseSetupArgs(args: string[]): SetupArgs { interactive = false; } break; + case "--backend": + if (i + 1 < args.length) { + const value = args[++i].toLowerCase(); + backend = value === "panda" ? "panda" : "chrome"; + interactive = false; + } + break; case "--headed": headed = true; interactive = false; @@ -2069,7 +2106,7 @@ export function parseSetupArgs(args: string[]): SetupArgs { break; } } - return { interactive, executable, profile, headed }; + return { interactive, executable, profile, headed, backend }; } /** Install SKILL.md for Claude Code and the generic cross-agent path. */ @@ -2101,6 +2138,33 @@ function installSkillFiles(report: (line: string) => void): void { */ function setupNonInteractive(parsed: SetupArgs): string { const config = readConfigFile(); + const backend = + parsed.backend ?? + (config.OPERA_CLI_BROWSER_BACKEND === "panda" ? "panda" : "chrome"); + + if (backend === "panda") { + config.OPERA_CLI_BROWSER_BACKEND = "panda"; + const lightpandaBin = detectLightpanda(); + if (lightpandaBin) config.OPERA_CLI_LIGHTPANDA_BIN = lightpandaBin; + writeConfigFile(config); + const notes: string[] = []; + installSkillFiles((line) => notes.push(line)); + const help = [ + "Run `opera-browser-cli open https://example.com` to browse with Lightpanda", + ]; + if (!lightpandaBin) { + help.push( + "lightpanda not found — install it (https://github.com/lightpanda-io/browser) or set OPERA_CLI_LIGHTPANDA_BIN", + ); + } + return renderOutput([ + encode({ config: getConfigFile(), settings: config }), + notes.join("\n"), + renderHelp(help), + ]); + } + + delete config.OPERA_CLI_BROWSER_BACKEND; const executable = parsed.executable ?? @@ -2160,6 +2224,38 @@ async function handleSetup(args: string[]): Promise { try { process.stdout.write("opera-browser-cli setup\n\n"); + // 0. Backend selection — panda configures lightpanda and skips the + // Chrome/Opera prompts below. + const currentBackend = existing["OPERA_CLI_BROWSER_BACKEND"] === "panda" ? "panda" : "chrome"; + const backendAns = ( + await ask(`Browser backend — "chrome" (Opera/Chrome) or "panda" (Lightpanda) [${currentBackend}]: `) + ).trim().toLowerCase(); + const pandaChosen = backendAns === "panda" || (backendAns === "" && currentBackend === "panda"); + + if (pandaChosen) { + config["OPERA_CLI_BROWSER_BACKEND"] = "panda"; + const detected = detectLightpanda(); + if (detected) { + config["OPERA_CLI_LIGHTPANDA_BIN"] = detected; + } else { + const binAns = (await ask("lightpanda binary not found on PATH — enter its path: ")).trim(); + if (binAns) config["OPERA_CLI_LIGHTPANDA_BIN"] = binAns; + else delete config["OPERA_CLI_LIGHTPANDA_BIN"]; + } + writeConfigFile(config); + process.stdout.write(`\nSaved to ${configFile}\n`); + installSkillFiles((line) => process.stdout.write(line + "\n")); + return renderOutput([ + encode({ config: configFile, settings: config }), + renderHelp([ + "Run `opera-browser-cli --help` to see all commands", + "Run `opera-browser-cli open https://example.com` to browse with Lightpanda", + ]), + ]); + } + + delete config["OPERA_CLI_BROWSER_BACKEND"]; + // 1. Browser executable path const detectedNeons = neonCandidatePaths(process.platform, homedir()).filter( (p) => existsSync(p), @@ -2405,6 +2501,27 @@ function formatBytes(n: number): string { async function runDoctorChecks(): Promise { const checks: DoctorCheck[] = []; + const panda = isPandaBackend(); + + // Backend selection + Lightpanda binary (panda only). + checks.push({ + name: "backend", + status: "ok", + detail: panda ? "panda (Lightpanda)" : "chrome", + }); + if (panda) { + const lightpandaBin = detectLightpanda(); + checks.push( + lightpandaBin + ? { name: "lightpanda", status: "ok", detail: lightpandaBin } + : { + name: "lightpanda", + status: "fail", + detail: + "lightpanda binary not found — set OPERA_CLI_LIGHTPANDA_BIN or install `lightpanda` on PATH", + }, + ); + } // Bridge const bridge = await getBridgeStatus(); @@ -2472,36 +2589,34 @@ async function runDoctorChecks(): Promise { } } - // Opera Neon executable - const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH; + // Opera Neon executable — Chrome/Opera backend only. const browserUrl = process.env.OPERA_CLI_BROWSER_URL; - if (browserUrl) { - checks.push({ - name: "neon", - status: "ok", - detail: `OPERA_CLI_BROWSER_URL=${browserUrl} (skipping executable check)`, - }); - } else if (!execPath) { - checks.push({ - name: "neon", - status: "warn", - detail: "OPERA_CLI_EXECUTABLE_PATH not set — AI commands will fail", - }); - } else if (!existsSync(execPath)) { - checks.push({ - name: "neon", - status: "fail", - detail: `OPERA_CLI_EXECUTABLE_PATH=${execPath} does not exist`, - }); - } else { - checks.push({ - name: "neon", - status: "ok", - detail: execPath, - }); + if (!panda) { + const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH; + if (browserUrl) { + checks.push({ + name: "neon", + status: "ok", + detail: `OPERA_CLI_BROWSER_URL=${browserUrl} (skipping executable check)`, + }); + } else if (!execPath) { + checks.push({ + name: "neon", + status: "warn", + detail: "OPERA_CLI_EXECUTABLE_PATH not set — AI commands will fail", + }); + } else if (!existsSync(execPath)) { + checks.push({ + name: "neon", + status: "fail", + detail: `OPERA_CLI_EXECUTABLE_PATH=${execPath} does not exist`, + }); + } else { + checks.push({ name: "neon", status: "ok", detail: execPath }); + } } - // opera-devtools-mcp — the bridge cannot start without it + // The MCP backend — either the panda adapter shim or opera-devtools-mcp. const mcp = resolveMcpBinStatus(); checks.push( mcp.found @@ -2509,12 +2624,12 @@ async function runDoctorChecks(): Promise { : { name: "mcp", status: "fail", - detail: `opera-devtools-mcp not found at ${mcp.bin} (${mcp.source})`, + detail: `${panda ? "panda adapter" : "opera-devtools-mcp"} not found at ${mcp.bin} (${mcp.source})`, }, ); - // Browser target — launch, or attach to something already running - if (browserUrl) { + // Browser target — launch, or attach to something already running (Chrome only). + if (!panda && browserUrl) { const attachPort = Number.parseInt(new URL(browserUrl).port, 10); const identity = Number.isFinite(attachPort) ? await probeDevToolsEndpoint(attachPort) @@ -2530,38 +2645,40 @@ async function runDoctorChecks(): Promise { ); } - // Profile lock — the usual reason a launch silently fails - const profileDir = process.env.OPERA_CLI_USER_DATA_DIR; - if (!profileDir) { - checks.push({ - name: "profile", - status: "ok", - detail: "isolated (no persistent profile configured)", - }); - } else if (!existsSync(profileDir)) { - checks.push({ - name: "profile", - status: "ok", - detail: `${profileDir} (will be created on first launch)`, - }); - } else { - const lock = inspectProfileLock(profileDir); - const attachable = readDevToolsPort(profileDir); - const live = attachable !== null ? await probeDevToolsEndpoint(attachable) : null; - if (lock.state === "free") { - checks.push({ name: "profile", status: "ok", detail: `${profileDir} (free)` }); - } else if (live) { + // Profile lock — the usual reason a launch silently fails (Chrome only). + if (!panda) { + const profileDir = process.env.OPERA_CLI_USER_DATA_DIR; + if (!profileDir) { checks.push({ name: "profile", status: "ok", - detail: `in use by ${live.browser}, attachable on port ${attachable}`, + detail: "isolated (no persistent profile configured)", }); - } else { + } else if (!existsSync(profileDir)) { checks.push({ name: "profile", - status: "warn", - detail: `in use${lock.pid ? ` by pid ${lock.pid}` : ""} with no debugging port — a separate profile will be used`, + status: "ok", + detail: `${profileDir} (will be created on first launch)`, }); + } else { + const lock = inspectProfileLock(profileDir); + const attachable = readDevToolsPort(profileDir); + const live = attachable !== null ? await probeDevToolsEndpoint(attachable) : null; + if (lock.state === "free") { + checks.push({ name: "profile", status: "ok", detail: `${profileDir} (free)` }); + } else if (live) { + checks.push({ + name: "profile", + status: "ok", + detail: `in use by ${live.browser}, attachable on port ${attachable}`, + }); + } else { + checks.push({ + name: "profile", + status: "warn", + detail: `in use${lock.pid ? ` by pid ${lock.pid}` : ""} with no debugging port — a separate profile will be used`, + }); + } } } @@ -2708,7 +2825,14 @@ async function handleDoctor(args: string[]): Promise { } if (checks.some((c) => c.name === "mcp" && c.status !== "ok")) { help.push( - "Install the MCP server: `npm install -g opera-devtools-mcp`, or set OPERA_CLI_MCP_BIN", + isPandaBackend() + ? "Build the package so dist/bin/panda-mcp-adapter.js exists (`npm run build`), or set OPERA_CLI_MCP_BIN" + : "Install the MCP server: `npm install -g opera-devtools-mcp`, or set OPERA_CLI_MCP_BIN", + ); + } + if (checks.some((c) => c.name === "lightpanda" && c.status === "fail")) { + help.push( + "Install lightpanda (https://github.com/lightpanda-io/browser) or set OPERA_CLI_LIGHTPANDA_BIN to its binary path", ); } if (checks.some((c) => c.name === "profile" && c.status === "warn")) { @@ -3966,6 +4090,8 @@ async function resolveBrowserConflict( const launched = await launchAttachableBrowser( process.env.OPERA_CLI_EXECUTABLE_PATH, target.userDataDir, + [], + !shouldRunHeaded(), ); if (!launched.ok || !launched.url) { throw new CdpError( diff --git a/src/client.ts b/src/client.ts index e798d11..7203000 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1075,6 +1075,12 @@ export function mapErrorMessage(message: string): CdpError { ], ); } + if (message.includes("UNSUPPORTED_ON_PANDA")) { + return new CdpError(message.replace(/^UNSUPPORTED_ON_PANDA:\s*/, ""), "UNSUPPORTED_OPERATION", [ + "This command requires Chrome/Opera — switch backends with OPERA_CLI_BROWSER_BACKEND=chrome", + "Run `opera-browser-cli doctor` to inspect the current backend", + ]); + } if (message.includes("ECONNREFUSED") || message.includes("ECONNRESET")) { return new CdpError("Bridge is not running", "BRIDGE_NOT_READY", [ "Run `opera-browser-cli open ` — the bridge starts automatically", diff --git a/src/config.ts b/src/config.ts index a624b0a..658c5da 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,6 +31,8 @@ export const KNOWN_CONFIG_KEYS = [ "OPERA_CLI_ENABLE_HOOKS", "OPERA_CLI_TAKEOVER", "OPERA_CLI_DEV", + "OPERA_CLI_BROWSER_BACKEND", + "OPERA_CLI_LIGHTPANDA_BIN", ] as const; /** Levenshtein distance, capped — only used to suggest a corrected key. */ diff --git a/src/detect.ts b/src/detect.ts index c85077a..ea39b92 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -8,6 +8,43 @@ */ import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +/** Look a bare command up on PATH, returning its resolved path (like a shell). */ +export function whichOnPath( + command: string, + env: NodeJS.ProcessEnv = process.env, + exists: (p: string) => boolean = existsSync, +): string | null { + const pathVar = env.PATH ?? ""; + const separator = process.platform === "win32" ? ";" : ":"; + const extensions = + process.platform === "win32" + ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") + : [""]; + for (const entry of pathVar.split(separator)) { + if (!entry) continue; + for (const ext of extensions) { + const candidate = resolve(entry, command + ext); + if (exists(candidate)) return candidate; + } + } + return null; +} + +/** + * Locate the Lightpanda ("panda") browser binary, which powers the alternative + * backend behind the adapter shim. `OPERA_CLI_LIGHTPANDA_BIN` wins; otherwise it + * is found on PATH as `lightpanda`. + */ +export function detectLightpanda( + env: NodeJS.ProcessEnv = process.env, + exists: (p: string) => boolean = existsSync, +): string | null { + const fromEnv = env.OPERA_CLI_LIGHTPANDA_BIN; + if (fromEnv && exists(fromEnv)) return fromEnv; + return whichOnPath("lightpanda", env, exists); +} export function neonCandidatePaths( platform: NodeJS.Platform = process.platform, diff --git a/src/identity.ts b/src/identity.ts index dd58ec0..a246d94 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -45,7 +45,7 @@ export interface BridgeHealth { */ export function computeBootMinute( nowMs: number = Date.now(), - uptimeSeconds: number = uptime(), + uptimeSeconds: number = (() => { try { return uptime(); } catch { return 0; } })(), ): number { return Math.floor((nowMs - uptimeSeconds * 1000) / 60_000); } diff --git a/src/panda-mcp-adapter.ts b/src/panda-mcp-adapter.ts new file mode 100644 index 0000000..b6ea844 --- /dev/null +++ b/src/panda-mcp-adapter.ts @@ -0,0 +1,618 @@ +/** + * Panda MCP adapter shim. + * + * The opera-browser-cli bridge talks to opera-devtools-mcp over stdio. When + * OPERA_CLI_BROWSER_BACKEND=panda the bridge instead talks to this process. + * It exposes the same tool *names* the CLI already calls, but translates each + * call into Lightpanda ("panda") MCP tools. + * + * The load-bearing contract is the CLI's `@X.Y` ref system: `take_snapshot` + * must return text the CLI's `compactSnapshot()` can process, and every + * interactive tool must accept the `uid` refs that snapshot produced. Panda + * addresses nodes by `backendNodeId`, so this shim keeps a + * `backendNodeId -> @X.Y` map between snapshots. + * + * backendNodeId values are stable across non-navigating mutations (panda's + * CDPNode.Registry is monotonic and only resets on navigation), so the map is + * built lazily from one `tree` call and reused until a navigation invalidates + * it. A stale-ref `NodeNotFound` triggers one rebuild + retry, matching the + * CLI's exit-code-6 "re-snapshot" behaviour. + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolResult, + type Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { detectLightpanda } from "./detect.js"; + +const SHIM_VERSION = "1.0.0"; + +/** Distinctive marker the CLI maps to exit code 2 (unsupported on panda). */ +const UNSUPPORTED_SENTINEL = "UNSUPPORTED_ON_PANDA"; + +/** Safety net for a single panda tool round-trip (lightpanda is Beta). */ +const PANDA_CALL_TIMEOUT_MS = 60_000; + +// ---------------------------------------------------------------- types + +interface PandaNode { + backendNodeId: number; + depth: number; + /** Empty string for text-only nodes (panda omits StaticText/none/generic). */ + role: string; + name: string | null; + value: string | null; + interactive: boolean; + disabled: boolean; + checked: boolean | null; +} + +class PandaError extends Error { + constructor(message: string, readonly cause?: unknown) { + super(message); + this.name = "PandaError"; + } +} + +// ---------------------------------------------------------------- tree parser + +const isFrameNotLoaded = (message: string): boolean => /FrameNotLoaded/i.test(message); +const isNodeNotFound = (message: string): boolean => /NodeNotFound/i.test(message); + +function errorMessageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Collapse whitespace and drop quote characters that would break the `key="value"` format. */ +function sanitize(value: string): string { + return value.replace(/[\r\n\t]+/g, " ").replace(/["']/g, "").replace(/\s+/g, " ").trim(); +} + +/** + * Parse one line of panda's `tree` text output. + * + * Grammar (order is fixed, from browser/src/SemanticTree.zig TextVisitor): + * ( [i](:disabled)?)?( role)?( 'name')?( value='v')?( [checked|unchecked])?( options=[...])? + */ +export function parseTreeLine(line: string): { depth: number; node: PandaNode } { + const trimmed = line.replace(/\r$/, ""); + const indent = trimmed.match(/^ */)?.[0].length ?? 0; + const depth = indent; + + let rest = trimmed.slice(indent); + + // 1. backendNodeId + const idMatch = rest.match(/^(\d+)/); + const backendNodeId = idMatch ? Number.parseInt(idMatch[1], 10) : 0; + rest = rest.slice(idMatch?.[0].length ?? 0); + + // 2. Interactive flag (" [i]" or " [i:disabled]") + let interactive = false; + let disabled = false; + const iMatch = rest.match(/^ \[i(?::disabled)?\]/); + if (iMatch) { + interactive = true; + disabled = iMatch[0] === " [i:disabled]"; + rest = rest.slice(iMatch[0].length); + } + + // 3. Role — omitted for text-only nodes (which print `id 'text'` directly). + let role = ""; + if (!/^\s*'/.test(rest)) { + const roleMatch = rest.match(/^\s*([A-Za-z][A-Za-z0-9]*)/); + if (roleMatch) { + role = roleMatch[1]; + rest = rest.slice(roleMatch[0].length); + } + } + + // 4. Accessible name / text value (single-quoted) + const nameMatch = rest.match(/^\s*'([^']*)'/); + let name: string | null = null; + if (nameMatch) { + name = nameMatch[1]; + rest = rest.slice(nameMatch[0].length); + } + if (name != null && name.length === 0) name = null; + + // 5. Input value + const valueMatch = rest.match(/^\s* value='([^']*)'/); + const value = valueMatch?.[1] ?? null; + + // 6. Checked state + const checkedMatch = rest.match(/ \[(checked|unchecked)\]/); + const checked = checkedMatch ? checkedMatch[1] === "checked" : null; + + return { + depth, + node: { backendNodeId, depth, role, name, value, interactive, disabled, checked }, + }; +} + +/** Parse a full `tree` response into nodes in document order. */ +export function parsePandaTree(text: string): PandaNode[] { + const nodes: PandaNode[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + nodes.push(parseTreeLine(line).node); + } + return nodes; +} + +/** Map a panda semantic role to the Chrome a11y role name the CLI expects. */ +export function mapRole(role: string): string { + if (role === "" || role === "none" || role === "generic" || role === "StaticText") { + return "StaticText"; + } + if (role === "RootWebArea") return "RootWebArea"; + // Panda roles already use Chrome's lowercase a11y names (link, button, …). + return role; +} + +export interface SerializeResult { + text: string; + /** uid ("1_4") -> backendNodeId, for action resolution. */ + uidToBackend: Map; + /** backendNodeId -> uid, so stable nodes keep their refs across refreshes. */ + backendToUid: Map; + nextId: number; +} + +/** + * Serialize parsed panda nodes into the `uid=X_Y RoleName "name" attr="value"` + * text the CLI's compactSnapshot pipeline consumes. Reuses existing uids for + * backendNodeIds already seen; assigns fresh uids to new ones. + */ +export function serializeNodes( + nodes: PandaNode[], + pageUrl: string | null, + backendToUid: ReadonlyMap, + nextId: number, + pageNum: number, +): SerializeResult { + const newBackendToUid = new Map(backendToUid); + const uidToBackend = new Map(); + let id = nextId; + + const lines: string[] = []; + for (const node of nodes) { + let uid = newBackendToUid.get(node.backendNodeId); + if (uid === undefined) { + uid = `${pageNum}_${id++}`; + newBackendToUid.set(node.backendNodeId, uid); + } + uidToBackend.set(uid, node.backendNodeId); + + const role = mapRole(node.role); + const parts = [`uid=${uid}`, role]; + if (node.name != null) parts.push(`"${sanitize(node.name)}"`); + if (node.value != null) parts.push(`value="${sanitize(node.value)}"`); + if (node.checked === true) parts.push("checked"); + if (node.disabled) parts.push("disabled"); + if (node.interactive) parts.push("focusable"); + if (node.depth === 0 && pageUrl != null) parts.push(`url="${pageUrl}"`); + + lines.push(`${" ".repeat(node.depth)}${parts.join(" ")}`); + } + + return { + text: lines.join("\n"), + uidToBackend, + backendToUid: newBackendToUid, + nextId: id, + }; +} + +/** + * Turn the CLI's `evaluate_script {function}` value into a panda `evaluate` + * script. The CLI wraps plain expressions as `() => (EXPR)`; Panda stringifies + * that form instead of invoking it, so unwrap it. Real function literals are + * invoked and their result returned. + */ +export function toPandaScript(fn: string): string { + const t = fn.trim(); + const wrapped = t.match(/^\(\)\s*=>\s*\(([\s\S]*)\)$/); + if (wrapped) return `return (${wrapped[1]})`; + const isFn = /^(?:async\s+)?(?:function(?:\s*\*)?(?:\s+[\w$]+)?\s*\(|\([\s\S]*?\)\s*=>|[\w$]+\s*=>)/.test(t); + if (isFn) return `return (${t})()`; + return `return (${t})`; +} + +// ---------------------------------------------------------------- shim state + +let backendToUid = new Map(); +let uidToBackend = new Map(); +let nextElemId = 1; +/** Monotonic page number — bumped on navigation so stale `@P.E` refs never collide. */ +let pageSeq = 0; +let cachedSnapshot: string | null = null; +let currentUrl: string | null = null; + +let pandaClient: Client | null = null; + +function invalidateRefs(): void { + pageSeq += 1; + backendToUid.clear(); + uidToBackend.clear(); + nextElemId = 1; + cachedSnapshot = null; +} + +/** A non-navigating action only invalidates the cached snapshot content. */ +function markStale(): void { + cachedSnapshot = null; +} + +function textResult(text: string, isError = false): CallToolResult { + return { content: [{ type: "text", text }], isError }; +} + +function extractText(result: CallToolResult): string { + return (result.content ?? []) + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join(""); +} + +async function callPanda(name: string, args: Record): Promise { + if (!pandaClient) { + throw new PandaError( + "Lightpanda is not reachable. Set OPERA_CLI_LIGHTPANDA_BIN, or install `lightpanda` on PATH.", + ); + } + let result: CallToolResult; + try { + result = (await pandaClient.callTool( + { name, arguments: args }, + undefined, + { timeout: PANDA_CALL_TIMEOUT_MS }, + )) as CallToolResult; + } catch (error) { + throw new PandaError(errorMessageOf(error)); + } + if (result.isError) { + throw new PandaError(extractText(result) || `${name} failed`); + } + return extractText(result); +} + +async function currentPageUrl(): Promise { + try { + return await callPanda("getUrl", {}); + } catch (error) { + if (isFrameNotLoaded(errorMessageOf(error))) return null; + throw error; + } +} + +/** Fetch a fresh tree, rebuild uids for new nodes, and cache the snapshot. */ +async function refreshSnapshot(): Promise { + let tree: string; + try { + tree = await callPanda("tree", {}); + } catch (error) { + if (isFrameNotLoaded(errorMessageOf(error))) { + currentUrl = null; + cachedSnapshot = null; + return "No page selected"; + } + throw error; + } + let url: string | null = null; + try { + url = await currentPageUrl(); + } catch { + url = null; + } + + // A changing URL means a navigation (link click, form submit, JS redirect) + // that no explicit trigger observed. Swap to a fresh page number so stale + // `@P.E` refs from the previous page can never resolve to the new page. + if (url !== null && currentUrl !== null && url !== currentUrl) { + invalidateRefs(); + } + currentUrl = url; + + const nodes = parsePandaTree(tree); + const serialized = serializeNodes(nodes, currentUrl, backendToUid, nextElemId, pageSeq); + backendToUid = serialized.backendToUid; + uidToBackend = serialized.uidToBackend; + nextElemId = serialized.nextId; + cachedSnapshot = serialized.text; + return serialized.text; +} + +async function takeSnapshot(): Promise { + if (cachedSnapshot != null) return cachedSnapshot; + return refreshSnapshot(); +} + +/** Resolve a `uid` ("1_4") to a backendNodeId, refreshing the snapshot if needed. */ +async function resolveUid(uid: string): Promise { + let id = uidToBackend.get(uid); + if (id === undefined) { + await refreshSnapshot(); + id = uidToBackend.get(uid); + } + if (id === undefined) { + throw new PandaError( + `Element ${uid} not found in the snapshot — run \`opera-browser-cli snapshot\` and retry with a fresh ref`, + ); + } + return id; +} + +/** + * Run a panda action addressed by the CLI's uid. A `NodeNotFound` means the + * element moved under us (navigation or DOM churn): refresh the snapshot and + * surface a stale-ref error that the CLI maps to exit 6, exactly like Chrome's + * stale-element-ref contract. + */ +async function runRefAction( + pandaName: "click" | "fill" | "hover", + uid: string, + extraArgs: Record = {}, +): Promise { + try { + return await callPanda(pandaName, { backendNodeId: await resolveUid(uid), ...extraArgs }); + } catch (error) { + if (!isNodeNotFound(errorMessageOf(error))) throw error; + await refreshSnapshot(); + throw new PandaError( + `Element ${uid} not found (stale ref) — the page changed. Run \`opera-browser-cli snapshot\` and retry with a fresh ref.`, + ); + } +} + +function unsupported(name: string): CallToolResult { + return textResult( + `${UNSUPPORTED_SENTINEL}: \`${name}\` requires Chrome/Opera and is not available on the Lightpanda backend. Switch backends with OPERA_CLI_BROWSER_BACKEND=chrome.`, + true, + ); +} + +// ---------------------------------------------------------------- tool registry + +const SUPPORTED_TOOLS: Array> = [ + { name: "navigate_page", description: "Navigate the current page to a URL, or go back." }, + { name: "take_snapshot", description: "Take a text snapshot of the current page." }, + { name: "page_markdown", description: "Read the current page as compact markdown (full text content)." }, + { name: "click", description: "Click an element by uid (e.g. @1.4)." }, + { name: "fill", description: "Fill a text field by uid." }, + { name: "hover", description: "Hover over an element by uid." }, + { name: "press_key", description: "Press a keyboard key." }, + { name: "type_text", description: "Type text into the focused element." }, + { name: "evaluate_script", description: "Run JavaScript in the page." }, + { name: "wait_for", description: "Wait for text to appear on the page." }, + { name: "list_console_messages", description: "List buffered console messages." }, + { name: "list_pages", description: "List open pages." }, + { name: "new_page", description: "Open a URL in a new page." }, + { name: "select_page", description: "Select a page by id." }, + { name: "close_page", description: "Close a page by id." }, +]; + +const UNSUPPORTED_TOOLS: Array> = [ + "take_screenshot", + "drag", + "upload_file", + "fill_form", + "handle_dialog", + "resize_page", + "emulate", + "get_console_message", + "list_network_requests", + "get_network_request", + "lighthouse_audit", + "performance_start_trace", + "performance_stop_trace", + "performance_analyze_insight", + "take_memory_snapshot", + "opera_chat", + "opera_do", + "opera_make", + "opera_research", + "opera_list_models", + "opera_list_mcp_servers", + "opera_list_mcp_tools", + "opera_call_mcp_tool", + "opera_register_mcp_server", + "opera_connect_mcp_server", + "opera_authenticate_mcp_server", + "opera_enable_mcp_server", + "opera_disable_mcp_server", + "opera_unregister_mcp_server", +].map((name) => ({ + name, + description: "Not supported on the Lightpanda backend.", +})); + +const PERMISSIVE_SCHEMA = { type: "object" as const, properties: {} }; + +// ---------------------------------------------------------------- handlers + +async function handleCall( + name: string, + args: Record, +): Promise { + switch (name) { + case "navigate_page": { + if (args.type === "back") { + invalidateRefs(); + await callPanda("evaluate", { script: "history.back()" }); + currentUrl = null; + return textResult("Navigated back"); + } + const url = String(args.url ?? ""); + if (!url) return textResult("Missing URL", true); + invalidateRefs(); + await callPanda("goto", { url }); + currentUrl = url; + return textResult("Navigated successfully."); + } + case "new_page": { + const url = String(args.url ?? ""); + if (!url) return textResult("Missing URL", true); + invalidateRefs(); + await callPanda("goto", { url }); + currentUrl = url; + return textResult("Navigated successfully."); + } + case "select_page": { + // Lightpanda over stdio has a single session, so there is exactly one page. + markStale(); + return textResult(`Selected page ${String(args.pageId ?? 0)}`); + } + case "close_page": { + return textResult("Only one page exists on the Lightpanda backend", true); + } + case "take_snapshot": + return textResult(await takeSnapshot()); + case "click": { + const resultText = await runRefAction("click", String(args.uid ?? "")); + // Panda's click reply includes the post-click URL — use it to detect a + // navigation eagerly so a stale ref clicked right after never resolves + // to an unrelated node on the new page. + const m = resultText.match(/Page url:\s*([^\s,]+)/); + if (m && currentUrl !== null && m[1] !== currentUrl) { + invalidateRefs(); + currentUrl = m[1]; + } + markStale(); + return textResult("Clicked element"); + } + case "fill": { + await runRefAction("fill", String(args.uid ?? ""), { value: String(args.value ?? "") }); + markStale(); + return textResult("Filled input"); + } + case "hover": { + await runRefAction("hover", String(args.uid ?? "")); + markStale(); + return textResult("Hovered element"); + } + case "press_key": { + await callPanda("press", { key: String(args.key ?? "") }); + markStale(); + return textResult("Pressed key"); + } + case "type_text": { + const text = String(args.text ?? ""); + const script = [ + "(() => {", + " const el = document.activeElement;", + " if (!el || typeof el.value !== 'string') return null;", + ` const t = ${JSON.stringify(text)};`, + " el.value = el.value + t;", + " el.dispatchEvent(new InputEvent('input', { bubbles: true, data: t, inputType: 'insertText' }));", + " el.dispatchEvent(new Event('change', { bubbles: true }));", + " return el.value;", + "})()", + ].join("\n"); + await callPanda("evaluate", { script }); + markStale(); + return textResult("Typed text"); + } + case "evaluate_script": { + const script = toPandaScript(String(args.function ?? "")); + const out = await callPanda("evaluate", { script }); + markStale(); + return textResult(out); + } + case "wait_for": { + const target = Array.isArray(args.text) ? String(args.text[0] ?? "") : String(args.text ?? ""); + const script = `document.body && document.body.innerText.includes(${JSON.stringify(target)})`; + await callPanda("waitForScript", { script }); + markStale(); + return textResult(`Waited for ${JSON.stringify(target)}`); + } + case "list_console_messages": { + return textResult(await callPanda("consoleLogs", {})); + } + case "list_pages": { + const url = (await currentPageUrl()) ?? "about:blank"; + return textResult(`0: ${url} [selected]`); + } + case "page_markdown": { + return textResult(await callPanda("markdown", {})); + } + default: + return unsupported(name); + } +} + +// ---------------------------------------------------------------- server + +async function resolveLightpandaBin(argv: string[]): Promise { + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--lightpanda-bin" && i + 1 < argv.length) return argv[i + 1]; + if (argv[i].startsWith("--lightpanda-bin=")) return argv[i].slice("--lightpanda-bin=".length); + } + return detectLightpanda(); +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const lightpandaBin = await resolveLightpandaBin(argv); + + // Connect to lightpanda up front. If it is missing we still serve tools/list + // so the CLI can introspect, and every call surfaces a clear error instead of + // a mysterious bridge crash. + if (lightpandaBin) { + const transport = new StdioClientTransport({ + command: lightpandaBin, + args: ["mcp"], + stderr: "inherit", + }); + const client = new Client({ name: "panda-mcp-adapter", version: SHIM_VERSION }); + try { + await client.connect(transport); + pandaClient = client; + } catch (error) { + process.stderr.write( + `[panda-mcp-adapter] Failed to connect to lightpanda (${lightpandaBin}): ${errorMessageOf(error)}\n`, + ); + } + } else { + process.stderr.write( + "[panda-mcp-adapter] lightpanda not found — set OPERA_CLI_LIGHTPANDA_BIN or install `lightpanda`.\n", + ); + } + + const server = new Server( + { name: "panda-mcp-adapter", version: SHIM_VERSION }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [...SUPPORTED_TOOLS, ...UNSUPPORTED_TOOLS].map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: PERMISSIVE_SCHEMA, + })), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + return await handleCall(name, (args ?? {}) as Record); + } catch (error) { + return textResult(errorMessageOf(error), true); + } + }); + + const shutdown = async () => { + await server.close(); + await pandaClient?.close(); + process.exit(0); + }; + process.on("SIGTERM", () => void shutdown()); + process.on("SIGINT", () => void shutdown()); + + const stdio = new StdioServerTransport(); + await server.connect(stdio); +} \ No newline at end of file diff --git a/src/snapshot.ts b/src/snapshot.ts index fcd96db..ddf1fc2 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -335,6 +335,20 @@ function collapseTextRuns(lines: string[]): string[] { return result; } +/** + * Compact Lightpanda markdown for the panda snapshot's `text:` trailer: + * trim per-line whitespace and collapse blank-line runs to a single separator, + * preserving heading/list structure while dropping emitted padding. + */ +export function compactMarkdown(markdown: string): string { + return markdown + .split("\n") + .map((line) => line.trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + export interface TruncationResult { text: string; truncated: boolean; diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 3377329..e2daddd 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -142,11 +142,15 @@ describe("buildTransportArgs", () => { savedEnv.OPERA_CLI_BROWSER_URL = process.env.OPERA_CLI_BROWSER_URL; savedEnv.OPERA_CLI_USER_DATA_DIR = process.env.OPERA_CLI_USER_DATA_DIR; savedEnv.OPERA_CLI_EXECUTABLE_PATH = process.env.OPERA_CLI_EXECUTABLE_PATH; + savedEnv.OPERA_CLI_BROWSER_BACKEND = process.env.OPERA_CLI_BROWSER_BACKEND; + savedEnv.OPERA_CLI_LIGHTPANDA_BIN = process.env.OPERA_CLI_LIGHTPANDA_BIN; delete process.env.OPERA_CLI_HEADED; delete process.env.OPERA_CLI_CHROME_ARGS; delete process.env.OPERA_CLI_BROWSER_URL; delete process.env.OPERA_CLI_USER_DATA_DIR; delete process.env.OPERA_CLI_EXECUTABLE_PATH; + delete process.env.OPERA_CLI_BROWSER_BACKEND; + delete process.env.OPERA_CLI_LIGHTPANDA_BIN; }); afterEach(() => { @@ -155,6 +159,8 @@ describe("buildTransportArgs", () => { process.env.OPERA_CLI_BROWSER_URL = savedEnv.OPERA_CLI_BROWSER_URL; process.env.OPERA_CLI_USER_DATA_DIR = savedEnv.OPERA_CLI_USER_DATA_DIR; process.env.OPERA_CLI_EXECUTABLE_PATH = savedEnv.OPERA_CLI_EXECUTABLE_PATH; + process.env.OPERA_CLI_BROWSER_BACKEND = savedEnv.OPERA_CLI_BROWSER_BACKEND; + process.env.OPERA_CLI_LIGHTPANDA_BIN = savedEnv.OPERA_CLI_LIGHTPANDA_BIN; }); it("defaults to headless and isolated", () => { @@ -261,6 +267,24 @@ describe("buildTransportArgs", () => { expect(args).toContain("--browserUrl=http://127.0.0.1:9222"); expect(args).not.toContain("--executablePath=/Applications/Opera Neon.app/Contents/MacOS/Opera"); }); + + it("returns --lightpanda-bin= when backend=panda", () => { + process.env.OPERA_CLI_BROWSER_BACKEND = "panda"; + process.env.OPERA_CLI_LIGHTPANDA_BIN = process.execPath; + expect(buildTransportArgs()).toEqual([`--lightpanda-bin=${process.execPath}`]); + }); + + it("ignores Chrome flags when backend=panda", () => { + process.env.OPERA_CLI_BROWSER_BACKEND = "panda"; + process.env.OPERA_CLI_LIGHTPANDA_BIN = process.execPath; + process.env.OPERA_CLI_CHROME_ARGS = "--enable-gpu"; + process.env.OPERA_CLI_HEADED = "1"; + const args = buildTransportArgs(); + expect(args).toEqual([`--lightpanda-bin=${process.execPath}`]); + expect(args).not.toContain("--headless"); + expect(args).not.toContain("--isolated"); + expect(args).not.toContain("--chrome-arg=--enable-gpu"); + }); }); describe("bridge health", () => { diff --git a/test/detect.test.ts b/test/detect.test.ts new file mode 100644 index 0000000..9a8223c --- /dev/null +++ b/test/detect.test.ts @@ -0,0 +1,62 @@ +/** + * Lightpanda binary detection — powers OPERA_CLI_BROWSER_BACKEND=panda. + */ + +import { describe, expect, it } from "vitest"; +import { detectLightpanda, whichOnPath } from "../src/detect.js"; + +const existsOnly = (...paths: string[]) => (p: string) => paths.includes(p); + +describe("whichOnPath", () => { + it("finds a command in a PATH entry", () => { + const found = whichOnPath( + "lightpanda", + { PATH: "/usr/bin:/opt/bin" }, + existsOnly("/opt/bin/lightpanda"), + ); + expect(found).toBe("/opt/bin/lightpanda"); + }); + + it("returns null when absent from every PATH entry", () => { + expect(whichOnPath("lightpanda", { PATH: "/usr/bin" }, () => false)).toBeNull(); + }); + + it("scans entries in order", () => { + const found = whichOnPath( + "lightpanda", + { PATH: "/a:/b" }, + existsOnly("/b/lightpanda"), + ); + expect(found).toBe("/b/lightpanda"); + }); +}); + +describe("detectLightpanda", () => { + it("prefers OPERA_CLI_LIGHTPANDA_BIN when it exists", () => { + const found = detectLightpanda( + { OPERA_CLI_LIGHTPANDA_BIN: "/custom/lightpanda" }, + existsOnly("/custom/lightpanda"), + ); + expect(found).toBe("/custom/lightpanda"); + }); + + it("ignores OPERA_CLI_LIGHTPANDA_BIN when the file is missing", () => { + const found = detectLightpanda( + { OPERA_CLI_LIGHTPANDA_BIN: "/gone/lightpanda", PATH: "/usr/bin" }, + existsOnly("/usr/bin/lightpanda"), + ); + expect(found).toBe("/usr/bin/lightpanda"); + }); + + it("falls back to PATH", () => { + const found = detectLightpanda( + { PATH: "/usr/bin" }, + existsOnly("/usr/bin/lightpanda"), + ); + expect(found).toBe("/usr/bin/lightpanda"); + }); + + it("returns null when not found anywhere", () => { + expect(detectLightpanda({ PATH: "/usr/bin" }, () => false)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/test/panda-adapter.test.ts b/test/panda-adapter.test.ts new file mode 100644 index 0000000..5bf35ef --- /dev/null +++ b/test/panda-adapter.test.ts @@ -0,0 +1,172 @@ +/** + * Unit tests for the panda adapter shim's pure logic: the semantic-tree parser + * and the a11y-format serializer that back the whole `@X.Y` ref contract. + */ + +import { describe, expect, it } from "vitest"; +import { + mapRole, + parsePandaTree, + parseTreeLine, + serializeNodes, + toPandaScript, +} from "../src/panda-mcp-adapter.js"; + +const SAMPLE_TREE = [ + "1 RootWebArea 'Test Page'", + " 4 heading 'Welcome'", + " 5 paragraph", + " 6 'Some intro text.'", + " 7 [i] link 'About us'", + " 8 form", + " 9 [i] textbox 'Search'", + " 10 [i] checkbox value='on' [checked]", + " 11 [i] combobox value='a' options=['a','b']", + " 12 [i:disabled] button 'Go'", +].join("\n"); + +describe("parseTreeLine", () => { + it("parses the root with a role and name", () => { + const { depth, node } = parseTreeLine("1 RootWebArea 'Test Page'"); + expect(depth).toBe(0); + expect(node).toMatchObject({ + backendNodeId: 1, + role: "RootWebArea", + name: "Test Page", + value: null, + interactive: false, + checked: null, + }); + }); + + it("uses one space per depth level", () => { + expect(parseTreeLine(" 4 heading 'Welcome'").depth).toBe(1); + expect(parseTreeLine(" 6 'text'").depth).toBe(2); + }); + + it("parses a text-only node (no role) as a bare quoted string", () => { + const { node } = parseTreeLine(" 6 'Some intro text.'"); + expect(node.role).toBe(""); + expect(node.name).toBe("Some intro text."); + }); + + it("parses an interactive node with flag", () => { + const { node } = parseTreeLine(" 7 [i] link 'About us'"); + expect(node.role).toBe("link"); + expect(node.name).toBe("About us"); + expect(node.interactive).toBe(true); + expect(node.disabled).toBe(false); + }); + + it("parses a disabled interactive node", () => { + const { node } = parseTreeLine(" 12 [i:disabled] button 'Go'"); + expect(node.interactive).toBe(true); + expect(node.disabled).toBe(true); + }); + + it("parses a checkbox with value and checked state", () => { + const { node } = parseTreeLine(" 10 [i] checkbox value='on' [checked]"); + expect(node.role).toBe("checkbox"); + expect(node.value).toBe("on"); + expect(node.checked).toBe(true); + }); + + it("parses a combobox with value and ignores its options list", () => { + const { node } = parseTreeLine(" 11 [i] combobox value='a' options=['a','b']"); + expect(node.role).toBe("combobox"); + expect(node.value).toBe("a"); + expect(node.checked).toBeNull(); + }); + + it("parses an unchecked state", () => { + const { node } = parseTreeLine(" 1 [i] checkbox [unchecked]"); + expect(node.checked).toBe(false); + }); +}); + +describe("parsePandaTree", () => { + it("parses a full tree in document order", () => { + const nodes = parsePandaTree(SAMPLE_TREE); + expect(nodes.map((n) => n.backendNodeId)).toEqual([1, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + expect(nodes[0].depth).toBe(0); + expect(nodes[1].depth).toBe(1); + expect(nodes[3].depth).toBe(2); + }); +}); + +describe("mapRole", () => { + it("maps text-only roles to StaticText", () => { + for (const role of ["", "none", "generic", "StaticText"]) { + expect(mapRole(role)).toBe("StaticText"); + } + }); + + it("passes through Chrome-style lowercase roles unchanged", () => { + expect(mapRole("link")).toBe("link"); + expect(mapRole("textbox")).toBe("textbox"); + expect(mapRole("heading")).toBe("heading"); + }); + + it("keeps the CamelCase root role", () => { + expect(mapRole("RootWebArea")).toBe("RootWebArea"); + }); +}); + +describe("serializeNodes", () => { + it("assigns uids with page and element numbers, url only on the root", () => { + const { text } = serializeNodes(parsePandaTree(SAMPLE_TREE), "https://example.com/", new Map(), 1, 1); + const lines = text.split("\n"); + expect(lines[0]).toBe('uid=1_1 RootWebArea "Test Page" url="https://example.com/"'); + expect(lines[1]).toMatch(/^\s\suid=1_2 heading "Welcome"$/); + // Only the root line carries url=. + expect(lines.filter((l) => l.includes("url="))).toHaveLength(1); + }); + + it("emits value, checked, and focusable attributes", () => { + const nodes = parsePandaTree(SAMPLE_TREE); + const { text } = serializeNodes(nodes, null, new Map(), 1, 1); + expect(text).toContain('value="on" checked focusable'); + expect(text).toContain('textbox "Search" focusable'); + }); + + it("indents two spaces per depth", () => { + const nodes = parsePandaTree(SAMPLE_TREE); + const { text } = serializeNodes(nodes, null, new Map(), 1, 1); + const textNode = text.split("\n").find((l) => l.includes("Some intro text")); + expect(textNode).toBe(' uid=1_4 StaticText "Some intro text."'); + }); + + it("reuses uids for stable backendNodeIds across refreshes", () => { + const nodes = parsePandaTree(SAMPLE_TREE); + const first = serializeNodes(nodes, null, new Map(), 1, 1); + // Refresh with the same nodes: every backendNodeId must keep its uid. + const second = serializeNodes(nodes, null, first.backendToUid, first.nextId, 1); + expect(second.uidToBackend).toEqual(first.uidToBackend); + expect(second.text).toBe(first.text); + }); + + it("returns the uid -> backendNodeId map for action resolution", () => { + const nodes = parsePandaTree(SAMPLE_TREE); + const { uidToBackend } = serializeNodes(nodes, null, new Map(), 1, 2); + expect(uidToBackend.get("2_2")).toBe(4); // heading + expect(uidToBackend.get("2_5")).toBe(7); // link + }); +}); + +describe("toPandaScript", () => { + it("unwraps the CLI's `() => (EXPR)` expression wrapper", () => { + expect(toPandaScript("() => (document.title)")).toBe("return (document.title)"); + }); + + it("invokes real function literals", () => { + expect(toPandaScript("function(){ return 42 }")).toBe("return (function(){ return 42 })()"); + expect(toPandaScript("(x) => x * 2")).toBe("return ((x) => x * 2)()"); + }); + + it("runs bare calls and expressions as-is", () => { + expect(toPandaScript("window.scrollBy(0, 500)")).toBe("return (window.scrollBy(0, 500))"); + expect(toPandaScript("new Promise(r => setTimeout(r, 300))")).toBe( + "return (new Promise(r => setTimeout(r, 300)))", + ); + }); +}); \ No newline at end of file