fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { test } from "node:test";
|
||||
import { createBrowserBridge, createBrowserTransport, type ChildProcessLike, type SpawnLike } from "./bridge.ts";
|
||||
import { BrowserOperationError } from "./contracts.ts";
|
||||
|
||||
class FakeWorker extends EventEmitter implements ChildProcessLike {
|
||||
readonly requests: Record<string, unknown>[] = [];
|
||||
readonly stdin = {
|
||||
write: (line: string): boolean => {
|
||||
const request = JSON.parse(line) as Record<string, unknown>;
|
||||
this.requests.push(request);
|
||||
queueMicrotask(() => this.respond(request));
|
||||
return true;
|
||||
},
|
||||
end: (): void => undefined,
|
||||
once: (event: string, listener: (...args: any[]) => void): unknown => this.once(event, listener),
|
||||
};
|
||||
readonly stdout = new EventEmitter() as EventEmitter & { setEncoding?: (encoding: string) => void };
|
||||
readonly stderr = new EventEmitter();
|
||||
killed = false;
|
||||
silent = false;
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true;
|
||||
this.emit("close", null, "SIGTERM");
|
||||
return true;
|
||||
}
|
||||
|
||||
respond(request: Record<string, unknown>): void {
|
||||
if (this.silent) return;
|
||||
const operation = request.operation as Record<string, unknown> | undefined;
|
||||
const op = operation?.op ?? request.op;
|
||||
const result = op === "observe"
|
||||
? {
|
||||
observationId: "obs-1",
|
||||
url: "http://fixture.test/form",
|
||||
title: "Fixture",
|
||||
candidates: [{ id: "c-0", role: "textbox", name: "Email", actions: ["fill"] }],
|
||||
}
|
||||
: op === "act"
|
||||
? { verified: true, evidence: "field value verified" }
|
||||
: { closed: true };
|
||||
this.stdout.emit("data", JSON.stringify({ id: request.id, ok: true, result: { op, result } }) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
function makeSpawn(worker: FakeWorker, seen: { command?: string; args?: readonly string[]; env?: NodeJS.ProcessEnv }): SpawnLike {
|
||||
return (command, args, options) => {
|
||||
seen.command = command;
|
||||
seen.args = args;
|
||||
seen.env = options.env;
|
||||
return worker;
|
||||
};
|
||||
}
|
||||
|
||||
const base = { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["http://fixture.test"] };
|
||||
|
||||
test("uses one persistent worker and validates observe/fill output", async () => {
|
||||
const worker = new FakeWorker();
|
||||
const seen: { command?: string; args?: readonly string[]; env?: NodeJS.ProcessEnv } = {};
|
||||
const bridge = createBrowserBridge({ ...base, python: "/fixture/python", spawn: makeSpawn(worker, seen) });
|
||||
|
||||
const observation = await bridge.observe(new AbortController().signal);
|
||||
assert.equal(observation.url, "http://fixture.test/form");
|
||||
assert.equal(observation.candidates[0]?.name, "Email");
|
||||
const result = await bridge.act({
|
||||
observationId: observation.observationId,
|
||||
candidateId: "c-0",
|
||||
action: "fill",
|
||||
text: "literal@example.test",
|
||||
}, new AbortController().signal);
|
||||
assert.equal(result.verified, true);
|
||||
assert.equal(seen.command, "/fixture/python");
|
||||
assert.match(String(seen.args?.[0]), /browser_worker\.py$/);
|
||||
assert.equal(seen.env?.BH_TELEMETRY, "0");
|
||||
assert.equal(seen.env?.BROWSER_USE_API_KEY, undefined);
|
||||
assert.equal(worker.requests.length, 2);
|
||||
await bridge.close();
|
||||
assert.equal((worker.requests.at(-1)?.operation as Record<string, unknown>)?.op, "close");
|
||||
});
|
||||
|
||||
test("unrestricted policy accepts any HTTP(S) observation and URL postcondition", async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.respond = (request) => {
|
||||
const result = ((request.operation as Record<string, unknown> | undefined)?.op ?? request.op) === "observe"
|
||||
? { observationId: "obs-1", url: "https://other.example/path", title: "Other", candidates: [] }
|
||||
: { verified: true, evidence: "URL postcondition verified" };
|
||||
worker.stdout.emit("data", JSON.stringify({ id: request.id, ok: true, result: { op: ((request.operation as Record<string, unknown> | undefined)?.op ?? request.op), result } }) + "\n");
|
||||
};
|
||||
const bridge = createBrowserBridge({ ...base, allowedOrigins: "*", spawn: makeSpawn(worker, {}) });
|
||||
const observation = await bridge.observe(new AbortController().signal);
|
||||
assert.equal(observation.url, "https://other.example/path");
|
||||
await bridge.act({
|
||||
observationId: observation.observationId,
|
||||
candidateId: "c-0",
|
||||
action: "click",
|
||||
postcondition: { kind: "url", value: "https://different.example/next" },
|
||||
}, new AbortController().signal);
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
test("does not forward model/provider credentials to the worker", async () => {
|
||||
const names = ["TYPESAFE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"];
|
||||
const previous = new Map(names.map((name) => [name, process.env[name]]));
|
||||
for (const name of names) process.env[name] = "should-not-cross-process-boundary";
|
||||
try {
|
||||
const worker = new FakeWorker();
|
||||
const seen: { command?: string; args?: readonly string[]; env?: NodeJS.ProcessEnv } = {};
|
||||
const bridge = createBrowserBridge({ ...base, spawn: makeSpawn(worker, seen) });
|
||||
assert.equal(seen.env?.TYPESAFE_API_KEY, undefined);
|
||||
assert.equal(seen.env?.OPENAI_API_KEY, undefined);
|
||||
assert.equal(seen.env?.ANTHROPIC_API_KEY, undefined);
|
||||
assert.equal(seen.env?.BH_TELEMETRY, "0");
|
||||
await bridge.close();
|
||||
} finally {
|
||||
for (const name of names) {
|
||||
const value = previous.get(name);
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-loopback endpoints and click actions without postconditions before spawning", async () => {
|
||||
const worker = new FakeWorker();
|
||||
assert.throws(() => createBrowserBridge({ ...base, cdpUrl: "https://remote.test:9222", spawn: makeSpawn(worker, {}) }), /loopback/);
|
||||
const bridge = createBrowserBridge({ ...base, spawn: makeSpawn(worker, {}) });
|
||||
assert.throws(() => bridge.act({
|
||||
observationId: "obs-1", candidateId: "c-0", action: "click",
|
||||
}, new AbortController().signal), /postcondition/);
|
||||
assert.equal(worker.requests.length, 0);
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
test("aborting an in-flight request tears down without retrying", async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.silent = true;
|
||||
const bridge = createBrowserBridge({ ...base, spawn: makeSpawn(worker, {}), requestTimeoutMs: 500 });
|
||||
const controller = new AbortController();
|
||||
const request = bridge.observe(controller.signal);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
await assert.rejects(request, (error: Error) => error.name === "AbortError");
|
||||
assert.equal(worker.requests.length, 1);
|
||||
assert.equal(worker.killed, true);
|
||||
});
|
||||
|
||||
test("malformed worker failure envelope rejects without escaping the event handler", async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.respond = (request) => worker.stdout.emit("data", JSON.stringify({ id: request.id, ok: false, error: 42 }) + "\n");
|
||||
const bridge = createBrowserBridge({ ...base, spawn: makeSpawn(worker, {}) });
|
||||
await assert.rejects(bridge.observe(new AbortController().signal), /invalid result envelope/);
|
||||
assert.equal(worker.killed, true);
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
test("duplicate candidate handles and extra fields terminate the worker", async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.respond = (request) => worker.stdout.emit("data", JSON.stringify({ id: request.id, ok: true, result: { op: "observe", result: {
|
||||
observationId: "obs-1", url: "http://fixture.test/form", title: "Fixture",
|
||||
candidates: Array(2).fill({ id: "same", role: "textbox", name: "Email", actions: ["fill"] }),
|
||||
} } }) + "\n");
|
||||
const bridge = createBrowserBridge({ ...base, spawn: makeSpawn(worker, {}) });
|
||||
await assert.rejects(bridge.observe(new AbortController().signal), /duplicate/);
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
test("close waits for actual child exit before releasing the browser lane", async () => {
|
||||
const worker = new FakeWorker();
|
||||
let exited = false;
|
||||
worker.kill = () => {
|
||||
setTimeout(() => { exited = true; worker.emit("close", null, "SIGTERM"); }, 10);
|
||||
return true;
|
||||
};
|
||||
const bridge = createBrowserBridge({ ...base, spawn: makeSpawn(worker, {}) });
|
||||
await bridge.observe(new AbortController().signal);
|
||||
await bridge.close();
|
||||
assert.equal(exited, true);
|
||||
});
|
||||
|
||||
test("worker timeout tears down instead of replaying an action", async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.silent = true;
|
||||
const bridge = createBrowserBridge({ ...base, spawn: makeSpawn(worker, {}), requestTimeoutMs: 50 });
|
||||
await assert.rejects(bridge.observe(new AbortController().signal), /timed out/);
|
||||
assert.equal(worker.requests.length, 1);
|
||||
assert.equal(worker.killed, true);
|
||||
});
|
||||
Reference in New Issue
Block a user