219 lines
13 KiB
TypeScript
219 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { createBrowserRuntimeFactory, withRuntimeOriginPolicy } from "./runtime.ts";
|
|
import type {
|
|
BrowserOperation,
|
|
BrowserRuntimeOptions,
|
|
BrowserTransport,
|
|
BrowserTransportRequest,
|
|
BrowserTransportResultFor,
|
|
PrivateArtifactMetadata,
|
|
PrivateArtifactStore,
|
|
RuntimeOperationContext,
|
|
} from "./contracts.ts";
|
|
|
|
const tab = (id: string, url = "https://example.test/form") => ({
|
|
tabId: id as never, kind: "page" as const, selected: true, owned: true as const,
|
|
url, origin: new URL(url).origin, title: "Fixture",
|
|
});
|
|
|
|
function artifacts(): { store: PrivateArtifactStore; committed: PrivateArtifactMetadata[] } {
|
|
const committed: PrivateArtifactMetadata[] = [];
|
|
const store: PrivateArtifactStore = {
|
|
async open(owner, kind) {
|
|
let bytes = 0;
|
|
let done = false;
|
|
return {
|
|
owner,
|
|
kind,
|
|
async write(chunk) { if (done) throw new Error("closed"); bytes += chunk.byteLength; },
|
|
async commit() {
|
|
done = true;
|
|
const metadata: PrivateArtifactMetadata = {
|
|
artifactId: `${kind}-1`, kind,
|
|
contentType: kind === "screenshot" ? "image/png" : "application/json",
|
|
path: `/private/${kind}-1`, bytes, owner,
|
|
permissions: "0600", localOnly: true, modelForwarded: false,
|
|
};
|
|
committed.push(metadata);
|
|
return metadata;
|
|
},
|
|
async abort() { done = true; },
|
|
};
|
|
},
|
|
async close() {},
|
|
};
|
|
return { store, committed };
|
|
}
|
|
|
|
function transportFixture() {
|
|
const calls: BrowserTransportRequest[] = [];
|
|
let transportClosed = 0;
|
|
const transport: BrowserTransport = {
|
|
async dispatch<Operation extends BrowserOperation>(request: BrowserTransportRequest<Operation>, signal: AbortSignal): Promise<BrowserTransportResultFor<Operation>> {
|
|
calls.push(request as BrowserTransportRequest);
|
|
const op = request.operation;
|
|
if (op.op === "observe") return { op: "observe", result: { observationId: "o1", url: "https://example.test/form", title: "Fixture", candidates: [] } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "read") return { op: "read", result: { tab: tab("tab-1"), documentId: "doc-1", url: "https://example.test/form", title: "Fixture", visibleText: "Hello", visibleTextTruncated: false } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "listTabs") return { op: "listTabs", result: [tab("tab-1"), tab("tab-2", "https://other.test/")] } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "navigate") return { op: "navigate", result: { status: "committed", mutationState: "verified", tab: tab("tab-1", op.request.url), documentId: "doc-2" } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "narrowOriginPolicy") return { op: "narrowOriginPolicy", result: { allowedOrigins: op.request.allowedOrigins } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "captureEvidence") return { op: "captureEvidence", result: {
|
|
status: "complete", console: [{ level: "error", message: "password=secret https://example.test/?token=secret", count: 1, truncated: false }], failedRequests: [],
|
|
screenshot: { kind: "screenshot", contentType: "image/png", encoding: "base64", dataBase64: "aGk=", byteLength: 2, truncated: false },
|
|
} } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "close") return { op: "close", result: { closed: true } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "assert") return { op: "assert", result: { status: "satisfied", readOnly: true, tab: tab("tab-1"), documentId: "doc-1" } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "reload") return { op: "reload", result: { status: "committed", mutationState: "verified", tab: tab("tab-1"), documentId: "doc-2" } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "selectTab") return { op: "selectTab", result: { selected: true, tab: tab(op.request.tabId) } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "waitPopup") return { op: "waitPopup", result: { status: "timed_out", autoSwitched: false } } as unknown as BrowserTransportResultFor<Operation>;
|
|
if (op.op === "closeTab") return { op: "closeTab", result: { closed: true, tabId: op.request.tabId, mutationState: "verified" } } as unknown as BrowserTransportResultFor<Operation>;
|
|
throw new Error("unexpected operation");
|
|
},
|
|
async close() { transportClosed++; },
|
|
};
|
|
return { transport, calls, transportClosed: () => transportClosed };
|
|
}
|
|
|
|
function options(artifactsStore: PrivateArtifactStore, transport: BrowserTransport): BrowserRuntimeOptions {
|
|
return { sessionId: "session-1", cdpUrl: "http://127.0.0.1:9222", allowedOrigins: "*", artifacts: artifactsStore,
|
|
transportFactory: { create() { return transport; } } };
|
|
}
|
|
|
|
test("factory creates one persistent transport and serializes same-session calls", async () => {
|
|
const store = artifacts();
|
|
const fixture = transportFixture();
|
|
const runtime = createBrowserRuntimeFactory(options(store.store, fixture.transport).transportFactory).create(options(store.store, fixture.transport));
|
|
await Promise.all([runtime.observe(new AbortController().signal), runtime.read({}, new AbortController().signal)]);
|
|
assert.equal(fixture.calls.filter((call) => call.operation.op === "observe" || call.operation.op === "read").length, 2);
|
|
assert.deepEqual(Object.keys(fixture.calls[0] ?? {}).sort(), ["operation"]);
|
|
assert.equal((fixture.calls[0]?.operation as any).op, "observe");
|
|
assert.equal((fixture.calls[0] as any)?.op, undefined);
|
|
assert.equal(fixture.calls[1]?.operation.op, "read");
|
|
await runtime.close();
|
|
await runtime.close();
|
|
assert.equal(fixture.transportClosed(), 1);
|
|
});
|
|
|
|
test("aborted operations still make an independent bounded evidence attempt", async () => {
|
|
const store = artifacts(); const fixture = transportFixture();
|
|
const runtime = createBrowserRuntimeFactory({ create() { return fixture.transport; } }).create(options(store.store, fixture.transport));
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("operation deadline exceeded"));
|
|
await assert.rejects(runtime.observe(controller.signal), (error: any) => {
|
|
assert.equal(error.details.code, "aborted");
|
|
assert.deepEqual(error.failureEvidence, { status: "complete" });
|
|
return true;
|
|
});
|
|
assert.equal(fixture.calls.filter((call) => call.operation.op === "captureEvidence").length, 1);
|
|
await runtime.close();
|
|
});
|
|
|
|
test("already-aborted parent does not create transport during failure evidence", async () => {
|
|
const store = artifacts();
|
|
const parent = new AbortController();
|
|
parent.abort(new Error("browser session closed"));
|
|
let created = 0;
|
|
const runtime = createBrowserRuntimeFactory({ create() { created++; return transportFixture().transport; } }).create({ ...options(store.store, transportFixture().transport) }, parent.signal);
|
|
await assert.rejects(runtime.observe(new AbortController().signal), (error: any) => {
|
|
assert.equal(error.details.code, "browser_closed");
|
|
assert.deepEqual(error.failureEvidence, { status: "unavailable", reason: "browser session is unavailable" });
|
|
return true;
|
|
});
|
|
assert.equal(created, 0);
|
|
await runtime.close();
|
|
});
|
|
|
|
test("destroyed transports report unavailable evidence without replacing the failure", async () => {
|
|
const store = artifacts();
|
|
const transport: BrowserTransport = {
|
|
async dispatch<Operation extends BrowserOperation>(request: BrowserTransportRequest<Operation>): Promise<BrowserTransportResultFor<Operation>> {
|
|
if (request.operation.op === "captureEvidence") throw new Error("browser bridge is closed");
|
|
throw new Error("browser worker request timed out; worker was torn down");
|
|
},
|
|
async close() {},
|
|
};
|
|
const runtime = createBrowserRuntimeFactory({ create() { return transport; } }).create(options(store.store, transport));
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("operation deadline exceeded"));
|
|
await assert.rejects(runtime.observe(controller.signal), (error: any) => {
|
|
assert.equal(error.details.code, "aborted");
|
|
assert.deepEqual(error.failureEvidence, { status: "unavailable", reason: "browser session is unavailable" });
|
|
return true;
|
|
});
|
|
await runtime.close();
|
|
});
|
|
|
|
test("factory forwards pinned Python selection to the transport", async () => {
|
|
const store = artifacts(); const fixture = transportFixture();
|
|
let received: unknown;
|
|
const runtime = createBrowserRuntimeFactory({ create(transportOptions) { received = transportOptions; return fixture.transport; } }).create({ ...options(store.store, fixture.transport), python: "/tmp/pinned-python" });
|
|
await runtime.observe(new AbortController().signal);
|
|
assert.equal((received as any).python, "/tmp/pinned-python");
|
|
await runtime.close();
|
|
});
|
|
|
|
test("wildcard narrowing is scoped while restricted bases cannot broaden", async () => {
|
|
const first = artifacts();
|
|
const fixture = transportFixture();
|
|
const runtime = createBrowserRuntimeFactory(options(first.store, fixture.transport).transportFactory).create(options(first.store, fixture.transport));
|
|
await withRuntimeOriginPolicy(runtime, ["https://example.test"], (context) => runtime.read({}, new AbortController().signal, context));
|
|
const reads = fixture.calls.filter((call) => call.operation.op === "read");
|
|
assert.deepEqual((reads[0]?.context as RuntimeOperationContext).allowedOrigins, ["https://example.test"]);
|
|
await runtime.read({}, new AbortController().signal);
|
|
assert.equal(fixture.calls.at(-1)?.context, undefined);
|
|
const restricted = createBrowserRuntimeFactory({ create() { return fixture.transport; } }).create({ ...options(first.store, fixture.transport), allowedOrigins: ["https://example.test"] });
|
|
await assert.rejects(restricted.narrowOriginPolicy({ allowedOrigins: "*" }, new AbortController().signal), /broadened/);
|
|
await runtime.close(); await restricted.close();
|
|
});
|
|
|
|
test("policy is enforced before navigation and tabs are origin filtered", async () => {
|
|
const store = artifacts(); const fixture = transportFixture();
|
|
const runtime = createBrowserRuntimeFactory(options(store.store, fixture.transport).transportFactory).create(options(store.store, fixture.transport));
|
|
const context = await runtime.narrowOriginPolicy({ allowedOrigins: ["https://example.test"] }, new AbortController().signal);
|
|
await assert.rejects(runtime.navigate({ url: "https://other.test/out" }, new AbortController().signal, context), (error: any) => error.details.code === "policy_denied");
|
|
const restricted = createBrowserRuntimeFactory({ create() { return fixture.transport; } }).create({ ...options(store.store, fixture.transport), allowedOrigins: ["https://example.test"] });
|
|
await assert.rejects(restricted.navigate({ url: "https://example.test/out" }, new AbortController().signal, { allowedOrigins: "*" }), (error: any) => {
|
|
assert.equal(error.details.code, "policy_denied");
|
|
assert.equal(error.details.mutationState, "not_attempted");
|
|
assert.equal(error.details.retry, "never");
|
|
return true;
|
|
});
|
|
const tabs = await runtime.listTabs(new AbortController().signal, context);
|
|
assert.deepEqual(tabs.map((item) => item.origin), ["https://example.test"]);
|
|
await runtime.close();
|
|
await restricted.close();
|
|
});
|
|
|
|
test("requested evidence commits local metadata and redacts untrusted console text", async () => {
|
|
const store = artifacts(); const fixture = transportFixture();
|
|
const runtime = createBrowserRuntimeFactory(options(store.store, fixture.transport).transportFactory).create(options(store.store, fixture.transport));
|
|
const result = await runtime.captureEvidence({ runId: "run-1", reason: "requested" }, new AbortController().signal);
|
|
assert.equal(result.screenshot?.bytes, 2);
|
|
assert.equal(result.screenshot?.modelForwarded, false);
|
|
assert.equal(result.console[0]?.message.includes("secret"), false);
|
|
assert.equal(store.committed.length, 1);
|
|
await runtime.close();
|
|
});
|
|
|
|
test("transport failures become safe errors and are not mutation retried", async () => {
|
|
const store = artifacts(); let actions = 0;
|
|
const transport: BrowserTransport = {
|
|
async dispatch<Operation extends BrowserOperation>(request: BrowserTransportRequest<Operation>): Promise<BrowserTransportResultFor<Operation>> {
|
|
actions++;
|
|
if (request.operation.op === "captureEvidence") return { op: "captureEvidence", result: { status: "unavailable", console: [], failedRequests: [] } } as unknown as BrowserTransportResultFor<Operation>;
|
|
throw new Error("private page secret and mutation may have run");
|
|
},
|
|
async close() {},
|
|
};
|
|
const runtime = createBrowserRuntimeFactory({ create() { return transport; } }).create(options(store.store, transport));
|
|
await assert.rejects(runtime.act({ observationId: "o", candidateId: "c", action: "click", postcondition: { kind: "text", value: "done" } }, new AbortController().signal), (error: any) => {
|
|
assert.equal(error.details.code, "worker_failure");
|
|
assert.equal(error.details.mutationState, "unknown");
|
|
assert.equal(String(error).includes("private"), false);
|
|
return true;
|
|
});
|
|
assert.equal(actions, 2, "one action dispatch plus one best-effort evidence request; no mutation retry");
|
|
await runtime.close();
|
|
});
|