194 lines
10 KiB
TypeScript
194 lines
10 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import { test } from "node:test";
|
|
import { createBrowserTransport, type ChildProcessLike, type SpawnLike } from "./bridge.ts";
|
|
import { BrowserOperationError } from "./contracts.ts";
|
|
import type { BrowserOperation } from "./contracts.ts";
|
|
|
|
class TransportWorker 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;
|
|
respond(request: Record<string, unknown>): void {
|
|
if (this.silent) return;
|
|
const operation = (request.operation as Record<string, unknown> | undefined)?.op ?? request.op;
|
|
const tab = { tabId: "tab-1", kind: "page", selected: true, owned: true, url: "http://fixture.test/form", origin: "http://fixture.test", title: "Fixture" };
|
|
let result: unknown;
|
|
if (operation === "observe") result = { observationId: "obs-1", url: tab.url, title: tab.title, candidates: [] };
|
|
else if (operation === "read") result = { tab, documentId: "doc-1", url: tab.url, title: tab.title, visibleText: "Fixture complete", visibleTextTruncated: false };
|
|
else if (operation === "listTabs") result = [tab];
|
|
else if (operation === "narrowOriginPolicy") result = (request.operation as Record<string, unknown>).request;
|
|
else if (operation === "captureEvidence") result = { status: "complete", console: [{ level: "error", message: "fixture failed", count: 1, truncated: false }], failedRequests: [] };
|
|
else if (operation === "close") result = { closed: true };
|
|
else result = { status: "committed", mutationState: "verified", tab, documentId: "doc-2" };
|
|
this.stdout.emit("data", JSON.stringify({ id: request.id, ok: true, result: { op: operation, result } }) + "\n");
|
|
}
|
|
kill(): boolean {
|
|
this.killed = true;
|
|
this.emit("close", null, "SIGTERM");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function spawnFor(worker: TransportWorker): SpawnLike {
|
|
return (_command, _args, _options) => worker;
|
|
}
|
|
|
|
const base = { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["http://fixture.test"] as const };
|
|
const signal = () => new AbortController().signal;
|
|
|
|
test("typed transport reuses one worker and carries scoped context without broadening", async () => {
|
|
const worker = new TransportWorker();
|
|
const transport = createBrowserTransport({ ...base, spawn: spawnFor(worker) });
|
|
try {
|
|
const narrowed = await transport.dispatch({ operation: { op: "narrowOriginPolicy", request: { allowedOrigins: ["http://fixture.test"] } } }, signal());
|
|
assert.deepEqual(narrowed, { op: "narrowOriginPolicy", result: { allowedOrigins: ["http://fixture.test"] } });
|
|
const read = await transport.dispatch({ operation: { op: "read", request: {} }, context: narrowed.result }, signal());
|
|
assert.equal(read.op, "read");
|
|
assert.equal(read.result.visibleText, "Fixture complete");
|
|
const tabs = await transport.dispatch({ operation: { op: "listTabs" } }, signal());
|
|
assert.equal(tabs.result[0]?.tabId, "tab-1");
|
|
assert.equal(worker.requests.length, 3);
|
|
assert.deepEqual(worker.requests[0]?.operation, { op: "narrowOriginPolicy", request: { allowedOrigins: ["http://fixture.test"] } });
|
|
assert.deepEqual(worker.requests[1]?.operation, { op: "read", request: {} });
|
|
assert.deepEqual(worker.requests[1]?.context, { allowedOrigins: ["http://fixture.test"] });
|
|
await assert.rejects(
|
|
transport.dispatch({ operation: { op: "read", request: {} }, context: { allowedOrigins: "*" } }, signal()),
|
|
(error: unknown) => error instanceof BrowserOperationError && error.details.code === "invalid_request" && error.details.mutationState === "not_attempted",
|
|
);
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
});
|
|
|
|
test("typed transport rejects malformed and oversized operation results safely", async () => {
|
|
const worker = new TransportWorker();
|
|
worker.respond = (request) => {
|
|
if ((request.operation as Record<string, unknown> | undefined)?.op === "read") {
|
|
worker.stdout.emit("data", JSON.stringify({ id: request.id, ok: true, result: { op: "read", result: { tab: {}, documentId: "d", url: "http://fixture.test/form", title: "x", visibleText: "x", visibleTextTruncated: false } } }) + "\n");
|
|
return;
|
|
}
|
|
worker.stdout.emit("data", `${JSON.stringify({ id: request.id, ok: true, result: { closed: true } })}\n`);
|
|
};
|
|
const transport = createBrowserTransport({ ...base, spawn: spawnFor(worker) });
|
|
try {
|
|
await assert.rejects(transport.dispatch({ operation: { op: "read", request: {} } }, signal()), (error: unknown) => {
|
|
return error instanceof BrowserOperationError && error.details.code === "worker_failure";
|
|
});
|
|
assert.equal(worker.killed, true);
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
|
|
const oversized = new TransportWorker();
|
|
oversized.respond = (request) => {
|
|
oversized.stdout.emit("data", `${"x".repeat(128 * 1024 + 1)}\n`);
|
|
};
|
|
const oversizedTransport = createBrowserTransport({ ...base, spawn: spawnFor(oversized) });
|
|
try {
|
|
await assert.rejects(oversizedTransport.dispatch({ operation: { op: "observe" } }, signal()), (error: unknown) => error instanceof BrowserOperationError && error.details.code === "worker_failure");
|
|
assert.equal(oversized.killed, true);
|
|
} finally {
|
|
await oversizedTransport.close();
|
|
}
|
|
});
|
|
|
|
test("transport preserves validated structured worker errors without raw exceptions", async () => {
|
|
const worker = new TransportWorker();
|
|
let failed = true;
|
|
worker.respond = (request) => {
|
|
if (failed) {
|
|
failed = false;
|
|
worker.stdout.emit("data", JSON.stringify({ id: request.id, ok: false, error: "safe worker message", details: {
|
|
code: "stale", operation: "act", phase: "verify", message: "browser target is stale", mutationState: "not_attempted", retry: "never",
|
|
} }) + "\n");
|
|
return;
|
|
}
|
|
worker.stdout.emit("data", JSON.stringify({ id: request.id, ok: true, result: { op: "observe", result: { observationId: "obs-2", url: "http://fixture.test/form", title: "Fixture", candidates: [] } } }) + "\n");
|
|
};
|
|
const transport = createBrowserTransport({ ...base, spawn: spawnFor(worker) });
|
|
try {
|
|
await assert.rejects(transport.dispatch({ operation: { op: "act", request: { observationId: "obs", candidateId: "c", action: "fill", text: "x" } } }, signal()), (error: unknown) => {
|
|
return error instanceof BrowserOperationError && error.details.code === "stale" && error.details.mutationState === "not_attempted" && error.details.message === "browser target is stale";
|
|
});
|
|
assert.equal(worker.killed, false);
|
|
const observation = await transport.dispatch({ operation: { op: "observe" } }, signal());
|
|
assert.equal(observation.result.observationId, "obs-2");
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
});
|
|
|
|
test("transport cancellation tears down the persistent worker without retry", async () => {
|
|
const worker = new TransportWorker();
|
|
worker.silent = true;
|
|
const transport = createBrowserTransport({ ...base, spawn: spawnFor(worker), requestTimeoutMs: 500 });
|
|
const controller = new AbortController();
|
|
const operation: BrowserOperation = { op: "navigate", request: { url: "http://fixture.test/form" } };
|
|
const pending = transport.dispatch({ operation }, controller.signal);
|
|
await Promise.resolve();
|
|
controller.abort();
|
|
await assert.rejects(pending, (error: unknown) => error instanceof BrowserOperationError && error.details.code === "aborted" && error.details.mutationState === "unknown");
|
|
assert.equal(worker.requests.length, 1);
|
|
assert.equal(worker.killed, true);
|
|
await transport.close();
|
|
});
|
|
|
|
test("queued cancellation is safe and never writes a second request", async () => {
|
|
const worker = new TransportWorker();
|
|
worker.silent = true;
|
|
const transport = createBrowserTransport({ ...base, spawn: spawnFor(worker), requestTimeoutMs: 500 });
|
|
const first = transport.dispatch({ operation: { op: "observe" } }, new AbortController().signal);
|
|
await Promise.resolve();
|
|
const queuedController = new AbortController();
|
|
const queued = transport.dispatch({ operation: { op: "navigate", request: { url: "http://fixture.test/form" } } }, queuedController.signal);
|
|
queuedController.abort();
|
|
worker.silent = false;
|
|
worker.respond(worker.requests[0]!);
|
|
await first;
|
|
await assert.rejects(queued, (error: unknown) => error instanceof BrowserOperationError
|
|
&& error.details.code === "aborted"
|
|
&& error.details.mutationState === "not_attempted"
|
|
&& error.details.retry === "never");
|
|
assert.equal(worker.requests.length, 1);
|
|
await transport.close();
|
|
});
|
|
|
|
test("unknown runtime operations fail before any worker dispatch", async () => {
|
|
const worker = new TransportWorker();
|
|
const transport = createBrowserTransport({ ...base, spawn: spawnFor(worker) });
|
|
try {
|
|
await assert.rejects(transport.dispatch({ operation: { op: "notAnOperation" } as never }, signal()), (error: unknown) => error instanceof BrowserOperationError
|
|
&& error.details.code === "invalid_request"
|
|
&& error.details.mutationState === "not_attempted");
|
|
assert.equal(worker.requests.length, 0);
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
});
|
|
|
|
test("parent cancellation tears down the worker even without a call signal abort", async () => {
|
|
const worker = new TransportWorker();
|
|
worker.silent = true;
|
|
const parent = new AbortController();
|
|
const transport = createBrowserTransport({ ...base, spawn: spawnFor(worker), requestTimeoutMs: 500 }, parent.signal);
|
|
const pending = transport.dispatch({ operation: { op: "observe" } }, new AbortController().signal);
|
|
await Promise.resolve();
|
|
parent.abort();
|
|
await assert.rejects(pending, (error: unknown) => error instanceof BrowserOperationError && error.details.code === "aborted");
|
|
assert.equal(worker.killed, true);
|
|
await transport.close();
|
|
});
|