aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-27 00:48:45 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commit9f2ef1cf87251ce12f6b6432c48c6e7d2017b6b5 (patch)
tree2fad3353b016a9a35a049a7250b58ab2d165b50e
parentae3a6dcdbe584a9131a514ea3d738abaec6460f3 (diff)
refactor(cdp): make session.call generic over the command table
call/callOnTarget/callBrowser now take a known method and return its decoded result type. The session is where unknown ends; transport keeps returning unknown. Removes the casts in session.ts and target-factory.ts, including the session's own internal Target.*/Browser.* calls, which now decode through the same path as the public call methods. Also completes two params-schema gaps in commands.ts surfaced by this migration: Target.createTarget was missing openerId and Runtime.evaluate was missing userGesture, both real CDP fields src/cdp/ already sends. tsc is red project-wide (6 errors, all in src/domains/) — the sanctioned state between Tasks 4 and 7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--src/cdp/commands.ts8
-rw-r--r--src/cdp/session.ts127
-rw-r--r--src/cdp/target-factory.ts17
-rw-r--r--test/cdp/session-call-test.ts28
4 files changed, 114 insertions, 66 deletions
diff --git a/src/cdp/commands.ts b/src/cdp/commands.ts
index 123b9f6..d7faa34 100644
--- a/src/cdp/commands.ts
+++ b/src/cdp/commands.ts
@@ -57,6 +57,7 @@ export const COMMANDS = {
newWindow: Type.Optional(Type.Boolean()),
background: Type.Optional(Type.Boolean()),
windowId: Type.Optional(Type.Number()),
+ openerId: Type.Optional(Type.String()),
}),
Type.Object({ targetId: Type.String() }),
),
@@ -129,7 +130,12 @@ export const COMMANDS = {
// --- Runtime ---
"Runtime.evaluate": cmd(
Type.Object(
- { expression: Type.String(), returnByValue: Type.Optional(Type.Boolean()), awaitPromise: Type.Optional(Type.Boolean()) },
+ {
+ expression: Type.String(),
+ returnByValue: Type.Optional(Type.Boolean()),
+ awaitPromise: Type.Optional(Type.Boolean()),
+ userGesture: Type.Optional(Type.Boolean()),
+ },
{ additionalProperties: true },
),
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index eeb3802..96b980f 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -3,6 +3,7 @@ import type { CdpError } from "./errors";
import type { DialogInfo } from "./types";
import type { OwnershipRegistry } from "./ownership";
import type { CdpTransport } from "./transport";
+import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands";
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
@@ -36,9 +37,9 @@ export type CdpSession = {
resolveRef(ref: string): number | undefined;
/** The active tab's prior ref signatures — baseline for the post-mutation diff. */
refSignatures(): ReadonlyMap<string, string>;
- call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
- callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
- callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
+ call<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
+ callOnTarget<M extends CdpMethod>(method: M, params: ParamsOf<M>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
+ callBrowser<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
takeDialog(): DialogInfo | null;
drainPageInfoInvalidations(): boolean;
drainNetworkBuffer(filter: NetworkFilter): DrainResult;
@@ -179,22 +180,29 @@ export const createCdpSession = (
}
};
- // CDP response shapes are documented in chromedevtools.github.io but not
- // available as TypeScript types. We cast `as` from `unknown` only in this
- // file (the CDP boundary). Each cast is paired with the CDP method that
- // produced the response. Adding runtime guards for every shape would be
- // noise — Chrome's protocol is stable enough that a wrong cast surfaces
- // as a clear Error in normal use.
+ // Internal helper: the session's own CDP calls (attach/switch bookkeeping)
+ // go through the same decode-on-call path as the public call/callOnTarget/
+ // callBrowser — the session is where `unknown` ends, including for its own
+ // requests, not just the ones exposed to callers.
+ const req = async <M extends CdpMethod>(
+ method: M,
+ params: ParamsOf<M>,
+ sid: string | null,
+ ): Promise<Result<ResultOf<M>, CdpError>> => {
+ const raw = await transport.request(method, { ...params }, { sessionId: sid });
+ if (!raw.success) return raw;
+ return decodeResult(method, raw.data);
+ };
+
return {
async attachFirstPage() {
// Subscribe to Target.* events so we can react to targetDestroyed.
// Best-effort: failing to enable discovery is not fatal for attach.
- await transport.request("Target.setDiscoverTargets", { discover: true }, { sessionId: null });
+ await req("Target.setDiscoverTargets", { discover: true }, null);
- const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
+ const targets = await req("Target.getTargets", {}, null);
if (!targets.success) return targets;
- const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
- const allPages = data.targetInfos.filter((t) => t.type === "page");
+ const allPages = targets.data.targetInfos.filter((t) => t.type === "page");
// Reconcile the persisted ownership set against live targets — drop dead IDs.
if (ownership) {
const live = new Set(allPages.map((p) => p.targetId));
@@ -217,25 +225,23 @@ export const createCdpSession = (
// persisted integer (which Chrome may reassign to a user window on
// restart — anchoring on a survivor makes that misfire impossible).
const anchorId = survivors[0]!;
- const anchorWin = await transport.request("Browser.getWindowForTarget", { targetId: anchorId }, { sessionId: null });
+ const anchorWin = await req("Browser.getWindowForTarget", { targetId: anchorId }, null);
if (anchorWin.success) {
- const anchorWindowId = (anchorWin.data as { windowId?: number }).windowId;
- if (typeof anchorWindowId === "number") {
- ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id
- const windows = await Promise.all(
- allPages.map((p) =>
- p.targetId === anchorId
- ? Promise.resolve(anchorWin)
- : transport.request("Browser.getWindowForTarget", { targetId: p.targetId }, { sessionId: null }),
- ),
- );
- allPages.forEach((p, i) => {
- const r = windows[i];
- if (r?.success && (r.data as { windowId?: number }).windowId === anchorWindowId) {
- ownership.add(p.targetId);
- }
- });
- }
+ const anchorWindowId = anchorWin.data.windowId;
+ ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id
+ const windows = await Promise.all(
+ allPages.map((p) =>
+ p.targetId === anchorId
+ ? Promise.resolve(anchorWin)
+ : req("Browser.getWindowForTarget", { targetId: p.targetId }, null),
+ ),
+ );
+ allPages.forEach((p, i) => {
+ const r = windows[i];
+ if (r?.success && r.data.windowId === anchorWindowId) {
+ ownership.add(p.targetId);
+ }
+ });
}
if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
}
@@ -266,28 +272,26 @@ export const createCdpSession = (
pickTargetId = seeded.data;
}
if (!pickTargetId) {
- const createParams: Record<string, unknown> = { url: "about:blank" };
- if (ownership) createParams["newWindow"] = true;
- const created = await transport.request("Target.createTarget", createParams, { sessionId: null });
+ const created = await req(
+ "Target.createTarget",
+ { url: "about:blank", ...(ownership ? { newWindow: true } : {}) },
+ null,
+ );
if (!created.success) return created;
- const c = created.data as { targetId: string };
- pickTargetId = c.targetId;
+ pickTargetId = created.data.targetId;
if (ownership) {
- ownership.setHarnessWindow(c.targetId);
- ownership.add(c.targetId);
+ ownership.setHarnessWindow(created.data.targetId);
+ ownership.add(created.data.targetId);
// Capture the real Chrome windowId — the durable identity of the
// window this session initialized (survives seed-tab closure).
- const win = await transport.request("Browser.getWindowForTarget", { targetId: c.targetId }, { sessionId: null });
- if (win.success) {
- const wid = (win.data as { windowId?: number }).windowId;
- if (typeof wid === "number") ownership.setHarnessWindowId(wid);
- }
+ const win = await req("Browser.getWindowForTarget", { targetId: created.data.targetId }, null);
+ if (win.success) ownership.setHarnessWindowId(win.data.windowId);
}
}
- const attached = await transport.request("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, { sessionId: null });
+ const attached = await req("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, null);
if (!attached.success) return attached;
- const a = attached.data as { sessionId: string };
+ const a = attached.data;
sessionId = a.sessionId;
targetId = pickTargetId;
await enableDomains(a.sessionId);
@@ -305,11 +309,11 @@ export const createCdpSession = (
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
},
async switchTo(tid) {
- const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
+ const activated = await req("Target.activateTarget", { targetId: tid }, null);
if (!activated.success) return activated;
- const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
+ const attached = await req("Target.attachToTarget", { targetId: tid, flatten: true }, null);
if (!attached.success) return attached;
- const a = attached.data as { sessionId: string };
+ const a = attached.data;
// Reuse existing TabSession or create one on first visit
const existing = tabs.get(tid);
const tab: TabSession = existing ?? {
@@ -354,14 +358,29 @@ export const createCdpSession = (
const tab = targetId ? tabs.get(targetId) : undefined;
return tab?.refSig ?? new Map();
},
- call(method, params = {}, opts = {}) {
- return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
+ async call(method, params, opts = {}) {
+ const raw = await transport.request(method, { ...(params ?? {}) }, {
+ sessionId,
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
+ });
+ if (!raw.success) return raw;
+ return decodeResult(method, raw.data);
},
- callOnTarget(method, params, sid, opts = {}) {
- return transport.request(method, params, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
+ async callOnTarget(method, params, sid, opts = {}) {
+ const raw = await transport.request(method, { ...params }, {
+ sessionId: sid,
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
+ });
+ if (!raw.success) return raw;
+ return decodeResult(method, raw.data);
},
- callBrowser(method, params = {}, opts = {}) {
- return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
+ async callBrowser(method, params, opts = {}) {
+ const raw = await transport.request(method, { ...(params ?? {}) }, {
+ sessionId: null,
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
+ });
+ if (!raw.success) return raw;
+ return decodeResult(method, raw.data);
},
takeDialog() {
const tab = targetId ? tabs.get(targetId) : undefined;
diff --git a/src/cdp/target-factory.ts b/src/cdp/target-factory.ts
index 25c43a6..fb22339 100644
--- a/src/cdp/target-factory.ts
+++ b/src/cdp/target-factory.ts
@@ -56,8 +56,7 @@ type PageTarget = {
const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
const r = await client.session().callBrowser("Target.getTargets");
if (!r.success) return r;
- const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
- return ok(data.targetInfos.filter((t) => t.type === "page"));
+ return ok(r.data.targetInfos.filter((t) => t.type === "page"));
};
/** A CDP session id for driving `targetId`, reusing the active one when possible. */
@@ -66,7 +65,7 @@ const sessionForTarget = async (client: BrowserClient, targetId: string): Promis
if (current?.targetId === targetId) return ok(current.sessionId);
const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
if (!attached.success) return attached;
- return ok((attached.data as { sessionId: string }).sessionId);
+ return ok(attached.data.sessionId);
};
/**
@@ -92,8 +91,7 @@ const spawnTabViaOpener = async (
session.data,
);
if (!opened.success) return opened;
- const result = opened.data as { result?: { value?: unknown } };
- if (result.result?.value !== true) {
+ if (opened.data.result.value !== true) {
return err(cdpError(
"invalid_response",
"the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
@@ -187,15 +185,12 @@ const ensureHarnessWindowUncoordinated = async (
const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
if (!created.success) return created;
- const { targetId } = created.data as { targetId: string };
+ const { targetId } = created.data;
const ownership = client.ownership();
ownership.setHarnessWindow(targetId);
ownership.add(targetId);
const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
- if (win.success) {
- const windowId = (win.data as { windowId?: number }).windowId;
- if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
- }
+ if (win.success) ownership.setHarnessWindowId(win.data.windowId);
return ok(targetId);
};
@@ -215,7 +210,7 @@ export const openHarnessTab = async (
openerId: openerTargetId,
});
if (!created.success) return created;
- return ok((created.data as { targetId: string }).targetId);
+ return ok(created.data.targetId);
})();
if (!spawned.success) return spawned;
client.ownership().add(spawned.data);
diff --git a/test/cdp/session-call-test.ts b/test/cdp/session-call-test.ts
new file mode 100644
index 0000000..b318eb4
--- /dev/null
+++ b/test/cdp/session-call-test.ts
@@ -0,0 +1,28 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { decodeResult } from "../../src/cdp/commands";
+
+describe("typed call decoding", () => {
+ test("a valid transport payload decodes to typed data", () => {
+ const fromTransport: unknown = { sessionId: "abc123" };
+ const r = decodeResult("Target.attachToTarget", fromTransport);
+ assert.equal(r.success, true);
+ if (r.success) {
+ const sid: string = r.data.sessionId;
+ assert.equal(sid, "abc123");
+ }
+ });
+
+ test("a garbage transport payload becomes invalid_response", () => {
+ const r = decodeResult("Target.attachToTarget", { sessionId: 42 });
+ assert.equal(r.success, false);
+ if (!r.success) assert.equal(r.error.kind, "invalid_response");
+ });
+
+ test("extra Chrome fields on a result are tolerated", () => {
+ const r = decodeResult("Target.getTargetInfo", {
+ targetInfo: { targetId: "t", type: "page", title: "T", url: "u", futureField: 1 },
+ });
+ assert.equal(r.success, true);
+ });
+});