cdp-toolkit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * network_mock.ts — a per-target FAKE BACKEND for the page: intercept matching
3
+ * requests via the CDP Fetch domain and fulfill them with a canned response,
4
+ * fail (abort) them, or let them continue (optionally with fault injection).
5
+ * The rod `HijackRequests` capability the read-only console/network tools lack.
6
+ *
7
+ * This file is split into two layers:
8
+ * 1. PURE logic (urlMatches / selectRule / buildFulfillParams / effectiveAction)
9
+ * — no browser, unit-tested in test/network_mock.test.ts.
10
+ * 2. STATEFUL tools (mockRequest / listMocks / clearMocks) — hold a persistent
11
+ * CDP connection per target; integration-tested in test/mock-smoke.ts.
12
+ */
13
+ import { CdpError, openPage, resolveTarget } from "../client.ts";
14
+ import type { CdpConnection } from "../client.ts";
15
+ import type { Target, TargetSelector } from "../types.ts";
16
+
17
+ /* ============================================================================
18
+ * LAYER 1 — pure logic (no I/O)
19
+ * ========================================================================== */
20
+
21
+ export type MockAction = "fulfill" | "fail" | "continue";
22
+
23
+ /** Escape a single character for literal use inside a RegExp. */
24
+ function escapeRegExpChar(ch: string): string {
25
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
26
+ }
27
+
28
+ /**
29
+ * Convert a CDP Fetch `urlPattern` glob to an anchored RegExp.
30
+ * CDP semantics: `*` = zero or more chars, `?` = exactly one, `\` escapes the
31
+ * next char. Everything else is literal.
32
+ */
33
+ function globToRegExp(pattern: string): RegExp {
34
+ let re = "";
35
+ for (let i = 0; i < pattern.length; i++) {
36
+ const ch = pattern[i]!;
37
+ if (ch === "\\") {
38
+ const next = pattern[i + 1];
39
+ if (next !== undefined) {
40
+ re += escapeRegExpChar(next);
41
+ i++;
42
+ } else {
43
+ re += "\\\\";
44
+ }
45
+ } else if (ch === "*") {
46
+ re += ".*";
47
+ } else if (ch === "?") {
48
+ re += ".";
49
+ } else {
50
+ re += escapeRegExpChar(ch);
51
+ }
52
+ }
53
+ return new RegExp(`^${re}$`);
54
+ }
55
+
56
+ /** True if `url` matches the CDP urlPattern glob `pattern`. */
57
+ export function urlMatches(pattern: string, url: string): boolean {
58
+ return globToRegExp(pattern).test(url);
59
+ }
60
+
61
+ /** First rule whose pattern matches `url` and whose method filter (if any) matches. */
62
+ export function selectRule<T extends { urlPattern: string; method?: string }>(
63
+ rules: T[],
64
+ url: string,
65
+ method: string,
66
+ ): T | undefined {
67
+ return rules.find(
68
+ (r) => urlMatches(r.urlPattern, url) && (!r.method || r.method.toUpperCase() === method.toUpperCase()),
69
+ );
70
+ }
71
+
72
+ /** CDP Fetch.fulfillRequest params. */
73
+ export interface FulfillParams {
74
+ requestId: string;
75
+ responseCode: number;
76
+ responseHeaders: Array<{ name: string; value: string }>;
77
+ /** base64-encoded body (CDP requires base64). */
78
+ body: string;
79
+ }
80
+
81
+ /**
82
+ * Build CDP `Fetch.fulfillRequest` params from a rule: status, headers
83
+ * (Content-Type default, overridable by custom headers, case-insensitively),
84
+ * and a base64-encoded body.
85
+ */
86
+ export function buildFulfillParams(
87
+ requestId: string,
88
+ rule: { status?: number; body?: string; contentType?: string; headers?: Record<string, string> },
89
+ ): FulfillParams {
90
+ const merged = new Map<string, { name: string; value: string }>();
91
+ merged.set("content-type", { name: "Content-Type", value: rule.contentType ?? "application/json" });
92
+ for (const [name, value] of Object.entries(rule.headers ?? {})) {
93
+ merged.set(name.toLowerCase(), { name, value });
94
+ }
95
+ return {
96
+ requestId,
97
+ responseCode: rule.status ?? 200,
98
+ responseHeaders: [...merged.values()],
99
+ body: Buffer.from(rule.body ?? "", "utf8").toString("base64"),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Resolve the action to take, applying fault injection: if `failRate` is set and
105
+ * the injected roll `rnd` (0..1) falls under it, force a "fail"; otherwise the
106
+ * rule's configured action.
107
+ */
108
+ export function effectiveAction(rule: { action: MockAction; failRate?: number }, rnd: number): MockAction {
109
+ if (rule.failRate !== undefined && rule.failRate > 0 && rnd < rule.failRate) return "fail";
110
+ return rule.action;
111
+ }
112
+
113
+ /* ============================================================================
114
+ * LAYER 2 — stateful tools (persistent per-target CDP connection)
115
+ *
116
+ * A "fake backend" is a per-target SESSION holding ONE persistent CDP connection
117
+ * with the Fetch domain enabled. Each session carries a list of rules; an
118
+ * incoming request that matches a pattern is paused (Fetch.requestPaused) and we
119
+ * fulfill / fail / continue it. The session survives navigations and reloads on
120
+ * the same target and lives until clear_mocks (or the tab closes).
121
+ *
122
+ * Persistence works because the MCP server process is long-lived across tool
123
+ * calls, so this module-level `sessions` map and its open connections outlive a
124
+ * single call. (The stateless CLI process exits after one command, so persistent
125
+ * mocking is an MCP-server capability — like the recorder's background mode.)
126
+ *
127
+ * SAFETY: every paused request MUST be answered or the renderer hangs. A request
128
+ * that pauses but matches no rule is continued untouched.
129
+ * ========================================================================== */
130
+
131
+ interface MockRule {
132
+ urlPattern: string;
133
+ action: MockAction;
134
+ status?: number;
135
+ body?: string;
136
+ contentType?: string;
137
+ headers?: Record<string, string>;
138
+ errorReason?: string;
139
+ method?: string;
140
+ delayMs?: number;
141
+ failRate?: number;
142
+ hits: number;
143
+ }
144
+
145
+ interface MockSession {
146
+ conn: CdpConnection;
147
+ target: Target;
148
+ rules: MockRule[];
149
+ intercepted: Array<{ url: string; method: string; action: MockAction }>;
150
+ chain: Promise<void>;
151
+ dead: boolean;
152
+ }
153
+
154
+ /** Per-target fake-backend sessions, keyed by targetId. Persists across calls in the MCP server process. */
155
+ const sessions = new Map<string, MockSession>();
156
+
157
+ function delay(ms: number): Promise<void> {
158
+ return new Promise((r) => setTimeout(r, ms));
159
+ }
160
+
161
+ /** Wire the Fetch.requestPaused handler (serialized so Fetch.disable waits for in-flight answers). */
162
+ function registerHandler(session: MockSession): void {
163
+ session.conn.on("Fetch.requestPaused", (params) => {
164
+ const p = params as { requestId: string; request: { url: string; method: string } };
165
+ session.chain = session.chain
166
+ .then(async () => {
167
+ const rule = selectRule(session.rules, p.request.url, p.request.method);
168
+ if (!rule) {
169
+ await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
170
+ return;
171
+ }
172
+ if (rule.delayMs) await delay(rule.delayMs);
173
+ const act = effectiveAction(rule, Math.random());
174
+ if (act === "fulfill") {
175
+ await session.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
176
+ } else if (act === "fail") {
177
+ await session.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
178
+ } else {
179
+ await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
180
+ }
181
+ rule.hits++;
182
+ session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
183
+ })
184
+ .catch((e) => {
185
+ // Tab navigated away mid-flight or the connection dropped — best-effort.
186
+ if (/closed|connection|Target|Inspected/i.test(String(e))) session.dead = true;
187
+ });
188
+ });
189
+ }
190
+
191
+ export interface MockRequestArgs {
192
+ target?: TargetSelector;
193
+ /** CDP Fetch urlPattern glob (`*` = any run, `?` = one char). Only matching URLs pause. */
194
+ urlPattern: string;
195
+ /** What to do with a matched request (default "fulfill"). */
196
+ action?: MockAction;
197
+ /** fulfill: HTTP status (default 200). */
198
+ status?: number;
199
+ /** fulfill: response body string. */
200
+ body?: string;
201
+ /** fulfill: Content-Type (default "application/json"). */
202
+ contentType?: string;
203
+ /** fulfill: extra response headers (override Content-Type case-insensitively). */
204
+ headers?: Record<string, string>;
205
+ /** fail: CDP Network.ErrorReason (default "Failed"), e.g. "BlockedByClient". */
206
+ errorReason?: string;
207
+ /** Only mock requests with this HTTP method; others pass through. */
208
+ method?: string;
209
+ /** Fault injection: artificial latency before responding, ms. */
210
+ delayMs?: number;
211
+ /** Fault injection: probability 0..1 of failing a matched request regardless of action. */
212
+ failRate?: number;
213
+ /** Reload the target after arming so the mock immediately catches traffic. */
214
+ reload?: boolean;
215
+ }
216
+
217
+ export interface MockRequestResult {
218
+ target: { id: string; url: string; title: string };
219
+ pattern: string;
220
+ ruleCount: number;
221
+ reloaded: boolean;
222
+ }
223
+
224
+ /**
225
+ * Register (or update) a mock rule on a target's fake-backend session, creating
226
+ * the persistent session on first use. Pass `reload:true` to apply it right away.
227
+ */
228
+ export async function mockRequest(args: MockRequestArgs): Promise<MockRequestResult> {
229
+ if (!args.urlPattern) throw new CdpError("mock_request requires a { urlPattern }");
230
+ const rule: MockRule = {
231
+ urlPattern: args.urlPattern,
232
+ action: args.action ?? "fulfill",
233
+ status: args.status,
234
+ body: args.body,
235
+ contentType: args.contentType,
236
+ headers: args.headers,
237
+ errorReason: args.errorReason,
238
+ method: args.method,
239
+ delayMs: args.delayMs,
240
+ failRate: args.failRate,
241
+ hits: 0,
242
+ };
243
+
244
+ const resolved = await resolveTarget(args.target);
245
+ let session = sessions.get(resolved.id);
246
+
247
+ if (session && !session.dead) {
248
+ // Replace a same-pattern+method rule, else append; then refresh the patterns.
249
+ const idx = session.rules.findIndex(
250
+ (r) => r.urlPattern === rule.urlPattern && (r.method ?? "") === (rule.method ?? ""),
251
+ );
252
+ if (idx >= 0) session.rules[idx] = rule;
253
+ else session.rules.push(rule);
254
+ await session.conn.send("Fetch.enable", { patterns: session.rules.map((r) => ({ urlPattern: r.urlPattern })) });
255
+ } else {
256
+ const { conn, target } = await openPage(resolved.id);
257
+ session = { conn, target, rules: [rule], intercepted: [], chain: Promise.resolve(), dead: false };
258
+ registerHandler(session);
259
+ await conn.send("Fetch.enable", { patterns: [{ urlPattern: rule.urlPattern }] });
260
+ sessions.set(target.id, session);
261
+ }
262
+
263
+ let reloaded = false;
264
+ if (args.reload) {
265
+ await session.conn.send("Page.enable");
266
+ await session.conn.send("Page.reload", { ignoreCache: true });
267
+ await delay(800);
268
+ reloaded = true;
269
+ }
270
+
271
+ return {
272
+ target: { id: session.target.id, url: session.target.url, title: session.target.title },
273
+ pattern: rule.urlPattern,
274
+ ruleCount: session.rules.length,
275
+ reloaded,
276
+ };
277
+ }
278
+
279
+ export interface ListMocksResult {
280
+ count: number;
281
+ mocks: Array<{
282
+ target: { id: string; url: string; title: string };
283
+ rules: Array<{ urlPattern: string; action: MockAction; method?: string; hits: number }>;
284
+ hits: number;
285
+ }>;
286
+ }
287
+
288
+ /** List active fake-backend sessions and their rules/hit counts. Prunes dead sessions. */
289
+ export async function listMocks(_args: { target?: TargetSelector } = {}): Promise<ListMocksResult> {
290
+ // Probe liveness cheaply; a closed tab's connection rejects immediately.
291
+ for (const [id, s] of sessions) {
292
+ if (s.dead) {
293
+ try {
294
+ s.conn.close();
295
+ } catch {
296
+ /* ignore */
297
+ }
298
+ sessions.delete(id);
299
+ continue;
300
+ }
301
+ try {
302
+ await s.conn.send("Runtime.evaluate", { expression: "1", returnByValue: true }, { timeoutMs: 3000 });
303
+ } catch {
304
+ try {
305
+ s.conn.close();
306
+ } catch {
307
+ /* ignore */
308
+ }
309
+ sessions.delete(id);
310
+ }
311
+ }
312
+ const mocks = [...sessions.values()].map((s) => ({
313
+ target: { id: s.target.id, url: s.target.url, title: s.target.title },
314
+ rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method, hits: r.hits })),
315
+ hits: s.intercepted.length,
316
+ }));
317
+ return { count: mocks.length, mocks };
318
+ }
319
+
320
+ export interface ClearMocksArgs {
321
+ target?: TargetSelector;
322
+ /** Clear every active session instead of just the resolved target's. */
323
+ all?: boolean;
324
+ }
325
+
326
+ /** Tear down fake-backend sessions: Fetch.disable + close the connection. */
327
+ export async function clearMocks(args: ClearMocksArgs = {}): Promise<{ cleared: number }> {
328
+ let ids: string[];
329
+ if (args.all) {
330
+ ids = [...sessions.keys()];
331
+ } else {
332
+ const resolved = await resolveTarget(args.target);
333
+ ids = sessions.has(resolved.id) ? [resolved.id] : [];
334
+ }
335
+ let cleared = 0;
336
+ for (const id of ids) {
337
+ const s = sessions.get(id);
338
+ if (!s) continue;
339
+ try {
340
+ await s.conn.send("Fetch.disable");
341
+ } catch {
342
+ /* tab may be gone */
343
+ }
344
+ try {
345
+ s.conn.close();
346
+ } catch {
347
+ /* ignore */
348
+ }
349
+ sessions.delete(id);
350
+ cleared++;
351
+ }
352
+ return { cleared };
353
+ }
354
+
355
+ /* ------------------------------------------------------------------------------
356
+ * CDP methods used:
357
+ * Fetch.enable (patterns), Fetch.requestPaused (event),
358
+ * Fetch.fulfillRequest / Fetch.failRequest / Fetch.continueRequest, Fetch.disable,
359
+ * Page.enable + Page.reload (reload:true), Runtime.evaluate (liveness probe).
360
+ * Model: persistent per-target session (like recorder.ts), MCP-server-scoped state.
361
+ * Parity gaps vs rod HijackRequests / chrome-devtools-mcp:
362
+ * - Request-stage only — no live Response-body rewriting (add requestStage:"Response"
363
+ * + Fetch.getResponseBody/continueResponse for that).
364
+ * - Persistence is in-process: works via the long-lived MCP server, not the
365
+ * one-shot CLI (mirrors the recorder's background mode).
366
+ * - Cached requests aren't intercepted (use reload:true / a hard reload to refetch).
367
+ * ---------------------------------------------------------------------------- */
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Page-lifecycle tools — operate on the BROWSER endpoint (Target.* domain).
3
+ *
4
+ * These four tools mirror chrome-devtools-mcp's page management:
5
+ * list_pages -> listPages (enumerate page targets)
6
+ * new_page -> newPage (Target.createTarget)
7
+ * close_page -> closePage (Target.closeTarget)
8
+ * select_page -> selectPage (Target.activateTarget + persist selected state)
9
+ *
10
+ * Unlike most modules these do NOT use withPage/openPage (which attach to a
11
+ * single page target's WebSocket). They use openBrowser() to reach the
12
+ * browser-level Target.* domain so we can create/close/activate any target.
13
+ *
14
+ * select_page persists the chosen targetId to the selected-state file
15
+ * (CDP_STATE_DIR/selected) per CONTRACT.md so other tools can treat it as the
16
+ * default active page if they choose.
17
+ */
18
+ import { mkdir, writeFile } from "node:fs/promises";
19
+ import { dirname } from "node:path";
20
+ import { CdpError, listTargets, openBrowser, resolveTarget } from "../client.ts";
21
+ import type { Target, TargetSelector } from "../types.ts";
22
+
23
+ /** Selected-target state file: a bare targetId written by select_page. */
24
+ const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
25
+ const SELECTED_FILE = `${STATE_DIR}/selected`;
26
+
27
+ /** Compact page descriptor returned to callers. */
28
+ interface PageInfo {
29
+ id: string;
30
+ url: string;
31
+ title: string;
32
+ type: string;
33
+ }
34
+
35
+ function toPageInfo(t: Target): PageInfo {
36
+ return { id: t.id, url: t.url, title: t.title, type: t.type };
37
+ }
38
+
39
+ /* --------------------------------- list_pages -------------------------------- */
40
+
41
+ export interface ListPagesArgs {
42
+ /** include non-page targets (workers, background pages) when true */
43
+ all?: boolean;
44
+ }
45
+
46
+ export interface ListPagesResult {
47
+ pages: PageInfo[];
48
+ count: number;
49
+ }
50
+
51
+ /** Enumerate page targets via GET /json/list. */
52
+ export async function listPages(args: ListPagesArgs = {}): Promise<ListPagesResult> {
53
+ const targets = await listTargets();
54
+ const filtered = args.all ? targets : targets.filter((t) => t.type === "page");
55
+ const pages = filtered.map(toPageInfo);
56
+ return { pages, count: pages.length };
57
+ }
58
+
59
+ /* ---------------------------------- new_page --------------------------------- */
60
+
61
+ export interface NewPageArgs {
62
+ /** URL to open in the new tab; defaults to about:blank */
63
+ url?: string;
64
+ }
65
+
66
+ export interface NewPageResult {
67
+ targetId: string;
68
+ url: string;
69
+ }
70
+
71
+ /** Create a new page target (Target.createTarget). */
72
+ export async function newPage(args: NewPageArgs = {}): Promise<NewPageResult> {
73
+ const url = args.url ?? "about:blank";
74
+ const conn = await openBrowser();
75
+ try {
76
+ const { targetId } = await conn.send<{ targetId: string }>("Target.createTarget", { url });
77
+ if (!targetId) throw new CdpError("Target.createTarget returned no targetId");
78
+ return { targetId, url };
79
+ } finally {
80
+ conn.close();
81
+ }
82
+ }
83
+
84
+ /* --------------------------------- close_page -------------------------------- */
85
+
86
+ export interface ClosePageArgs {
87
+ target: TargetSelector;
88
+ }
89
+
90
+ export interface ClosePageResult {
91
+ closed: string;
92
+ success: boolean;
93
+ }
94
+
95
+ /**
96
+ * Close a page target (Target.closeTarget). Refuses with a clear error if the
97
+ * selector cannot be resolved to a concrete target.
98
+ */
99
+ export async function closePage(args: ClosePageArgs): Promise<ClosePageResult> {
100
+ if (args.target === undefined || args.target === "") {
101
+ throw new CdpError("close_page requires an explicit target; refusing to guess which page to close");
102
+ }
103
+ // resolveTarget throws a descriptive CdpError if the selector matches nothing.
104
+ const target = await resolveTarget(args.target);
105
+ const conn = await openBrowser();
106
+ try {
107
+ const res = await conn.send<{ success?: boolean }>("Target.closeTarget", { targetId: target.id });
108
+ // Target.closeTarget returns { success: boolean } on older builds; newer
109
+ // builds may return {}. Absence of an error means the close was accepted.
110
+ return { closed: target.id, success: res.success ?? true };
111
+ } finally {
112
+ conn.close();
113
+ }
114
+ }
115
+
116
+ /* -------------------------------- select_page -------------------------------- */
117
+
118
+ export interface SelectPageArgs {
119
+ target: TargetSelector;
120
+ }
121
+
122
+ export interface SelectPageResult {
123
+ selected: string;
124
+ }
125
+
126
+ /**
127
+ * Activate (focus) a page target (Target.activateTarget) and persist its bare
128
+ * targetId to the selected-state file so other tools can default to it.
129
+ */
130
+ export async function selectPage(args: SelectPageArgs): Promise<SelectPageResult> {
131
+ if (args.target === undefined || args.target === "") {
132
+ throw new CdpError("select_page requires an explicit target");
133
+ }
134
+ const target = await resolveTarget(args.target);
135
+ const conn = await openBrowser();
136
+ try {
137
+ await conn.send("Target.activateTarget", { targetId: target.id });
138
+ } finally {
139
+ conn.close();
140
+ }
141
+ await mkdir(dirname(SELECTED_FILE), { recursive: true });
142
+ await writeFile(SELECTED_FILE, target.id, "utf8");
143
+ return { selected: target.id };
144
+ }
145
+
146
+ /*
147
+ * CDP methods/domains used:
148
+ * - GET /json/list (via listTargets) — list_pages
149
+ * - Target.createTarget — new_page
150
+ * - Target.closeTarget — close_page
151
+ * - Target.activateTarget — select_page
152
+ * - resolveTarget() — selector resolution for close/select
153
+ *
154
+ * Parity gaps vs chrome-devtools-mcp:
155
+ * - new_page returns {targetId,url}; MCP returns the page's eventual title/URL after load. We do not await navigation here (navigate_page covers that).
156
+ * - select_page writes a flat-file selected target; the MCP keeps an in-process active-page handle. resolveTarget does NOT read this file, so "active" still means index:0 unless a tool opts in.
157
+ * - close_page reports success:true when newer Chromium returns an empty result (no explicit success field); it relies on the absence of a CDP error rather than the legacy boolean.
158
+ * - list_pages "all" flag exposes worker/background targets; the MCP only lists page-type tabs.
159
+ */