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:
gsxdsm
2026-06-12 02:11:04 -07:00
parent 5930c53eac
commit 3f2197be20
8 changed files with 745 additions and 1 deletions

View File

@@ -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",

View File

@@ -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);
});
});

View 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);
});
});

View File

@@ -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();
});
});

View 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;
}
}

View File

@@ -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";

View File

@@ -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
View File

@@ -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