aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-28 19:40:45 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commitd3fb25a8e1297f00dbf7ce4a188b12290b594c73 (patch)
treebfe2b8f0611306e01cde3225afe6144937b867f6
parent0fa62d8177f2216b95739e3d36f87fc1a95886c5 (diff)
test: fake browser client over a stub transport
Wires a real CdpSession to a canned-response transport so domain tools can be driven without Chrome. Unconfigured client methods return an error rather than a success, so a test cannot pass by accident. Report: docs/superpowers/reports/2026-07-26-bug-audit.md#f30 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--test/domains/fake-client-test.ts34
-rw-r--r--test/domains/fake-client.ts187
2 files changed, 221 insertions, 0 deletions
diff --git a/test/domains/fake-client-test.ts b/test/domains/fake-client-test.ts
new file mode 100644
index 0000000..7b0cb37
--- /dev/null
+++ b/test/domains/fake-client-test.ts
@@ -0,0 +1,34 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { createFakeClient } from "./fake-client";
+import { ok } from "../../src/util/result";
+
+describe("fake browser client", () => {
+ test("attaches through the real session and reports a current tab", async () => {
+ const fake = await createFakeClient();
+ assert.deepEqual(fake.client.current(), { targetId: "t1", sessionId: "s1" });
+ assert.equal(fake.client.owns("t1"), true);
+ });
+
+ test("routes typed calls through the real decoder, so a bad payload fails", async () => {
+ const fake = await createFakeClient({
+ canned: { "DOM.getBoxModel": ok({ model: { width: 10 } }) },
+ });
+ const r = await fake.session.call("DOM.getBoxModel", { backendNodeId: 3 });
+ assert.equal(r.success, false);
+ if (!r.success) assert.equal(r.error.kind, "invalid_response");
+ });
+
+ test("unconfigured client methods fail loudly instead of returning success", async () => {
+ const fake = await createFakeClient();
+ const r = await fake.client.evaluateJs("1");
+ assert.equal(r.success, false);
+ if (!r.success) assert.match(r.error.message, /not configured/);
+ });
+
+ test("seeded refs resolve through the real ref map", async () => {
+ const fake = await createFakeClient({ refs: [{ ref: "e1", backendId: 42 }] });
+ assert.equal(fake.session.resolveRef("e1"), 42);
+ assert.equal(fake.session.resolveRef("e2"), undefined);
+ });
+});
diff --git a/test/domains/fake-client.ts b/test/domains/fake-client.ts
new file mode 100644
index 0000000..6bf2e54
--- /dev/null
+++ b/test/domains/fake-client.ts
@@ -0,0 +1,187 @@
+import { createCdpSession, type CdpSession } from "../../src/cdp/session";
+import { createOwnershipRegistry, type OwnershipRegistry } from "../../src/cdp/ownership";
+import { type CdpError, cdpError } from "../../src/cdp/errors";
+import type { CdpTransport } from "../../src/cdp/transport";
+import type { CdpEvent } from "../../src/cdp/types";
+import type { BrowserClient } from "../../src/client";
+import { createMutex } from "../../src/util/mutex";
+import { type Result, err, ok } from "../../src/util/result";
+
+export type RecordedCall = {
+ readonly method: string;
+ readonly params: Record<string, unknown>;
+ readonly sessionId: string | null | undefined;
+};
+
+export type CannedResult = Result<unknown, CdpError>;
+
+export type Canned = Record<string, CannedResult | ReadonlyArray<CannedResult>>;
+
+export type StubTransport = {
+ readonly transport: CdpTransport;
+ readonly calls: RecordedCall[];
+ readonly emit: (event: CdpEvent) => void;
+ readonly closeQueue: () => void;
+};
+
+const DEFAULT_ATTACH: Canned = {
+ "Target.setDiscoverTargets": ok({}),
+ "Target.getTargets": ok({
+ targetInfos: [{ targetId: "t1", type: "page", title: "Fake", url: "https://example.test/" }],
+ }),
+ "Target.attachToTarget": ok({ sessionId: "s1" }),
+ "Browser.getWindowForTarget": ok({ windowId: 7 }),
+};
+
+export const createStubTransport = (canned: Canned = {}): StubTransport => {
+ const calls: RecordedCall[] = [];
+ const queues = new Map<string, CannedResult[]>();
+ for (const [method, value] of Object.entries(canned)) {
+ queues.set(method, Array.isArray(value) ? [...value] : [value]);
+ }
+
+ const buffered: CdpEvent[] = [];
+ const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = [];
+ let ended = false;
+
+ const next = (method: string): CannedResult => {
+ const queue = queues.get(method);
+ if (queue === undefined || queue.length === 0) return ok({});
+ const head = queue.length === 1 ? queue[0] : queue.shift();
+ return head ?? ok({});
+ };
+
+ const transport: CdpTransport = {
+ connect: () => Promise.resolve(ok(undefined)),
+ close: () => Promise.resolve(),
+ request: (method, params, opts) => {
+ calls.push({ method, params, sessionId: opts?.sessionId });
+ return Promise.resolve(next(method));
+ },
+ events: (): AsyncIterable<CdpEvent> => ({
+ [Symbol.asyncIterator]: (): AsyncIterator<CdpEvent, undefined, undefined> => ({
+ next: (): Promise<IteratorResult<CdpEvent, undefined>> =>
+ new Promise((resolve) => {
+ const head = buffered.shift();
+ if (head !== undefined) resolve({ value: head, done: false });
+ else if (ended) resolve({ value: undefined, done: true });
+ else waiters.push(resolve);
+ }),
+ }),
+ }),
+ state: () => "open",
+ onClose: () => () => {},
+ };
+
+ return {
+ transport,
+ calls,
+ emit: (event) => {
+ const waiter = waiters.shift();
+ if (waiter) waiter({ value: event, done: false });
+ else buffered.push(event);
+ },
+ closeQueue: () => {
+ ended = true;
+ for (const w of waiters.splice(0)) w({ value: undefined, done: true });
+ },
+ };
+};
+
+export type FakeClientOptions = {
+ readonly canned?: Canned;
+ readonly evaluate?: (expression: string, sessionId?: string) => Result<unknown, CdpError>;
+ readonly refs?: ReadonlyArray<{ readonly ref: string; readonly backendId: number; readonly sig?: string }>;
+ readonly ownedTargetIds?: ReadonlyArray<string>;
+};
+
+export type FakeClient = {
+ readonly client: BrowserClient;
+ readonly session: CdpSession;
+ readonly ownership: OwnershipRegistry;
+ readonly calls: RecordedCall[];
+ readonly emit: (event: CdpEvent) => void;
+ readonly callsTo: (method: string) => ReadonlyArray<RecordedCall>;
+ readonly evaluated: string[];
+};
+
+const notConfigured = <T>(what: string): Result<T, CdpError> =>
+ err(cdpError("invalid_response", `fake client: ${what} was called but not configured`));
+
+export const createFakeClient = async (opts: FakeClientOptions = {}): Promise<FakeClient> => {
+ const stub = createStubTransport({ ...DEFAULT_ATTACH, ...(opts.canned ?? {}) });
+ const ownership = createOwnershipRegistry({ ownedTargetIds: opts.ownedTargetIds ?? ["t1"] });
+ const session = createCdpSession(stub.transport, ownership);
+ const mutex = createMutex();
+ const evaluated: string[] = [];
+
+ const attached = await session.attachFirstPage();
+ if (!attached.success) throw new Error(`fake client could not attach: ${attached.error.message}`);
+ stub.calls.length = 0;
+
+ if (opts.refs !== undefined) {
+ const refMap = new Map<string, number>();
+ const refSig = new Map<string, string>();
+ for (const r of opts.refs) {
+ refMap.set(r.ref, r.backendId);
+ refSig.set(r.ref, r.sig ?? `textbox|${r.ref}||`);
+ }
+ session.setRefMap(refMap, refSig);
+ }
+
+ const client: BrowserClient = {
+ namespace: "fake",
+ ensureAlive: () => Promise.resolve(ok(undefined)),
+ status: () => ({ alive: true, sessionId: attached.data.sessionId, namespace: "fake" }),
+ start: () => Promise.resolve(ok(undefined)),
+ stop: () => Promise.resolve(),
+ detach: () => Promise.resolve(),
+ closeOwnedTabs: () => Promise.resolve(),
+ evaluateJs: (expression, sessionId) => {
+ evaluated.push(expression);
+ const run = opts.evaluate;
+ if (run === undefined) return Promise.resolve(notConfigured<unknown>("evaluateJs"));
+ return Promise.resolve(sessionId === undefined ? run(expression) : run(expression, sessionId));
+ },
+ pageInfo: () => Promise.resolve(notConfigured("pageInfo")),
+ takeDialog: () => session.takeDialog(),
+ listTabs: () => Promise.resolve(notConfigured("listTabs")),
+ switchTab: () => Promise.resolve(notConfigured("switchTab")),
+ newTab: () => Promise.resolve(notConfigured("newTab")),
+ closeTab: () => Promise.resolve(ok(undefined)),
+ owns: (id) => ownership.has(id),
+ ownership: () => ownership,
+ current: () => session.current(),
+ session: () => session,
+ transport: () => stub.transport,
+ userDataDir: () => undefined,
+ profilePin: () => null,
+ setProfilePin: () => {},
+ profileContextId: () => undefined,
+ setProfileContextId: () => {},
+ seedPinnedProfileWindow: () => Promise.resolve(notConfigured("seedPinnedProfileWindow")),
+ mutationMutex: () => mutex,
+ };
+
+ return {
+ client,
+ session,
+ ownership,
+ calls: stub.calls,
+ emit: stub.emit,
+ callsTo: (method) => stub.calls.filter((c) => c.method === method),
+ evaluated,
+ };
+};
+
+export const axNode = (
+ nodeId: string,
+ role: string,
+ name: string,
+ extra: Readonly<Record<string, unknown>> = {},
+): Readonly<Record<string, unknown>> => ({
+ nodeId,
+ role: { value: role },
+ name: { value: name },
+ ...extra,
+});