feat(agent-browser): real navigate/interact/observe driver (U8)
Replaces the metadata/probe stub with a playwright-core driver (no bundled browser download; uses a probe-discovered Chrome) exposing navigate/click/type/ observe + idempotent dispose. Browser-unavailable and un-exercisable assertions return inconclusive (never a false pass/fail); a missing selector returns a distinct 'absent' negative observation. Driver is a direct export scoped to the verification run, not registered as a coding-agent tool.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
218
plugins/fusion-plugin-agent-browser/src/__tests__/driver.test.ts
Normal file
218
plugins/fusion-plugin-agent-browser/src/__tests__/driver.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
321
plugins/fusion-plugin-agent-browser/src/driver.ts
Normal file
321
plugins/fusion-plugin-agent-browser/src/driver.ts
Normal file
@@ -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<void>;
|
||||
fill(value: string): Promise<void>;
|
||||
textContent(): Promise<string | null>;
|
||||
}
|
||||
|
||||
/** A single page/tab the driver navigates and observes. */
|
||||
export interface AutomationPage {
|
||||
goto(url: string, opts?: { timeout?: number; waitUntil?: string }): Promise<unknown>;
|
||||
/** 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<AutomationElement | null>;
|
||||
/** Read the visible text of the whole document body. */
|
||||
innerText(selector: string): Promise<string>;
|
||||
url(): string;
|
||||
}
|
||||
|
||||
/** A browser context (isolated cookie/storage jar) holding pages. */
|
||||
export interface AutomationContext {
|
||||
newPage(): Promise<AutomationPage>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** A launched browser process. */
|
||||
export interface AutomationBrowser {
|
||||
newContext(): Promise<AutomationContext>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** The injectable automation backend (real = playwright-core, test = mock). */
|
||||
export interface BrowserAutomationClient {
|
||||
launch(opts: { executablePath: string; headless: boolean }): Promise<AutomationBrowser>;
|
||||
}
|
||||
|
||||
// ── 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<NavigateOutcome>;
|
||||
/** Click the first element matching `selector`. */
|
||||
click(selector: string, opts?: { timeoutMs?: number }): Promise<InteractOutcome>;
|
||||
/** Type `value` into the first element matching `selector`. */
|
||||
type(selector: string, value: string, opts?: { timeoutMs?: number }): Promise<InteractOutcome>;
|
||||
/**
|
||||
* 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<ObserveOutcome>;
|
||||
/** Close page/context/browser unconditionally. Idempotent. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
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<DriverLaunchResult> {
|
||||
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<AutomationElement | "inconclusive"> {
|
||||
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<void> } | undefined): Promise<void> {
|
||||
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<BrowserAutomationClient | undefined> {
|
||||
try {
|
||||
const pw = (await import("playwright-core")) as unknown as {
|
||||
chromium: { launch(opts: { executablePath: string; headless: boolean }): Promise<AutomationBrowser> };
|
||||
};
|
||||
return {
|
||||
launch: (opts) => pw.chromium.launch(opts),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<BrowserExecutableProbeResult> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await access(p, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryResolveBinaryPath(binary: string): Promise<string | undefined> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const which = process.platform === "win32" ? "where" : "which";
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user