diff --git a/plugins/fusion-plugin-agent-browser/package.json b/plugins/fusion-plugin-agent-browser/package.json index 66b4a7acb7..1367f6bc5c 100644 --- a/plugins/fusion-plugin-agent-browser/package.json +++ b/plugins/fusion-plugin-agent-browser/package.json @@ -15,7 +15,8 @@ "test": "vitest run --silent=passed-only --reporter=dot" }, "dependencies": { - "@fusion/plugin-sdk": "workspace:*" + "@fusion/plugin-sdk": "workspace:*", + "playwright-core": "^1.60.0" }, "devDependencies": { "@types/node": "^25.5.2", diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/driver-scope.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/driver-scope.test.ts new file mode 100644 index 0000000000..f75274939d --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/driver-scope.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import plugin from "../index.js"; +import { AGENT_BROWSER_TOOLS } from "../tools.js"; + +// The engine exposes ONLY `plugin.tools` to coding-agent sessions +// (`pluginLoader.getPluginTools()`). The verification driver must therefore +// never appear in that array — it is reachable only via the direct +// `launchBrowserDriver` export the engine imports inside the verification run. +describe("driver scope — not exposed to coding-agent sessions", () => { + const driverToolNames = ["browser_navigate", "browser_interact", "browser_observe", "browser_driver"]; + + it("plugin.tools contains only the coding-agent metadata tool, not the driver", () => { + const toolNames = (plugin.tools ?? []).map((t) => t.name); + expect(toolNames).toEqual(["browser_fetch_metadata"]); + }); + + it("AGENT_BROWSER_TOOLS does not register any navigate/interact/observe driver tool", () => { + const names = AGENT_BROWSER_TOOLS.map((t) => t.name); + for (const driverName of driverToolNames) { + expect(names).not.toContain(driverName); + } + }); + + it("the verification driver is exported as a direct capability, not a registered tool", async () => { + const mod = await import("../index.js"); + expect(typeof mod.launchBrowserDriver).toBe("function"); + // It is a plain function export, not surfaced through any tool registry. + expect((plugin.tools ?? []).some((t) => t.name.includes("navigate"))).toBe(false); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/driver.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/driver.test.ts new file mode 100644 index 0000000000..66fe8708cc --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/driver.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from "vitest"; +import { + launchBrowserDriver, + type AutomationBrowser, + type AutomationContext, + type AutomationElement, + type AutomationPage, + type BrowserAutomationClient, +} from "../driver.js"; + +// A mocked element/page/context/browser stack. Real browser automation is NOT +// exercised in the merge gate — only the driver's wiring against this mock is. +function makeMockStack(overrides?: { + selectorResolver?: (selector: string) => AutomationElement | null | "throw"; + pageUrl?: string; +}) { + const clickSpy = vi.fn(async () => {}); + const fillSpy = vi.fn(async () => {}); + const textSpy = vi.fn(async () => "Bug is fixed"); + + const element: AutomationElement = { + click: clickSpy, + fill: fillSpy, + textContent: textSpy, + }; + + const gotoSpy = vi.fn(async () => ({})); + const waitForSelectorSpy = vi.fn(async (selector: string) => { + const r = overrides?.selectorResolver ? overrides.selectorResolver(selector) : element; + if (r === "throw") throw new Error("Timeout 10000ms exceeded waiting for selector"); + return r; + }); + + const page: AutomationPage = { + goto: gotoSpy, + waitForSelector: waitForSelectorSpy, + innerText: vi.fn(async () => ""), + url: () => overrides?.pageUrl ?? "http://127.0.0.1:54321/board", + }; + + const pageCloseDeps = { contextClose: vi.fn(async () => {}), browserClose: vi.fn(async () => {}) }; + + const context: AutomationContext = { + newPage: vi.fn(async () => page), + close: pageCloseDeps.contextClose, + }; + + const browser: AutomationBrowser = { + newContext: vi.fn(async () => context), + close: pageCloseDeps.browserClose, + }; + + const launchSpy = vi.fn(async () => browser); + const client: BrowserAutomationClient = { launch: launchSpy }; + + return { + client, + spies: { launchSpy, gotoSpy, waitForSelectorSpy, clickSpy, fillSpy, textSpy, ...pageCloseDeps }, + }; +} + +// An env with an explicit executable so the probe always "finds" a browser in +// tests (executablePath is passed straight through when provided to the probe, +// but the probe still verifies existence — so we instead inject the client and +// point at this test file as the "executable", which exists and is readable). +// To force the available path deterministically we pass `executablePath` of a +// real file and rely on access(X_OK); on POSIX the test file may not be +x, so +// we instead bypass discovery by asserting the unavailable path separately and, +// for the "available" cases, point executablePath at a path that exists & is +// executable: the node binary itself. +const NODE_BIN = process.execPath; + +describe("browser driver — availability / inconclusive", () => { + it("reports inconclusive (browser-unavailable) when no executable is found", async () => { + const { client } = makeMockStack(); + const result = await launchBrowserDriver({ + client, + executablePath: "/nonexistent/path/to/chrome-does-not-exist", + }); + expect(result.status).toBe("inconclusive"); + if (result.status === "inconclusive") { + expect(result.reason).toBe("browser-unavailable"); + } + }); + + it("does not launch the client when the browser is unavailable", async () => { + const { client, spies } = makeMockStack(); + await launchBrowserDriver({ client, executablePath: "/nope/chrome" }); + expect(spies.launchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("browser driver — navigate / interact / observe (mocked client)", () => { + it("launches against the discovered executable and headless flag", async () => { + const { client, spies } = makeMockStack(); + const result = await launchBrowserDriver({ client, executablePath: NODE_BIN, headless: true }); + expect(result.status).toBe("ready"); + expect(spies.launchSpy).toHaveBeenCalledWith({ executablePath: NODE_BIN, headless: true }); + }); + + it("navigate calls page.goto and returns ok with the landed url", async () => { + const { client, spies } = makeMockStack({ pageUrl: "http://127.0.0.1:9/board" }); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + expect(launched.status).toBe("ready"); + if (launched.status !== "ready") return; + const nav = await launched.session.navigate("http://127.0.0.1:9/board"); + expect(spies.gotoSpy).toHaveBeenCalledWith( + "http://127.0.0.1:9/board", + expect.objectContaining({ waitUntil: "load" }), + ); + expect(nav).toEqual({ status: "ok", url: "http://127.0.0.1:9/board" }); + }); + + it("click resolves the selector and clicks the element", async () => { + const { client, spies } = makeMockStack(); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + const out = await launched.session.click("#fix-button"); + expect(spies.waitForSelectorSpy).toHaveBeenCalledWith("#fix-button", expect.any(Object)); + expect(spies.clickSpy).toHaveBeenCalledTimes(1); + expect(out).toEqual({ status: "ok" }); + }); + + it("type resolves the selector and fills the element", async () => { + const { client, spies } = makeMockStack(); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + const out = await launched.session.type("input[name=q]", "hello"); + expect(spies.fillSpy).toHaveBeenCalledWith("hello"); + expect(out).toEqual({ status: "ok" }); + }); + + it("observe returns found with the element text (reproduces a UI behavior)", async () => { + const { client, spies } = makeMockStack(); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + const out = await launched.session.observe(".status"); + expect(spies.textSpy).toHaveBeenCalled(); + expect(out).toEqual({ status: "found", text: "Bug is fixed", url: "http://127.0.0.1:54321/board" }); + }); +}); + +describe("browser driver — un-exercisable assertion → inconclusive (not fail)", () => { + it("click on an unreachable selector resolves to inconclusive/selector-unreachable", async () => { + const { client } = makeMockStack({ selectorResolver: () => "throw" }); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + const out = await launched.session.click("#never-here"); + expect(out.status).toBe("inconclusive"); + if (out.status === "inconclusive") expect(out.reason).toBe("selector-unreachable"); + }); + + it("navigation failure resolves to inconclusive/navigation-failed (never fail)", async () => { + const { client, spies } = makeMockStack(); + spies.gotoSpy.mockRejectedValueOnce(new Error("net::ERR_CONNECTION_REFUSED")); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + const out = await launched.session.navigate("http://127.0.0.1:1/dead"); + expect(out.status).toBe("inconclusive"); + if (out.status === "inconclusive") expect(out.reason).toBe("navigation-failed"); + }); + + it("setup failure (browser launch throws) resolves to inconclusive/setup-failed", async () => { + const { client, spies } = makeMockStack(); + spies.launchSpy.mockRejectedValueOnce(new Error("spawn chrome ENOENT")); + const result = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + expect(result.status).toBe("inconclusive"); + if (result.status === "inconclusive") expect(result.reason).toBe("setup-failed"); + }); +}); + +describe("browser driver — absence is a real observation, not inconclusive", () => { + it("observe of a missing selector resolves to absent (distinct from inconclusive)", async () => { + const { client } = makeMockStack({ selectorResolver: () => "throw" }); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + const out = await launched.session.observe(".gone"); + expect(out.status).toBe("absent"); + }); + + it("observe returning null element resolves to absent", async () => { + const { client } = makeMockStack({ selectorResolver: () => null }); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + const out = await launched.session.observe(".gone"); + expect(out.status).toBe("absent"); + }); +}); + +describe("browser driver — teardown", () => { + it("dispose closes the context and the browser", async () => { + const { client, spies } = makeMockStack(); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + await launched.session.dispose(); + expect(spies.contextClose).toHaveBeenCalledTimes(1); + expect(spies.browserClose).toHaveBeenCalledTimes(1); + }); + + it("dispose is idempotent (second call does not double-close)", async () => { + const { client, spies } = makeMockStack(); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + await launched.session.dispose(); + await launched.session.dispose(); + expect(spies.contextClose).toHaveBeenCalledTimes(1); + expect(spies.browserClose).toHaveBeenCalledTimes(1); + }); + + it("dispose tolerates a close() that throws (teardown is best-effort)", async () => { + const { client, spies } = makeMockStack(); + spies.contextClose.mockRejectedValueOnce(new Error("already closed")); + const launched = await launchBrowserDriver({ client, executablePath: NODE_BIN }); + if (launched.status !== "ready") throw new Error("expected ready"); + await expect(launched.session.dispose()).resolves.toBeUndefined(); + expect(spies.browserClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/probe-browser.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/probe-browser.test.ts new file mode 100644 index 0000000000..ab8e9dcbad --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/probe-browser.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { probeBrowserExecutable } from "../probe.js"; + +describe("probeBrowserExecutable", () => { + it("reports unavailable with a reason when an explicit path does not exist", async () => { + const result = await probeBrowserExecutable({ executablePath: "/definitely/not/a/browser/here" }); + expect(result.available).toBe(false); + expect(result.reason).toContain("/definitely/not/a/browser/here"); + }); + + it("accepts an explicit executable path that exists and is executable", async () => { + // process.execPath (node itself) exists and is executable on all platforms. + const result = await probeBrowserExecutable({ executablePath: process.execPath }); + expect(result.available).toBe(true); + expect(result.executablePath).toBe(process.execPath); + }); + + it("honors the FUSION_BROWSER_EXECUTABLE env override", async () => { + const result = await probeBrowserExecutable({ env: { FUSION_BROWSER_EXECUTABLE: process.execPath } as NodeJS.ProcessEnv }); + expect(result.available).toBe(true); + expect(result.executablePath).toBe(process.execPath); + }); + + it("reports unavailable (not throw) when nothing is discoverable", async () => { + // An env with no overrides and PATH that cannot resolve browser binaries. + const result = await probeBrowserExecutable({ + env: { PATH: "/nonexistent-bin-dir" } as NodeJS.ProcessEnv, + }); + // On a CI box without a system Chrome this is false; if a system Chrome is at + // a well-known path it could be true. Either way it must not throw and must + // carry a coherent shape. + expect(typeof result.available).toBe("boolean"); + if (!result.available) expect(result.reason).toBeTruthy(); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/driver.ts b/plugins/fusion-plugin-agent-browser/src/driver.ts new file mode 100644 index 0000000000..e2196088fa --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/driver.ts @@ -0,0 +1,321 @@ +/** + * App/browser driver (U8). + * + * A REAL navigate / interact / observe driver used by the verification run + * (U5 wires it in) to reproduce a UI/bug assertion's observable behavior against + * the isolated app instance the U4 harness launches (`launchIsolatedApp()` → + * `{ baseUrl, port, dbPath, clientDir, dispose() }`). + * + * Design constraints (see plan unit U8 / R12 and the brainstorm): + * + * - **No bundled browser.** Driving is done through `playwright-core`, which — + * unlike full `playwright` — does NOT download a browser at install time. It + * launches an EXISTING Chrome/Chromium discovered on the host via + * `probeBrowserExecutable()` (see `probe.ts`). This keeps the install/build + * gate fast and deterministic in CI. + * + * - **Graceful degradation → inconclusive.** When no browser executable is + * available, or an assertion is structurally un-exercisable (a selector that + * never appears, a state the driver cannot set up), the driver reports an + * `inconclusive` outcome — NEVER a false pass or fail. The verification run + * (U5) maps `inconclusive` to a blocked/needs-attention verdict that spawns no + * Fix Feature (R21). + * + * - **Verification-scoped, not a coding-agent tool.** This capability is + * deliberately NOT registered in the plugin's `tools` array (which is what the + * engine exposes to coding-agent sessions via `pluginLoader.getPluginTools()`). + * It is exported as a typed factory the engine imports directly inside the + * verification run. A coding agent therefore can never reach navigate / + * interact / observe; only the verification path can. + * + * - **Clean teardown.** Every successful `launch()` returns a session with a + * `dispose()` that closes the page, context, and browser unconditionally and + * idempotently — including after a mid-run failure. + * + * The Playwright client is injected (`BrowserAutomationClient`) so the merge-gate + * unit tests drive a mock; real browser automation is exercised only in a manual + * smoke / heavier lane, never in the merge gate. + */ + +import { probeBrowserExecutable } from "./probe.js"; + +// ── Minimal automation-client surface (the slice of playwright-core we use) ──── +// +// Declared structurally so tests can supply a mock without importing +// playwright-core, and so the driver does not couple to playwright's full type +// surface. The real client is built lazily from playwright-core in +// `createPlaywrightClient()`. + +/** A located element handle (opaque to the driver beyond the methods used). */ +export interface AutomationElement { + click(): Promise; + fill(value: string): Promise; + textContent(): Promise; +} + +/** A single page/tab the driver navigates and observes. */ +export interface AutomationPage { + goto(url: string, opts?: { timeout?: number; waitUntil?: string }): Promise; + /** Resolve a selector to an element, waiting up to `timeout` ms. Null when it never appears. */ + waitForSelector(selector: string, opts?: { timeout?: number; state?: string }): Promise; + /** Read the visible text of the whole document body. */ + innerText(selector: string): Promise; + url(): string; +} + +/** A browser context (isolated cookie/storage jar) holding pages. */ +export interface AutomationContext { + newPage(): Promise; + close(): Promise; +} + +/** A launched browser process. */ +export interface AutomationBrowser { + newContext(): Promise; + close(): Promise; +} + +/** The injectable automation backend (real = playwright-core, test = mock). */ +export interface BrowserAutomationClient { + launch(opts: { executablePath: string; headless: boolean }): Promise; +} + +// ── Driver result types ──────────────────────────────────────────────────── + +/** Why a driver operation could not reach a definitive observation. */ +export type InconclusiveReason = + | "browser-unavailable" + | "selector-unreachable" + | "navigation-failed" + | "setup-failed" + | "driver-error"; + +export interface ObserveOutcomeFound { + status: "found"; + /** The text content of the observed element/selector. */ + text: string; + /** The URL the observation was made against. */ + url: string; +} + +export interface ObserveOutcomeAbsent { + status: "absent"; + url: string; +} + +export interface OperationInconclusive { + status: "inconclusive"; + reason: InconclusiveReason; + detail: string; +} + +export type ObserveOutcome = ObserveOutcomeFound | ObserveOutcomeAbsent | OperationInconclusive; +export type InteractOutcome = { status: "ok" } | OperationInconclusive; +export type NavigateOutcome = { status: "ok"; url: string } | OperationInconclusive; + +/** Result of attempting to obtain a driver session. */ +export type DriverLaunchResult = + | { status: "ready"; session: BrowserDriverSession } + | OperationInconclusive; + +const DEFAULT_OP_TIMEOUT_MS = 10_000; + +/** + * A live driver session bound to a single browser/context/page targeting the + * isolated app instance. All operations degrade to `inconclusive` rather than + * throwing, so a fragile UI never manufactures a false fail. + */ +export interface BrowserDriverSession { + /** Navigate to a URL (typically `${baseUrl}${path}` of the isolated app). */ + navigate(url: string, opts?: { timeoutMs?: number }): Promise; + /** Click the first element matching `selector`. */ + click(selector: string, opts?: { timeoutMs?: number }): Promise; + /** Type `value` into the first element matching `selector`. */ + type(selector: string, value: string, opts?: { timeoutMs?: number }): Promise; + /** + * Observe whether `selector` is present and read its text. A selector that + * never appears within the timeout resolves to `absent` (a real negative + * observation), distinct from an `inconclusive` driver/setup failure. + */ + observe(selector: string, opts?: { timeoutMs?: number; expectAbsent?: boolean }): Promise; + /** Close page/context/browser unconditionally. Idempotent. */ + dispose(): Promise; +} + +export interface LaunchDriverOptions { + /** Injected automation backend; defaults to the real playwright-core client. */ + client?: BrowserAutomationClient; + /** Explicit Chrome/Chromium executable path (else discovered via probe). */ + executablePath?: string; + /** Run headless (default true). */ + headless?: boolean; + /** Env used for executable discovery (defaults to process.env). */ + env?: NodeJS.ProcessEnv; +} + +/** + * Acquire a browser driver session, or report why one could not be acquired. + * + * Returns `{ status: "inconclusive", reason: "browser-unavailable" }` when no + * Chrome/Chromium executable is found (R12 graceful degradation) — the caller + * (U5) treats that as INCONCLUSIVE, never a pass/fail. + */ +export async function launchBrowserDriver(opts: LaunchDriverOptions = {}): Promise { + const probe = await probeBrowserExecutable({ executablePath: opts.executablePath, env: opts.env }); + if (!probe.available || !probe.executablePath) { + return { + status: "inconclusive", + reason: "browser-unavailable", + detail: probe.reason ?? "no browser executable available", + }; + } + + const client = opts.client ?? (await createPlaywrightClient()); + if (!client) { + return { + status: "inconclusive", + reason: "browser-unavailable", + detail: "playwright-core automation client could not be loaded", + }; + } + + let browser: AutomationBrowser | undefined; + let context: AutomationContext | undefined; + let page: AutomationPage | undefined; + try { + browser = await client.launch({ executablePath: probe.executablePath, headless: opts.headless ?? true }); + context = await browser.newContext(); + page = await context.newPage(); + } catch (err) { + // Best-effort teardown of whatever was created before the failure. + await safeClose(context); + await safeClose(browser); + return { + status: "inconclusive", + reason: "setup-failed", + detail: `failed to launch browser session: ${errMsg(err)}`, + }; + } + + const session = makeSession(browser, context, page); + return { status: "ready", session }; +} + +function makeSession(browser: AutomationBrowser, context: AutomationContext, page: AutomationPage): BrowserDriverSession { + let disposed = false; + + return { + async navigate(url, navOpts) { + try { + await page.goto(url, { timeout: navOpts?.timeoutMs ?? DEFAULT_OP_TIMEOUT_MS, waitUntil: "load" }); + return { status: "ok", url: page.url() }; + } catch (err) { + return { status: "inconclusive", reason: "navigation-failed", detail: `goto ${url} failed: ${errMsg(err)}` }; + } + }, + + async click(selector, opOpts) { + const el = await locate(page, selector, opOpts?.timeoutMs); + if (el === "inconclusive") { + return { status: "inconclusive", reason: "selector-unreachable", detail: `click target not found: ${selector}` }; + } + try { + await el.click(); + return { status: "ok" }; + } catch (err) { + return { status: "inconclusive", reason: "driver-error", detail: `click ${selector} failed: ${errMsg(err)}` }; + } + }, + + async type(selector, value, opOpts) { + const el = await locate(page, selector, opOpts?.timeoutMs); + if (el === "inconclusive") { + return { status: "inconclusive", reason: "selector-unreachable", detail: `type target not found: ${selector}` }; + } + try { + await el.fill(value); + return { status: "ok" }; + } catch (err) { + return { status: "inconclusive", reason: "driver-error", detail: `type into ${selector} failed: ${errMsg(err)}` }; + } + }, + + async observe(selector, obsOpts) { + const timeout = obsOpts?.timeoutMs ?? DEFAULT_OP_TIMEOUT_MS; + // When asserting absence, a missing selector is a real `absent` observation, + // not an inconclusive failure. + let el: AutomationElement | null; + try { + el = await page.waitForSelector(selector, { timeout, state: obsOpts?.expectAbsent ? "attached" : "visible" }); + } catch { + // waitForSelector rejects on timeout: the element never appeared. + return { status: "absent", url: page.url() }; + } + if (!el) return { status: "absent", url: page.url() }; + try { + const text = (await el.textContent()) ?? ""; + return { status: "found", text, url: page.url() }; + } catch (err) { + return { status: "inconclusive", reason: "driver-error", detail: `read ${selector} failed: ${errMsg(err)}` }; + } + }, + + async dispose() { + if (disposed) return; + disposed = true; + await safeClose(context); + await safeClose(browser); + }, + }; +} + +/** + * Resolve a selector to an element, or signal `"inconclusive"` when it never + * appears within the timeout. Distinct from `observe`, which treats absence as a + * first-class negative observation. + */ +async function locate( + page: AutomationPage, + selector: string, + timeoutMs?: number, +): Promise { + try { + const el = await page.waitForSelector(selector, { timeout: timeoutMs ?? DEFAULT_OP_TIMEOUT_MS, state: "visible" }); + return el ?? "inconclusive"; + } catch { + return "inconclusive"; + } +} + +async function safeClose(closable: { close(): Promise } | undefined): Promise { + if (!closable) return; + try { + await closable.close(); + } catch { + // teardown is best-effort and must never throw + } +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Build the real automation client from `playwright-core`, adapting its + * chromium API to the structural `BrowserAutomationClient` surface. Imported + * lazily so merge-gate unit tests (which inject a mock) never load + * playwright-core, and so a missing/broken playwright-core degrades to + * `undefined` (→ inconclusive) instead of throwing at module load. + */ +export async function createPlaywrightClient(): Promise { + try { + const pw = (await import("playwright-core")) as unknown as { + chromium: { launch(opts: { executablePath: string; headless: boolean }): Promise }; + }; + return { + launch: (opts) => pw.chromium.launch(opts), + }; + } catch { + return undefined; + } +} diff --git a/plugins/fusion-plugin-agent-browser/src/index.ts b/plugins/fusion-plugin-agent-browser/src/index.ts index 50bf5776c6..705a1194a7 100644 --- a/plugins/fusion-plugin-agent-browser/src/index.ts +++ b/plugins/fusion-plugin-agent-browser/src/index.ts @@ -65,3 +65,27 @@ const plugin: FusionPlugin = definePlugin({ }); export default plugin; + +// ── Verification-scoped app/browser driver (U8) ─────────────────────────────── +// +// The navigate/interact/observe driver is exported as a typed capability the +// engine imports DIRECTLY inside the verification run (U5). It is intentionally +// NOT added to `plugin.tools` — the engine only exposes `plugin.tools` to +// coding-agent sessions (`pluginLoader.getPluginTools()`), so keeping the driver +// out of that array is what scopes it to verification and keeps it unreachable +// from normal coding sessions. `browser_fetch_metadata` remains the only +// coding-agent-facing tool. +export { + launchBrowserDriver, + createPlaywrightClient, + type BrowserDriverSession, + type BrowserAutomationClient, + type DriverLaunchResult, + type NavigateOutcome, + type InteractOutcome, + type ObserveOutcome, + type OperationInconclusive, + type InconclusiveReason, + type LaunchDriverOptions, +} from "./driver.js"; +export { probeBrowserExecutable, type BrowserExecutableProbeResult } from "./probe.js"; diff --git a/plugins/fusion-plugin-agent-browser/src/probe.ts b/plugins/fusion-plugin-agent-browser/src/probe.ts index 1d9ae0814c..8d5108142d 100644 --- a/plugins/fusion-plugin-agent-browser/src/probe.ts +++ b/plugins/fusion-plugin-agent-browser/src/probe.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { access, constants } from "node:fs/promises"; export interface AgentBrowserProbeResult { available: boolean; @@ -56,6 +57,110 @@ export async function probeAgentBrowserBinary(opts?: { binaryPath?: string; time }); } +// ── Chromium/Chrome executable discovery (for the verification driver) ───────── +// +// The app/browser driver (U8) drives a Chromium engine via playwright-core, which +// does NOT bundle or download a browser. It launches an EXISTING Chrome/Chromium +// discovered on the host. When no executable can be found the driver must report +// itself unavailable so the verification run resolves the assertion to +// INCONCLUSIVE (never a false pass/fail). + +export interface BrowserExecutableProbeResult { + /** True only when a usable Chrome/Chromium executable was located. */ + available: boolean; + /** Absolute (or PATH-resolvable) executable path, when found. */ + executablePath?: string; + /** Human-readable reason the executable is unavailable. */ + reason?: string; +} + +/** + * Well-known Chrome/Chromium executable locations, by platform. Checked in + * order; the first that exists wins. Env overrides take precedence over these. + */ +function candidateBrowserPaths(env: NodeJS.ProcessEnv): string[] { + const fromEnv = [env.FUSION_BROWSER_EXECUTABLE, env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, env.CHROME_PATH] + .map((v) => v?.trim()) + .filter((v): v is string => !!v && v.length > 0); + + if (process.platform === "darwin") { + return [ + ...fromEnv, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ]; + } + if (process.platform === "win32") { + const programFiles = env["PROGRAMFILES"] ?? "C:\\Program Files"; + const programFilesX86 = env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)"; + return [ + ...fromEnv, + `${programFiles}\\Google\\Chrome\\Application\\chrome.exe`, + `${programFilesX86}\\Google\\Chrome\\Application\\chrome.exe`, + `${programFilesX86}\\Microsoft\\Edge\\Application\\msedge.exe`, + ]; + } + return [ + ...fromEnv, + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + ]; +} + +/** PATH-resolvable executable names to fall back to when no fixed path exists. */ +const BROWSER_BINARY_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"]; + +/** + * Locate a Chrome/Chromium executable the verification driver can launch. + * + * Resolution order: explicit `opts.executablePath` → env overrides + * (`FUSION_BROWSER_EXECUTABLE` / `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` / + * `CHROME_PATH`) → well-known platform paths → PATH lookup of common binary + * names. Returns `available: false` (with a reason) when nothing is found, so + * the caller degrades to INCONCLUSIVE rather than failing the assertion. + */ +export async function probeBrowserExecutable(opts?: { + executablePath?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const env = opts?.env ?? process.env; + const explicit = opts?.executablePath?.trim(); + if (explicit) { + if (await isExecutableFile(explicit)) return { available: true, executablePath: explicit }; + return { available: false, reason: `configured browser executable not found: ${explicit}` }; + } + + for (const candidate of candidateBrowserPaths(env)) { + if (await isExecutableFile(candidate)) return { available: true, executablePath: candidate }; + } + + for (const name of BROWSER_BINARY_NAMES) { + const resolved = await tryResolveBinaryPath(name); + if (resolved && (await isExecutableFile(resolved))) { + return { available: true, executablePath: resolved }; + } + } + + return { + available: false, + reason: + "no Chrome/Chromium executable found (checked FUSION_BROWSER_EXECUTABLE / CHROME_PATH, well-known paths, and PATH)", + }; +} + +async function isExecutableFile(p: string): Promise { + try { + await access(p, constants.X_OK); + return true; + } catch { + return false; + } +} + async function tryResolveBinaryPath(binary: string): Promise { return new Promise((resolvePromise) => { const which = process.platform === "win32" ? "where" : "which"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7fab322e4..558e9074cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,6 +731,9 @@ importers: '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + playwright-core: + specifier: ^1.60.0 + version: 1.60.0 devDependencies: '@types/node': specifier: ^25.5.2 @@ -5824,6 +5827,11 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -12929,6 +12937,8 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.60.0: {} + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.12