fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,880 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import registerFastBrowser, { type FastBrowserAdapters } from "./index.ts";
|
||||
import type { BrowserSession, BrowserSetupSession, BrowserSessionManager } from "./browser-session.ts";
|
||||
import type {
|
||||
BrowserBridge,
|
||||
BrowserOperation,
|
||||
BrowserTransport,
|
||||
BrowserTransportRequest,
|
||||
BrowserTransportResultFor,
|
||||
BrowserRuntime,
|
||||
Candidate,
|
||||
Observation,
|
||||
} from "./contracts.ts";
|
||||
import { createBrowserRuntime } from "./runtime.ts";
|
||||
|
||||
// Adapter tests are intentionally offline. In particular, exact-name runs must
|
||||
// not accidentally call JEV or any other HTTP provider using ambient secrets.
|
||||
const forbiddenFetch = mock.method(globalThis, "fetch", async () => {
|
||||
throw new Error("Network is disabled in offline adapter tests");
|
||||
});
|
||||
test.after(() => assert.equal(forbiddenFetch.mock.callCount(), 0, "offline tests attempted HTTP"));
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
description: string;
|
||||
executionMode?: string;
|
||||
promptGuidelines?: string[];
|
||||
parameters: Record<string, any>;
|
||||
execute: (...args: any[]) => Promise<any>;
|
||||
}
|
||||
|
||||
interface CapturedPi {
|
||||
tools: RegisteredTool[];
|
||||
commands: string[];
|
||||
handlers: string[];
|
||||
}
|
||||
|
||||
function sessionManagerFixture(): BrowserSessionManager & { browserExit(id: string): void } {
|
||||
const sessions = new Map<string, BrowserSession | BrowserSetupSession>();
|
||||
const setupSessions = new Set<string>();
|
||||
const controllers = new Map<string, AbortController>();
|
||||
let next = 0;
|
||||
return {
|
||||
async open(config, signal) {
|
||||
const controller = new AbortController();
|
||||
const session: BrowserSession = {
|
||||
id: `session-${++next}`,
|
||||
config: {
|
||||
...config,
|
||||
cdpUrl: config.cdpUrl ?? `http://127.0.0.1:${9_222 + next}`,
|
||||
},
|
||||
owned: config.browser?.mode === "launch",
|
||||
signal: AbortSignal.any([signal, controller.signal]),
|
||||
};
|
||||
sessions.set(session.id, session);
|
||||
controllers.set(session.id, controller);
|
||||
return session;
|
||||
},
|
||||
async openSetup(config, signal) {
|
||||
const controller = new AbortController();
|
||||
const session: BrowserSetupSession = {
|
||||
id: `setup-${++next}`,
|
||||
config: { ...config, browser: config.browser ? { ...config.browser } : undefined },
|
||||
owned: true,
|
||||
signal: AbortSignal.any([signal, controller.signal]),
|
||||
};
|
||||
sessions.set(session.id, session);
|
||||
setupSessions.add(session.id);
|
||||
controllers.set(session.id, controller);
|
||||
return session;
|
||||
},
|
||||
get(id) {
|
||||
const session = sessions.get(id);
|
||||
if (!session) throw new Error("unknown session");
|
||||
if (setupSessions.has(id)) throw new Error("manual-only and cannot be automated");
|
||||
return session as BrowserSession;
|
||||
},
|
||||
async close(id) {
|
||||
controllers.get(id)?.abort(new Error("session closed"));
|
||||
controllers.delete(id);
|
||||
setupSessions.delete(id);
|
||||
sessions.delete(id);
|
||||
},
|
||||
async closeAll() {
|
||||
for (const controller of controllers.values()) controller.abort(new Error("sessions closed"));
|
||||
controllers.clear();
|
||||
setupSessions.clear();
|
||||
sessions.clear();
|
||||
},
|
||||
browserExit(id: string) {
|
||||
controllers.get(id)?.abort(new Error("browser exited"));
|
||||
controllers.delete(id);
|
||||
setupSessions.delete(id);
|
||||
sessions.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function capturePi(): CapturedPi {
|
||||
const captured: CapturedPi = { tools: [], commands: [], handlers: [] };
|
||||
registerFastBrowser(
|
||||
{
|
||||
registerTool(tool: unknown) {
|
||||
captured.tools.push(tool as RegisteredTool);
|
||||
},
|
||||
registerCommand(name: string) {
|
||||
captured.commands.push(name);
|
||||
},
|
||||
on(name: string) {
|
||||
captured.handlers.push(name);
|
||||
},
|
||||
} as unknown as ExtensionAPI,
|
||||
{ createSessionManager: (() => sessionManagerFixture()) as FastBrowserAdapters["createSessionManager"] },
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
|
||||
async function withEnvironment<T>(
|
||||
values: Record<string, string | undefined>,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const temporaryAgentDir = values.PI_CODING_AGENT_DIR === undefined
|
||||
? await mkdtemp(join(tmpdir(), "fast-browser-test-agent-"))
|
||||
: undefined;
|
||||
const effectiveValues: Record<string, string | undefined> = {
|
||||
...values,
|
||||
...(temporaryAgentDir ? { PI_CODING_AGENT_DIR: temporaryAgentDir } : {}),
|
||||
};
|
||||
const names = [
|
||||
"PI_CODING_AGENT_DIR",
|
||||
"FAST_BROWSER_CDP_URL",
|
||||
"FAST_BROWSER_ALLOWED_ORIGINS",
|
||||
"FAST_BROWSER_PYTHON",
|
||||
"FAST_BROWSER_TEXT_MODEL",
|
||||
"FAST_BROWSER_TEXT_REASONING",
|
||||
"TYPESAFE_API_KEY",
|
||||
"FAST_BROWSER_TEST_PASSWORD",
|
||||
];
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const name of names) {
|
||||
previous.set(name, process.env[name]);
|
||||
const value = effectiveValues[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
for (const [name, value] of previous) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
if (temporaryAgentDir) await rm(temporaryAgentDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function configEnvironment(): Record<string, string> {
|
||||
return {
|
||||
FAST_BROWSER_CDP_URL: "http://127.0.0.1:9222",
|
||||
FAST_BROWSER_ALLOWED_ORIGINS: '["https://example.test"]',
|
||||
};
|
||||
}
|
||||
|
||||
function fixtureCandidate(name = "Password"): Candidate {
|
||||
return { id: "candidate-1", role: "textbox", name, actions: ["fill"] };
|
||||
}
|
||||
|
||||
function fixtureObservation(candidate = fixtureCandidate()): Observation {
|
||||
return {
|
||||
observationId: "observation-1",
|
||||
url: "https://example.test/login?token=private#fragment",
|
||||
title: "Fixture",
|
||||
candidates: [candidate],
|
||||
};
|
||||
}
|
||||
|
||||
function fakeContext(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
||||
return {
|
||||
// This is print mode on purpose. The adapter must not use hasUI as an
|
||||
// approval gate and must not call the UI confirm method.
|
||||
hasUI: false,
|
||||
mode: "print",
|
||||
cwd: mkdtempSync(join(tmpdir(), "fast-browser-test-project-")),
|
||||
ui: {
|
||||
confirm: async () => {
|
||||
throw new Error("UI confirmation must never be called");
|
||||
},
|
||||
},
|
||||
modelRegistry: {
|
||||
getAvailable: () => [],
|
||||
hasConfiguredAuth: () => false,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function bridgeFixture(
|
||||
onAct: (request: unknown) => void = () => undefined,
|
||||
): BrowserBridge {
|
||||
return {
|
||||
async observe() {
|
||||
return fixtureObservation();
|
||||
},
|
||||
async act(request) {
|
||||
onAct(request);
|
||||
return { verified: true, evidence: "field value verified" };
|
||||
},
|
||||
async close() {
|
||||
// no-op
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerWithAdapters(adapters: FastBrowserAdapters): CapturedPi {
|
||||
const captured: CapturedPi = { tools: [], commands: [], handlers: [] };
|
||||
registerFastBrowser(
|
||||
{
|
||||
registerTool(tool: unknown) {
|
||||
captured.tools.push(tool as RegisteredTool);
|
||||
},
|
||||
registerCommand(name: string) {
|
||||
captured.commands.push(name);
|
||||
},
|
||||
on(name: string) {
|
||||
captured.handlers.push(name);
|
||||
},
|
||||
} as unknown as ExtensionAPI,
|
||||
{ createSessionManager: (() => sessionManagerFixture()) as FastBrowserAdapters["createSessionManager"], ...adapters },
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
|
||||
function toolsOf(captured: CapturedPi): { observe: RegisteredTool; run: RegisteredTool } {
|
||||
const observe = captured.tools.find((tool) => tool.name === "fast_browser_observe");
|
||||
const run = captured.tools.find((tool) => tool.name === "fast_browser_run");
|
||||
assert.ok(observe);
|
||||
assert.ok(run);
|
||||
return { observe, run };
|
||||
}
|
||||
|
||||
test("registers unattended parallel session tools and the companion command", () => {
|
||||
const captured = capturePi();
|
||||
assert.deepEqual(captured.tools.map((tool) => tool.name), [
|
||||
"fast_browser_open",
|
||||
"fast_browser_close",
|
||||
"fast_browser_observe",
|
||||
"fast_browser_run",
|
||||
"fast_browser_read",
|
||||
"fast_browser_assert",
|
||||
"fast_browser_control",
|
||||
"fast_browser_capture_evidence",
|
||||
]);
|
||||
assert.deepEqual(captured.commands, ["fast-browser"]);
|
||||
assert.deepEqual(captured.handlers, ["session_shutdown"]);
|
||||
const { observe, run } = toolsOf(captured);
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open");
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close");
|
||||
assert.ok(open);
|
||||
assert.ok(close);
|
||||
assert.ok(open.parameters.properties.settings);
|
||||
const originSchema = open.parameters.properties.settings.properties.allowedOrigins;
|
||||
assert.ok(originSchema.anyOf?.some((schema: any) => schema.const === "*"));
|
||||
assert.equal(open.parameters.properties.settings.properties.browser.properties.profile.pattern, "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$");
|
||||
assert.equal(close.parameters.properties.sessionId.minLength, 1);
|
||||
assert.equal(observe.promptGuidelines, undefined);
|
||||
assert.equal(run.promptGuidelines, undefined);
|
||||
assert.equal(observe.executionMode, "parallel");
|
||||
assert.equal(run.executionMode, "parallel");
|
||||
assert.match(run.description, /caller-authorized/);
|
||||
assert.match(run.description, /No user dialog/);
|
||||
assert.match(run.description, /print and JSON/);
|
||||
|
||||
const stepsSchema = run.parameters.properties.steps;
|
||||
assert.equal(stepsSchema.maxItems, 10);
|
||||
assert.equal(stepsSchema.minItems, 1);
|
||||
assert.equal(stepsSchema.items.properties.credential.maxLength, 100);
|
||||
assert.ok(run.parameters.properties.settings);
|
||||
assert.ok(run.parameters.properties.task);
|
||||
assert.ok(run.parameters.properties.credentials);
|
||||
assert.ok(run.parameters.properties.budgets);
|
||||
const control = captured.tools.find((tool) => tool.name === "fast_browser_control");
|
||||
assert.ok(control);
|
||||
assert.equal(control.parameters.allOf.length, 2);
|
||||
assert.ok(control.parameters.allOf[0].properties.sessionId);
|
||||
assert.ok(control.parameters.allOf[0].properties.settings);
|
||||
assert.equal(control.parameters.allOf[1].properties.operation.anyOf.length, 6);
|
||||
const evidence = captured.tools.find((tool) => tool.name === "fast_browser_capture_evidence");
|
||||
assert.ok(evidence);
|
||||
const evidenceOperationSchema = evidence.parameters.allOf[1].properties.operation;
|
||||
const evidenceOperations = evidenceOperationSchema.enum ?? evidenceOperationSchema.anyOf?.map((schema: any) => schema.const) ?? [];
|
||||
assert.ok(evidenceOperations.includes("closeTab"));
|
||||
assert.equal(evidenceOperations.includes("close"), false);
|
||||
});
|
||||
|
||||
test("defaults missing origin policy to unrestricted websites", async () => {
|
||||
let bridgeOptions: unknown;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: ((options: unknown) => {
|
||||
bridgeOptions = options;
|
||||
return bridgeFixture();
|
||||
}) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
const { observe } = toolsOf(captured);
|
||||
await withEnvironment({
|
||||
FAST_BROWSER_CDP_URL: "http://127.0.0.1:9222",
|
||||
FAST_BROWSER_ALLOWED_ORIGINS: undefined,
|
||||
}, async () => {
|
||||
await observe.execute("observe", { settings: {} }, undefined, undefined, fakeContext());
|
||||
});
|
||||
assert.deepEqual(bridgeOptions, {
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
allowedOrigins: "*",
|
||||
});
|
||||
});
|
||||
|
||||
test("wildcard sessions narrow per call while restricted sessions cannot broaden", async () => {
|
||||
let manager: BrowserSessionManager;
|
||||
let bridgeOptions: unknown;
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
createBrowserBridge: ((options: unknown) => {
|
||||
bridgeOptions = options;
|
||||
return bridgeFixture();
|
||||
}) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
const { observe } = toolsOf(captured);
|
||||
await withEnvironment({
|
||||
FAST_BROWSER_CDP_URL: "http://127.0.0.1:9222",
|
||||
FAST_BROWSER_ALLOWED_ORIGINS: undefined,
|
||||
}, async () => {
|
||||
const wildcard = await open.execute("open", { settings: {} }, undefined, undefined, fakeContext());
|
||||
await observe.execute("observe", {
|
||||
sessionId: wildcard.details.sessionId,
|
||||
settings: { allowedOrigins: ["https://narrow.example"] },
|
||||
}, undefined, undefined, fakeContext());
|
||||
assert.deepEqual((bridgeOptions as any).allowedOrigins, ["https://narrow.example"]);
|
||||
// Narrowing is per-call; omitting settings later still uses the stored
|
||||
// wildcard policy and must not accidentally persist the narrower list.
|
||||
await observe.execute("observe", {
|
||||
sessionId: wildcard.details.sessionId,
|
||||
settings: {},
|
||||
}, undefined, undefined, fakeContext());
|
||||
assert.equal((bridgeOptions as any).allowedOrigins, "*");
|
||||
await close.execute("close", { sessionId: wildcard.details.sessionId }, undefined, undefined, fakeContext());
|
||||
|
||||
const restricted = await open.execute("open", {
|
||||
settings: { allowedOrigins: ["https://restricted.example"] },
|
||||
}, undefined, undefined, fakeContext());
|
||||
await assert.rejects(
|
||||
observe.execute("observe", {
|
||||
sessionId: restricted.details.sessionId,
|
||||
settings: { allowedOrigins: "*" },
|
||||
}, undefined, undefined, fakeContext()),
|
||||
/failed/,
|
||||
);
|
||||
await close.execute("close", { sessionId: restricted.details.sessionId }, undefined, undefined, fakeContext());
|
||||
});
|
||||
});
|
||||
|
||||
test("does not connect while the extension or command is registered", () => {
|
||||
let created = 0;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: (() => {
|
||||
created++;
|
||||
return bridgeFixture();
|
||||
}) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
assert.deepEqual(captured.commands, ["fast-browser"]);
|
||||
assert.equal(created, 0);
|
||||
});
|
||||
|
||||
test("opens an isolated launch session, reuses it by opaque id, and closes it", async () => {
|
||||
let manager: BrowserSessionManager;
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
createBrowserBridge: (() => bridgeFixture()) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
const { observe } = toolsOf(captured);
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const opened = await open.execute("open", {
|
||||
settings: {
|
||||
allowedOrigins: ["https://example.test", "https://other.test"],
|
||||
browser: { mode: "launch", product: "chromium", headless: true, profile: "evebot", startUrl: "https://example.test/login" },
|
||||
},
|
||||
}, undefined, undefined, fakeContext());
|
||||
const sessionId = opened.details.sessionId;
|
||||
assert.match(sessionId, /^session-/);
|
||||
assert.equal(opened.details.browser.mode, "launch");
|
||||
assert.equal(opened.details.browser.profile, "evebot");
|
||||
|
||||
const observed = await observe.execute("observe", { sessionId, settings: { allowedOrigins: ["https://other.test"] } }, undefined, undefined, fakeContext());
|
||||
assert.equal(observed.details.url, "https://example.test");
|
||||
|
||||
const closed = await close.execute("close", { sessionId }, undefined, undefined, fakeContext());
|
||||
assert.deepEqual(closed.details, { sessionId, closed: true });
|
||||
assert.throws(() => manager!.get(sessionId), /unknown session/);
|
||||
});
|
||||
});
|
||||
|
||||
test("closes an attached session lease without closing the external browser", async () => {
|
||||
let manager: BrowserSessionManager;
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const opened = await open.execute("open", { settings: { allowedOrigins: ["https://example.test"] } }, undefined, undefined, fakeContext());
|
||||
assert.equal(opened.details.owned, false);
|
||||
const sessionId = opened.details.sessionId;
|
||||
const closed = await close.execute("close", { sessionId }, undefined, undefined, fakeContext());
|
||||
assert.equal(closed.details.closed, true);
|
||||
assert.throws(() => manager!.get(sessionId), /unknown session/);
|
||||
});
|
||||
});
|
||||
|
||||
test("manual setup sessions remain explicitly closable", async () => {
|
||||
let manager: ReturnType<typeof sessionManagerFixture>;
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
});
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
const setup = await manager!.openSetup({} as any, new AbortController().signal);
|
||||
const result = await close.execute("close", { sessionId: setup.id }, undefined, undefined, fakeContext());
|
||||
assert.equal(result.details.closed, true);
|
||||
});
|
||||
|
||||
test("rejected session lane cleanup does not create an unhandled rejection", async () => {
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserRuntime: (() => ({
|
||||
async read() { throw new Error("expected read failure"); },
|
||||
async close() {},
|
||||
}) as unknown as BrowserRuntime) as FastBrowserAdapters["createBrowserRuntime"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const read = captured.tools.find((tool) => tool.name === "fast_browser_read")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const opened = await open.execute("open", { settings: { allowedOrigins: ["https://example.test"] } }, undefined, undefined, fakeContext());
|
||||
const unhandled: unknown[] = [];
|
||||
const listener = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", listener);
|
||||
try {
|
||||
await assert.rejects(read.execute("read", { sessionId: opened.details.sessionId }, undefined, undefined, fakeContext()), /fast_browser_read failed/);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
process.off("unhandledRejection", listener);
|
||||
await close.execute("close", { sessionId: opened.details.sessionId }, undefined, undefined, fakeContext());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("browser-exit cleanup failures remain actionable", async () => {
|
||||
let manager: ReturnType<typeof sessionManagerFixture>;
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
createBrowserRuntime: (() => ({
|
||||
sessionId: "unused",
|
||||
async close() { throw new Error("runtime cleanup failed"); },
|
||||
}) as unknown as BrowserRuntime) as FastBrowserAdapters["createBrowserRuntime"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const opened = await open.execute("open", { settings: { allowedOrigins: ["https://example.test"] } }, undefined, undefined, fakeContext());
|
||||
manager!.browserExit(opened.details.sessionId);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await assert.rejects(close.execute("close", { sessionId: opened.details.sessionId }, undefined, undefined, fakeContext()), /runtime cleanup failed/);
|
||||
});
|
||||
});
|
||||
|
||||
test("natural browser exit removes only that session runtime and store", async () => {
|
||||
let manager: ReturnType<typeof sessionManagerFixture>;
|
||||
const closed: string[] = [];
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
createBrowserRuntime: ((options) => ({
|
||||
sessionId: options.sessionId,
|
||||
async close() { closed.push(options.sessionId); },
|
||||
}) as unknown as BrowserRuntime) as FastBrowserAdapters["createBrowserRuntime"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const first = await open.execute("open", { settings: { allowedOrigins: ["https://example.test"] } }, undefined, undefined, fakeContext());
|
||||
const second = await open.execute("open", { settings: { allowedOrigins: ["https://example.test"] } }, undefined, undefined, fakeContext());
|
||||
manager!.browserExit(first.details.sessionId);
|
||||
await assert.doesNotReject(close.execute("close", { sessionId: first.details.sessionId }, undefined, undefined, fakeContext()));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(closed, [first.details.sessionId]);
|
||||
assert.throws(() => manager!.get(first.details.sessionId), /unknown session/);
|
||||
await close.execute("close", { sessionId: second.details.sessionId }, undefined, undefined, fakeContext());
|
||||
assert.deepEqual(closed, [first.details.sessionId, second.details.sessionId]);
|
||||
});
|
||||
});
|
||||
|
||||
test("closing one active session aborts only that session's run", async () => {
|
||||
let manager: BrowserSessionManager;
|
||||
let started!: () => void;
|
||||
const runStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
createBrowserBridge: (() => bridgeFixture()) as FastBrowserAdapters["createBrowserBridge"],
|
||||
runSteps: (async (_steps, _deps, options) => {
|
||||
started();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (options?.signal?.aborted) return resolve();
|
||||
options?.signal?.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
return { status: "stopped", reason: "session closed", steps: [], elapsedMs: 0 };
|
||||
}) as FastBrowserAdapters["runSteps"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
const { run } = toolsOf(captured);
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const settings = { allowedOrigins: ["https://example.test"], browser: { mode: "launch" as const, product: "chromium" as const, startUrl: "https://example.test/login" } };
|
||||
const first = await open.execute("open", { settings }, undefined, undefined, fakeContext());
|
||||
const second = await open.execute("open", { settings }, undefined, undefined, fakeContext());
|
||||
const runPromise = run.execute("run", { sessionId: first.details.sessionId, steps: [{ instruction: "Password", action: "fill", text: "known" }] }, undefined, undefined, fakeContext());
|
||||
await runStarted;
|
||||
await close.execute("close", { sessionId: first.details.sessionId }, undefined, undefined, fakeContext());
|
||||
const result = await runPromise;
|
||||
assert.equal(result.details.reason, "session closed");
|
||||
assert.doesNotThrow(() => manager!.get(second.details.sessionId));
|
||||
await close.execute("close", { sessionId: second.details.sessionId }, undefined, undefined, fakeContext());
|
||||
});
|
||||
});
|
||||
|
||||
test("guards overlap per session while allowing independent sessions to run", async () => {
|
||||
let manager: BrowserSessionManager;
|
||||
let startedCount = 0;
|
||||
let releaseStarted!: () => void;
|
||||
const startedPromise = new Promise<void>((resolve) => { releaseStarted = resolve; });
|
||||
const captured = registerWithAdapters({
|
||||
createSessionManager: (() => {
|
||||
manager = sessionManagerFixture();
|
||||
return manager;
|
||||
}) as FastBrowserAdapters["createSessionManager"],
|
||||
createBrowserBridge: (() => bridgeFixture()) as FastBrowserAdapters["createBrowserBridge"],
|
||||
runSteps: (async (_steps, _deps, options) => {
|
||||
startedCount++;
|
||||
if (startedCount === 2) releaseStarted();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (options?.signal?.aborted) return resolve();
|
||||
options?.signal?.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
return { status: "stopped", reason: "released", steps: [], elapsedMs: 0 };
|
||||
}) as FastBrowserAdapters["runSteps"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
const { run } = toolsOf(captured);
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const settings = { allowedOrigins: ["https://example.test"], browser: { mode: "launch" as const, product: "chromium" as const, startUrl: "https://example.test/login" } };
|
||||
const first = await open.execute("open", { settings }, undefined, undefined, fakeContext());
|
||||
const second = await open.execute("open", { settings }, undefined, undefined, fakeContext());
|
||||
const input = { steps: [{ instruction: "Password", action: "fill", text: "known" }] };
|
||||
const firstRun = run.execute("run", { ...input, sessionId: first.details.sessionId }, undefined, undefined, fakeContext());
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => startedCount > 0 ? resolve() : setImmediate(check);
|
||||
check();
|
||||
});
|
||||
const queuedFirstRun = run.execute("run", { ...input, sessionId: first.details.sessionId }, undefined, undefined, fakeContext());
|
||||
const secondRun = run.execute("run", { ...input, sessionId: second.details.sessionId }, undefined, undefined, fakeContext());
|
||||
await startedPromise;
|
||||
await close.execute("close", { sessionId: first.details.sessionId }, undefined, undefined, fakeContext());
|
||||
await close.execute("close", { sessionId: second.details.sessionId }, undefined, undefined, fakeContext());
|
||||
await Promise.all([firstRun, queuedFirstRun, secondRun]);
|
||||
assert.equal(startedCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects launch mode on the no-session legacy path", async () => {
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: (() => {
|
||||
throw new Error("bridge must not be created");
|
||||
}) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
const { observe } = toolsOf(captured);
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
await assert.rejects(
|
||||
observe.execute("observe", {
|
||||
settings: {
|
||||
allowedOrigins: ["https://example.test"],
|
||||
browser: { mode: "launch", product: "chromium", startUrl: "https://example.test/login" },
|
||||
},
|
||||
}, undefined, undefined, fakeContext()),
|
||||
/fast_browser_observe failed.*fast_browser_open/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("observe validates caller settings before spawning and forwards allowedOrigins", async () => {
|
||||
let created = 0;
|
||||
let bridgeOptions: unknown;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: ((options: unknown) => {
|
||||
created++;
|
||||
bridgeOptions = options;
|
||||
return bridgeFixture();
|
||||
}) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
const { observe } = toolsOf(captured);
|
||||
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const result = await observe.execute(
|
||||
"observe",
|
||||
{ settings: { allowedOrigins: ["https://caller-authorized.test"] } },
|
||||
AbortSignal.timeout(1000),
|
||||
undefined,
|
||||
fakeContext(),
|
||||
);
|
||||
assert.equal(result.details.url, "https://example.test");
|
||||
assert.deepEqual(bridgeOptions, {
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
allowedOrigins: ["https://caller-authorized.test"],
|
||||
});
|
||||
});
|
||||
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
await assert.rejects(
|
||||
observe.execute(
|
||||
"observe",
|
||||
{ settings: { allowedOrigins: ["https://caller-authorized.test/path"] } },
|
||||
undefined,
|
||||
undefined,
|
||||
fakeContext(),
|
||||
),
|
||||
/fast_browser_observe failed/,
|
||||
);
|
||||
});
|
||||
assert.equal(created, 1, "invalid settings spawned a bridge");
|
||||
});
|
||||
|
||||
test("runs a caller-authorized credential fill in print mode without UI approval", async () => {
|
||||
let actionRequest: any;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: ((() => bridgeFixture((request) => {
|
||||
actionRequest = request;
|
||||
})) as FastBrowserAdapters["createBrowserBridge"]),
|
||||
});
|
||||
const { run } = toolsOf(captured);
|
||||
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const result = await run.execute(
|
||||
"run",
|
||||
{
|
||||
task: "Fill the password on the caller-authorized test fixture",
|
||||
steps: [{ instruction: "Password", action: "fill", credential: "password" }],
|
||||
credentials: { password: "literal-secret" },
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
fakeContext(),
|
||||
);
|
||||
assert.equal(result.details.status, "completed");
|
||||
assert.equal(actionRequest.text, "literal-secret");
|
||||
// The resolved value is allowed only at the browser fill boundary. It is
|
||||
// absent from the compact result and tool content.
|
||||
assert.equal(JSON.stringify(result.details).includes("literal-secret"), false);
|
||||
assert.equal(result.content[0].text.includes("literal-secret"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves an environment credential reference only when its fill is selected", async () => {
|
||||
let actionRequest: any;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: (() => bridgeFixture((request) => {
|
||||
actionRequest = request;
|
||||
})) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
const { run } = toolsOf(captured);
|
||||
|
||||
await withEnvironment(
|
||||
{ ...configEnvironment(), FAST_BROWSER_TEST_PASSWORD: "env-secret" },
|
||||
async () => {
|
||||
const result = await run.execute(
|
||||
"run",
|
||||
{
|
||||
steps: [{ instruction: "Password", action: "fill", credential: "password" }],
|
||||
credentials: { password: { env: "FAST_BROWSER_TEST_PASSWORD" } },
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
fakeContext(),
|
||||
);
|
||||
assert.equal(result.details.status, "completed");
|
||||
assert.equal(actionRequest.text, "env-secret");
|
||||
assert.equal(result.content[0].text.includes("env-secret"), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("passes run budgets to the controller and keeps the exact-name path offline", async () => {
|
||||
let received: { steps: unknown; deps: any; options: any } | undefined;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: (() => bridgeFixture()) as FastBrowserAdapters["createBrowserBridge"],
|
||||
runSteps: (async (steps, deps, options) => {
|
||||
received = { steps, deps, options };
|
||||
return { status: "completed", reason: "fixture", steps: [], elapsedMs: 0 };
|
||||
}) as FastBrowserAdapters["runSteps"],
|
||||
});
|
||||
const { run } = toolsOf(captured);
|
||||
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
await run.execute(
|
||||
"run",
|
||||
{
|
||||
steps: [{ instruction: "Password", action: "fill", text: "known-value" }],
|
||||
budgets: { maxSteps: 3, timeoutMs: 3_000, minConfidence: 0.9 },
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
fakeContext(),
|
||||
);
|
||||
});
|
||||
assert.ok(received);
|
||||
assert.equal(received.options.maxSteps, 3);
|
||||
assert.equal(received.options.timeoutMs, 3_000);
|
||||
assert.equal(received.options.minConfidence, 0.9);
|
||||
assert.equal(typeof received.deps.resolveCredential, "function");
|
||||
});
|
||||
|
||||
test("an unavailable auxiliary model does not break literal or credential fills", async () => {
|
||||
let actionRequest: any;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: (() => bridgeFixture((request) => { actionRequest = request; })) as FastBrowserAdapters["createBrowserBridge"],
|
||||
});
|
||||
const { run } = toolsOf(captured);
|
||||
await withEnvironment({ ...configEnvironment(), FAST_BROWSER_TEXT_MODEL: "missing/model" }, async () => {
|
||||
const result = await run.execute("run", {
|
||||
steps: [{ instruction: "Password", action: "fill", credential: "password" }],
|
||||
credentials: { password: "literal-secret" },
|
||||
}, undefined, undefined, fakeContext());
|
||||
assert.equal(result.details.status, "completed");
|
||||
assert.equal(actionRequest.text, "literal-secret");
|
||||
});
|
||||
});
|
||||
|
||||
test("does not expose credential values through semantic decisions", async () => {
|
||||
let decisionInput: any;
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserBridge: (() => ({
|
||||
async observe() {
|
||||
return fixtureObservation({
|
||||
...fixtureCandidate("Password field"),
|
||||
id: "password-field",
|
||||
});
|
||||
},
|
||||
async act() {
|
||||
return { verified: true, evidence: "field value verified" };
|
||||
},
|
||||
async close() {},
|
||||
})) as FastBrowserAdapters["createBrowserBridge"],
|
||||
createJevDecider: (() => async (input: unknown) => {
|
||||
decisionInput = input;
|
||||
return { candidateId: "password-field", confidence: 1, model: "offline-fixture" };
|
||||
}) as unknown as FastBrowserAdapters["createJevDecider"],
|
||||
});
|
||||
const { run } = toolsOf(captured);
|
||||
|
||||
await withEnvironment({ ...configEnvironment(), TYPESAFE_API_KEY: "typesafe-fixture" }, async () => {
|
||||
const result = await run.execute(
|
||||
"run",
|
||||
{
|
||||
steps: [{ instruction: "enter the password", action: "fill", credential: "password" }],
|
||||
credentials: { password: "password-secret" },
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
fakeContext(),
|
||||
);
|
||||
assert.equal(result.details.status, "completed");
|
||||
});
|
||||
assert.ok(decisionInput);
|
||||
assert.equal(JSON.stringify(decisionInput).includes("password-secret"), false);
|
||||
});
|
||||
|
||||
test("assertion and control results expose only origin-safe tab projections", async () => {
|
||||
const secretUrl = "https://example.test/private?token=secret-token#fragment";
|
||||
const tab = { tabId: "tab-1" as never, kind: "page", selected: true, owned: true, url: secretUrl, origin: "https://example.test", title: "Fixture" };
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserRuntime: (() => ({
|
||||
async assert() { return { status: "satisfied", readOnly: true, tab, documentId: "doc-1", assertion: { value: secretUrl } }; },
|
||||
async navigate() { return { status: "committed", mutationState: "verified", tab, documentId: "doc-2", assertion: { value: secretUrl } }; },
|
||||
async reload() { return { status: "committed", mutationState: "verified", tab, documentId: "doc-3", assertion: { value: secretUrl } }; },
|
||||
async close() {},
|
||||
}) as unknown as BrowserRuntime) as FastBrowserAdapters["createBrowserRuntime"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
const assertTool = captured.tools.find((tool) => tool.name === "fast_browser_assert")!;
|
||||
const control = captured.tools.find((tool) => tool.name === "fast_browser_control")!;
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const opened = await open.execute("open", { settings: { allowedOrigins: ["https://example.test"] } }, undefined, undefined, fakeContext());
|
||||
const sessionId = opened.details.sessionId;
|
||||
const assertion = await assertTool.execute("assert", { sessionId, assertion: { kind: "url", match: "exact", value: secretUrl }, wait: "none" }, undefined, undefined, fakeContext());
|
||||
const navigate = await control.execute("control", { sessionId, operation: { op: "navigate", request: { url: "https://example.test/next?token=secret-token" } } }, undefined, undefined, fakeContext());
|
||||
const reload = await control.execute("control", { sessionId, operation: { op: "reload", request: {} } }, undefined, undefined, fakeContext());
|
||||
for (const result of [assertion.details, navigate.details, reload.details]) {
|
||||
const json = JSON.stringify(result);
|
||||
assert.equal(json.includes("secret-token"), false);
|
||||
assert.equal(json.includes("/private"), false);
|
||||
assert.equal(json.includes("#fragment"), false);
|
||||
assert.equal(result.tab.url, undefined);
|
||||
assert.equal(result.tab.origin, "https://example.test");
|
||||
}
|
||||
await close.execute("close", { sessionId }, undefined, undefined, fakeContext());
|
||||
});
|
||||
});
|
||||
|
||||
test("runtime-backed tools reuse one typed transport and preserve explicit tab targets", async () => {
|
||||
const calls: BrowserTransportRequest[] = [];
|
||||
const transport: BrowserTransport = {
|
||||
async dispatch<Operation extends BrowserOperation>(request: BrowserTransportRequest<Operation>): Promise<BrowserTransportResultFor<Operation>> {
|
||||
calls.push(request as BrowserTransportRequest);
|
||||
if (request.operation.op === "read") return {
|
||||
op: "read",
|
||||
result: {
|
||||
tab: { tabId: "tab-1" as never, kind: "page", selected: true, owned: true, url: "https://example.test/form", origin: "https://example.test", title: "Fixture" },
|
||||
documentId: "doc-1", url: "https://example.test/form", title: "Fixture", visibleText: "Visible", visibleTextTruncated: false,
|
||||
},
|
||||
} as unknown as BrowserTransportResultFor<Operation>;
|
||||
if (request.operation.op === "close") return { op: "close", result: { closed: true } } as unknown as BrowserTransportResultFor<Operation>;
|
||||
throw new Error("unexpected operation");
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
const captured = registerWithAdapters({
|
||||
createBrowserRuntime: ((options, parentSignal) => createBrowserRuntime(options, parentSignal, { create() { return transport; } })) as FastBrowserAdapters["createBrowserRuntime"],
|
||||
});
|
||||
const open = captured.tools.find((tool) => tool.name === "fast_browser_open")!;
|
||||
const read = captured.tools.find((tool) => tool.name === "fast_browser_read")!;
|
||||
const close = captured.tools.find((tool) => tool.name === "fast_browser_close")!;
|
||||
await withEnvironment(configEnvironment(), async () => {
|
||||
const opened = await open.execute("open", { settings: { allowedOrigins: ["https://example.test"] } }, undefined, undefined, fakeContext());
|
||||
const input = { sessionId: opened.details.sessionId, tabId: "tab-1" };
|
||||
const first = await read.execute("read", input, undefined, undefined, fakeContext());
|
||||
const second = await read.execute("read", input, undefined, undefined, fakeContext());
|
||||
assert.equal(first.details.visibleText, "Visible");
|
||||
assert.equal(second.details.documentId, "doc-1");
|
||||
assert.equal(calls.filter((call) => call.operation.op === "read").length, 2);
|
||||
assert.equal((calls[0]?.operation as any).op, "read");
|
||||
await close.execute("close", { sessionId: opened.details.sessionId }, undefined, undefined, fakeContext());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user