import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { BrowserTool } from "@oh-my-pi/pi-coding-agent/tools/browser"; import { findFreeCdpPort, pickElectronTarget, probeCdpStatus, shouldPreserveConnectedBrowserFocus, } from "@oh-my-pi/pi-coding-agent/tools/browser/attach"; import { acquireBrowser, type BrowserHandle, normalizeConnectedCdpUrl, releaseBrowser, } from "@oh-my-pi/pi-coding-agent/tools/browser/registry"; import { acquireTab } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor"; import type { Browser, Page, Target } from "puppeteer-core"; import { chromiumAvailable } from "./chromium-probe"; const CHROMIUM_AVAILABLE = await chromiumAvailable(); let sharedHeadless: BrowserHandle | undefined; function makeSession(): ToolSession { return { cwd: process.cwd(), hasUI: false, getSessionFile: () => null, getSessionSpawns: () => "*", settings: Settings.isolated({ "browser.headless": true }), }; } interface FakePageOptions { url: string; title: string; visible?: boolean; } function fakePage(options: FakePageOptions): Page { return { url: () => options.url, title: async () => options.title, evaluate: async () => options.visible === true, } as unknown as Page; } function fakeTarget(type: string, page: Page | null): Target { return { type: () => type, page: async () => page, } as unknown as Target; } describe("pickElectronTarget", () => { beforeAll(async () => { if (!CHROMIUM_AVAILABLE) return; sharedHeadless = await acquireBrowser({ kind: "headless", headless: true }, { cwd: process.cwd() }); }); afterAll(async () => { if (sharedHeadless) await releaseBrowser(sharedHeadless, { kill: true }); }); test("uses discovered CDP page targets when browser.pages is empty", async () => { const page = fakePage({ url: "https://www.google.com/", title: "Google" }); let pagesCalled = false; const browser = { targets: () => [fakeTarget("browser", null), fakeTarget("page", page)], pages: async () => { pagesCalled = true; return []; }, } as unknown as Browser; await expect(pickElectronTarget(browser, { matcher: "google" })).resolves.toBe(page); expect(pagesCalled).toBe(false); }); test("falls back to browser.pages when discovered targets have no usable page", async () => { const page = fakePage({ url: "https://example.com/", title: "Example" }); const browser = { targets: () => [fakeTarget("browser", null), fakeTarget("service_worker", null)], pages: async () => [page], } as unknown as Browser; await expect(pickElectronTarget(browser)).resolves.toBe(page); }); test("reports available pages when the matcher misses", async () => { const page = fakePage({ url: "https://example.com/", title: "Example" }); const browser = { targets: () => [fakeTarget("page", page)], pages: async () => [], } as unknown as Browser; await expect(pickElectronTarget(browser, { matcher: "missing" })).rejects.toThrow( 'No page target matched "missing". Available pages:\n- Example https://example.com/', ); }); test("prefers the foreground tab when asked to, without disturbing default order", async () => { const background = fakePage({ url: "https://example.com/", title: "Example" }); const foreground = fakePage({ url: "https://example.org/", title: "Example Org", visible: true }); const browser = { targets: () => [fakeTarget("page", background), fakeTarget("page", foreground)], pages: async () => [], } as unknown as Browser; await expect(pickElectronTarget(browser, { preferVisible: true })).resolves.toBe(foreground); await expect(pickElectronTarget(browser)).resolves.toBe(background); }); test("falls back to the first usable tab when no tab reports itself visible", async () => { const first = fakePage({ url: "https://example.com/", title: "Example" }); const second = fakePage({ url: "https://example.org/", title: "Example Org" }); const browser = { targets: () => [fakeTarget("page", first), fakeTarget("page", second)], pages: async () => [], } as unknown as Browser; await expect(pickElectronTarget(browser, { preferVisible: true })).resolves.toBe(first); }); test("preserves connected-browser focus only for automatic target selection", () => { expect(shouldPreserveConnectedBrowserFocus()).toBe(true); expect(shouldPreserveConnectedBrowserFocus("example.com")).toBe(false); }); test("rejects websocket cdp_url values with an actionable diagnostic", () => { expect(() => normalizeConnectedCdpUrl("ws://127.0.0.1:9222/devtools/browser/id")).toThrow( "browser app.cdp_url must be the HTTP CDP discovery endpoint", ); expect(normalizeConnectedCdpUrl("http://127.0.0.1:9222/")).toBe("http://127.0.0.1:9222"); }); // Launches real headless Chromium; skipped where Chrome's system libraries are absent. test.skipIf(!CHROMIUM_AVAILABLE)( "navigates a fresh attached tab and releases its handle without closing the target", async () => { const launched = sharedHeadless; if (!launched || !("browser" in launched)) throw new Error("Expected a shared Puppeteer browser"); const endpoint = new URL(launched.browser.wsEndpoint()); const tool = new BrowserTool(makeSession()); let opened = false; const tabName = `attach-navigation-${process.pid}-${Math.random().toString(36).slice(2)}`; const requested = "data:text/html,attached-navigation-target"; const targetPage = (await launched.browser.pages())[0]; if (!targetPage) throw new Error("Expected the launched browser to expose a page target"); try { await tool.execute("open", { action: "open", name: tabName, url: requested, app: { cdp_url: `http://${endpoint.host}` }, }); opened = true; const closeResult = await tool.execute("close", { action: "close", name: tabName }); opened = false; expect(closeResult.content).toEqual([{ type: "text", text: `Released managed tab "${tabName}"` }]); expect(targetPage.isClosed()).toBe(false); expect(targetPage.url()).toBe(requested); } finally { if (opened) await tool.execute("close", { action: "close", name: tabName }); } }, 30_000, ); test.skipIf(!CHROMIUM_AVAILABLE)( "does not retry an attached navigation failure as worker startup", async () => { let requestCount = 0; const server = Bun.serve({ port: 0, fetch: () => { requestCount++; return new Promise(() => {}); }, }); const launched = sharedHeadless; if (!launched || !("browser" in launched)) throw new Error("Expected a shared Puppeteer browser"); const endpoint = new URL(launched.browser.wsEndpoint()); let attached: BrowserHandle | undefined; let attempted = false; try { attached = await acquireBrowser( { kind: "connected", cdpUrl: `http://${endpoint.host}` }, { cwd: process.cwd() }, ); attempted = true; await expect( acquireTab(`attach-failure-${process.pid}-${Math.random().toString(36).slice(2)}`, attached, { url: `http://127.0.0.1:${server.port}/hang`, waitUntil: "domcontentloaded", timeoutMs: 100, }), ).rejects.toThrow(/Navigation timeout/i); expect(requestCount).toBe(1); } finally { if (attached && !attempted) await releaseBrowser(attached, { kill: false }); await server.stop(true); } }, 30_000, ); }); describe("probeCdpStatus", () => { // Regression for #8567: a local proxy (Clash, corporate) 502s internal // loopback addresses, so a bare fetch()/node:http probe misreports a healthy // CDP daemon as dead. The raw-TCP probe must ignore HTTP_PROXY entirely. test("returns the loopback status even when HTTP_PROXY 502s the request", async () => { const cdp = Bun.serve({ port: 0, fetch: () => new Response("{}", { status: 200 }) }); const proxy = Bun.serve({ port: 0, fetch: () => new Response("Bad Gateway", { status: 502 }) }); const saved = { HTTP_PROXY: process.env.HTTP_PROXY, http_proxy: process.env.http_proxy }; process.env.HTTP_PROXY = `http://127.0.0.1:${proxy.port}`; process.env.http_proxy = `http://127.0.0.1:${proxy.port}`; try { const status = await probeCdpStatus(`http://127.0.0.1:${cdp.port}/json/version`, { timeoutMs: 1500 }); expect(status).toBe(200); } finally { process.env.HTTP_PROXY = saved.HTTP_PROXY; process.env.http_proxy = saved.http_proxy; if (saved.HTTP_PROXY === undefined) delete process.env.HTTP_PROXY; if (saved.http_proxy === undefined) delete process.env.http_proxy; await cdp.stop(true); await proxy.stop(true); } }); test("surfaces a non-2xx status from a live endpoint", async () => { const server = Bun.serve({ port: 0, fetch: () => new Response("nope", { status: 503 }) }); try { const status = await probeCdpStatus(`http://127.0.0.1:${server.port}/json/version`, { timeoutMs: 1500 }); expect(status).toBe(503); } finally { await server.stop(true); } }); test("returns null when the endpoint is unreachable", async () => { const port = await findFreeCdpPort(); const status = await probeCdpStatus(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 500 }); expect(status).toBeNull(); }); test("returns null when the request is already aborted", async () => { const server = Bun.serve({ port: 0, fetch: () => new Response("{}", { status: 200 }) }); try { const status = await probeCdpStatus(`http://127.0.0.1:${server.port}/json/version`, { timeoutMs: 1500, signal: AbortSignal.abort(), }); expect(status).toBeNull(); } finally { await server.stop(true); } }); });