diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 19:40:45 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | d3fb25a8e1297f00dbf7ce4a188b12290b594c73 (patch) | |
| tree | bfe2b8f0611306e01cde3225afe6144937b867f6 /test | |
| parent | 0fa62d8177f2216b95739e3d36f87fc1a95886c5 (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>
Diffstat (limited to 'test')
| -rw-r--r-- | test/domains/fake-client-test.ts | 34 | ||||
| -rw-r--r-- | test/domains/fake-client.ts | 187 |
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, +}); |
