190 lines
8.3 KiB
TypeScript
190 lines
8.3 KiB
TypeScript
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);
|
|
});
|