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.
- package/CHANGELOG.md +29 -0
- package/CONTRACT.md +116 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/cli.js +2108 -0
- package/dist/index.js +2019 -0
- package/dist/mcp.js +3056 -0
- package/package.json +82 -0
- package/src/cli.ts +145 -0
- package/src/client.ts +255 -0
- package/src/index.ts +128 -0
- package/src/manifest.ts +1030 -0
- package/src/mcp.ts +61 -0
- package/src/tools/console.ts +199 -0
- package/src/tools/dialogs.ts +236 -0
- package/src/tools/emulation.ts +212 -0
- package/src/tools/evaluate.ts +155 -0
- package/src/tools/heap.ts +109 -0
- package/src/tools/input.ts +399 -0
- package/src/tools/lighthouse.ts +285 -0
- package/src/tools/navigation.ts +202 -0
- package/src/tools/network.ts +275 -0
- package/src/tools/network_mock.ts +367 -0
- package/src/tools/pages.ts +159 -0
- package/src/tools/performance.ts +492 -0
- package/src/tools/recorder.ts +265 -0
- package/src/tools/screenshot.ts +180 -0
- package/src/tools/snapshot.ts +206 -0
- package/src/types.ts +63 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/tools/pages.ts
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/client.ts
|
|
8
|
+
var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
9
|
+
var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
10
|
+
|
|
11
|
+
class CdpError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
data;
|
|
14
|
+
constructor(message, code, data) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.data = data;
|
|
18
|
+
this.name = "CdpError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class CdpConnection {
|
|
23
|
+
wsUrl;
|
|
24
|
+
opts;
|
|
25
|
+
ws;
|
|
26
|
+
nextId = 0;
|
|
27
|
+
pending = new Map;
|
|
28
|
+
listeners = new Map;
|
|
29
|
+
closed = false;
|
|
30
|
+
constructor(wsUrl, opts = {}) {
|
|
31
|
+
this.wsUrl = wsUrl;
|
|
32
|
+
this.opts = opts;
|
|
33
|
+
}
|
|
34
|
+
connect() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const ws = new WebSocket(this.wsUrl);
|
|
37
|
+
this.ws = ws;
|
|
38
|
+
const openTimer = setTimeout(() => reject(new CdpError(`connect timeout: ${this.wsUrl}`)), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
39
|
+
ws.onopen = () => {
|
|
40
|
+
clearTimeout(openTimer);
|
|
41
|
+
resolve(this);
|
|
42
|
+
};
|
|
43
|
+
ws.onerror = (e) => {
|
|
44
|
+
clearTimeout(openTimer);
|
|
45
|
+
const msg = e?.message ?? "websocket error";
|
|
46
|
+
if (this.pending.size === 0)
|
|
47
|
+
reject(new CdpError(msg));
|
|
48
|
+
this.rejectAll(new CdpError(msg));
|
|
49
|
+
};
|
|
50
|
+
ws.onclose = () => {
|
|
51
|
+
this.closed = true;
|
|
52
|
+
this.rejectAll(new CdpError("connection closed"));
|
|
53
|
+
};
|
|
54
|
+
ws.onmessage = (ev) => this.onMessage(String(ev.data));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
onMessage(raw) {
|
|
58
|
+
let msg;
|
|
59
|
+
try {
|
|
60
|
+
msg = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (typeof msg.id === "number" && this.pending.has(msg.id)) {
|
|
65
|
+
const p = this.pending.get(msg.id);
|
|
66
|
+
clearTimeout(p.timer);
|
|
67
|
+
this.pending.delete(msg.id);
|
|
68
|
+
p.resolve(msg);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (msg.method) {
|
|
72
|
+
const set = this.listeners.get(msg.method);
|
|
73
|
+
if (set)
|
|
74
|
+
for (const h of set)
|
|
75
|
+
h(msg.params ?? {}, msg.sessionId);
|
|
76
|
+
const star = this.listeners.get("*");
|
|
77
|
+
if (star)
|
|
78
|
+
for (const h of star)
|
|
79
|
+
h({ method: msg.method, ...msg.params ?? {} }, msg.sessionId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
rejectAll(err) {
|
|
83
|
+
for (const [, p] of this.pending) {
|
|
84
|
+
clearTimeout(p.timer);
|
|
85
|
+
p.reject(err);
|
|
86
|
+
}
|
|
87
|
+
this.pending.clear();
|
|
88
|
+
}
|
|
89
|
+
send(method, params = {}, opts = {}) {
|
|
90
|
+
if (this.closed || !this.ws)
|
|
91
|
+
return Promise.reject(new CdpError("connection not open"));
|
|
92
|
+
const id = ++this.nextId;
|
|
93
|
+
const timeoutMs = opts.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
94
|
+
const payload = { id, method, params };
|
|
95
|
+
if (opts.sessionId)
|
|
96
|
+
payload.sessionId = opts.sessionId;
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
this.pending.delete(id);
|
|
100
|
+
reject(new CdpError(`'${method}' timed out after ${timeoutMs}ms`));
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
this.pending.set(id, {
|
|
103
|
+
resolve: (r) => {
|
|
104
|
+
if (r.error)
|
|
105
|
+
reject(new CdpError(`${method}: ${r.error.message}`, r.error.code, r.error.data));
|
|
106
|
+
else
|
|
107
|
+
resolve(r.result ?? {});
|
|
108
|
+
},
|
|
109
|
+
reject,
|
|
110
|
+
timer
|
|
111
|
+
});
|
|
112
|
+
this.ws.send(JSON.stringify(payload));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
on(method, handler) {
|
|
116
|
+
let set = this.listeners.get(method);
|
|
117
|
+
if (!set) {
|
|
118
|
+
set = new Set;
|
|
119
|
+
this.listeners.set(method, set);
|
|
120
|
+
}
|
|
121
|
+
set.add(handler);
|
|
122
|
+
return () => set.delete(handler);
|
|
123
|
+
}
|
|
124
|
+
waitFor(method, predicate, timeoutMs = this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
off();
|
|
128
|
+
reject(new CdpError(`waitFor('${method}') timed out after ${timeoutMs}ms`));
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
const off = this.on(method, (params) => {
|
|
131
|
+
if (!predicate || predicate(params)) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
off();
|
|
134
|
+
resolve(params);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
close() {
|
|
140
|
+
this.closed = true;
|
|
141
|
+
this.rejectAll(new CdpError("closed by caller"));
|
|
142
|
+
try {
|
|
143
|
+
this.ws?.close();
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function httpJson(path) {
|
|
148
|
+
const res = await fetch(`${BASE}${path}`);
|
|
149
|
+
if (!res.ok)
|
|
150
|
+
throw new CdpError(`${path} -> HTTP ${res.status}`);
|
|
151
|
+
return await res.json();
|
|
152
|
+
}
|
|
153
|
+
function listTargets() {
|
|
154
|
+
return httpJson("/json/list");
|
|
155
|
+
}
|
|
156
|
+
async function browserWsUrl() {
|
|
157
|
+
const v = await httpJson("/json/version");
|
|
158
|
+
return v.webSocketDebuggerUrl;
|
|
159
|
+
}
|
|
160
|
+
async function resolveTarget(selector) {
|
|
161
|
+
const targets = await listTargets();
|
|
162
|
+
const pages = targets.filter((t) => t.type === "page");
|
|
163
|
+
if (!selector || selector === "active") {
|
|
164
|
+
if (!pages.length)
|
|
165
|
+
throw new CdpError("no page targets open");
|
|
166
|
+
return pages[0];
|
|
167
|
+
}
|
|
168
|
+
if (selector.startsWith("index:")) {
|
|
169
|
+
const i = Number(selector.slice(6));
|
|
170
|
+
if (!pages[i])
|
|
171
|
+
throw new CdpError(`no page target at index ${i} (have ${pages.length})`);
|
|
172
|
+
return pages[i];
|
|
173
|
+
}
|
|
174
|
+
if (selector.startsWith("url:")) {
|
|
175
|
+
const needle = selector.slice(4);
|
|
176
|
+
const hit = pages.find((t) => t.url.includes(needle));
|
|
177
|
+
if (!hit)
|
|
178
|
+
throw new CdpError(`no page url containing '${needle}'`);
|
|
179
|
+
return hit;
|
|
180
|
+
}
|
|
181
|
+
if (selector.startsWith("title:")) {
|
|
182
|
+
const needle = selector.slice(6);
|
|
183
|
+
const hit = pages.find((t) => t.title.includes(needle));
|
|
184
|
+
if (!hit)
|
|
185
|
+
throw new CdpError(`no page title containing '${needle}'`);
|
|
186
|
+
return hit;
|
|
187
|
+
}
|
|
188
|
+
const byId = targets.find((t) => t.id === selector);
|
|
189
|
+
if (!byId)
|
|
190
|
+
throw new CdpError(`no target with id '${selector}'`);
|
|
191
|
+
return byId;
|
|
192
|
+
}
|
|
193
|
+
async function openBrowser(opts = {}) {
|
|
194
|
+
return new CdpConnection(await browserWsUrl(), opts).connect();
|
|
195
|
+
}
|
|
196
|
+
async function openPage(selector, opts = {}) {
|
|
197
|
+
const target = await resolveTarget(selector);
|
|
198
|
+
const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
|
|
199
|
+
return { conn, target };
|
|
200
|
+
}
|
|
201
|
+
async function withPage(selector, fn, opts = {}) {
|
|
202
|
+
const { conn, target } = await openPage(selector, opts);
|
|
203
|
+
try {
|
|
204
|
+
return await fn(conn, target);
|
|
205
|
+
} finally {
|
|
206
|
+
conn.close();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/tools/pages.ts
|
|
211
|
+
var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
212
|
+
var SELECTED_FILE = `${STATE_DIR}/selected`;
|
|
213
|
+
function toPageInfo(t) {
|
|
214
|
+
return { id: t.id, url: t.url, title: t.title, type: t.type };
|
|
215
|
+
}
|
|
216
|
+
async function listPages(args = {}) {
|
|
217
|
+
const targets = await listTargets();
|
|
218
|
+
const filtered = args.all ? targets : targets.filter((t) => t.type === "page");
|
|
219
|
+
const pages = filtered.map(toPageInfo);
|
|
220
|
+
return { pages, count: pages.length };
|
|
221
|
+
}
|
|
222
|
+
async function newPage(args = {}) {
|
|
223
|
+
const url = args.url ?? "about:blank";
|
|
224
|
+
const conn = await openBrowser();
|
|
225
|
+
try {
|
|
226
|
+
const { targetId } = await conn.send("Target.createTarget", { url });
|
|
227
|
+
if (!targetId)
|
|
228
|
+
throw new CdpError("Target.createTarget returned no targetId");
|
|
229
|
+
return { targetId, url };
|
|
230
|
+
} finally {
|
|
231
|
+
conn.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function closePage(args) {
|
|
235
|
+
if (args.target === undefined || args.target === "") {
|
|
236
|
+
throw new CdpError("close_page requires an explicit target; refusing to guess which page to close");
|
|
237
|
+
}
|
|
238
|
+
const target = await resolveTarget(args.target);
|
|
239
|
+
const conn = await openBrowser();
|
|
240
|
+
try {
|
|
241
|
+
const res = await conn.send("Target.closeTarget", { targetId: target.id });
|
|
242
|
+
return { closed: target.id, success: res.success ?? true };
|
|
243
|
+
} finally {
|
|
244
|
+
conn.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function selectPage(args) {
|
|
248
|
+
if (args.target === undefined || args.target === "") {
|
|
249
|
+
throw new CdpError("select_page requires an explicit target");
|
|
250
|
+
}
|
|
251
|
+
const target = await resolveTarget(args.target);
|
|
252
|
+
const conn = await openBrowser();
|
|
253
|
+
try {
|
|
254
|
+
await conn.send("Target.activateTarget", { targetId: target.id });
|
|
255
|
+
} finally {
|
|
256
|
+
conn.close();
|
|
257
|
+
}
|
|
258
|
+
await mkdir(dirname(SELECTED_FILE), { recursive: true });
|
|
259
|
+
await writeFile(SELECTED_FILE, target.id, "utf8");
|
|
260
|
+
return { selected: target.id };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/tools/navigation.ts
|
|
264
|
+
async function navigatePage(args) {
|
|
265
|
+
const reload = args.reload === true;
|
|
266
|
+
if (!reload && (!args.url || typeof args.url !== "string")) {
|
|
267
|
+
throw new CdpError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
|
|
268
|
+
}
|
|
269
|
+
const waitUntil = args.waitUntil ?? "load";
|
|
270
|
+
return withPage(args.target, async (conn) => {
|
|
271
|
+
await conn.send("Page.enable");
|
|
272
|
+
const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
|
|
273
|
+
const milestone = conn.waitFor(milestoneMethod, undefined, args.timeoutMs).then(() => waitUntil).catch(() => {
|
|
274
|
+
return;
|
|
275
|
+
});
|
|
276
|
+
const stopped = conn.waitFor("Page.frameStoppedLoading", undefined, args.timeoutMs).then(() => "frameStoppedLoading").catch(() => {
|
|
277
|
+
return;
|
|
278
|
+
});
|
|
279
|
+
let resolvedUrl;
|
|
280
|
+
let frameId;
|
|
281
|
+
if (reload) {
|
|
282
|
+
await conn.send("Page.reload", { ignoreCache: args.ignoreCache === true }, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
|
|
283
|
+
const tree = await conn.send("Page.getFrameTree").catch(() => {
|
|
284
|
+
return;
|
|
285
|
+
});
|
|
286
|
+
frameId = tree?.frameTree?.frame?.id ?? "";
|
|
287
|
+
resolvedUrl = tree?.frameTree?.frame?.url ?? args.url ?? "";
|
|
288
|
+
} else {
|
|
289
|
+
const nav = await conn.send("Page.navigate", { url: args.url }, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
|
|
290
|
+
if (nav.errorText) {
|
|
291
|
+
throw new CdpError(`navigate_page: ${nav.errorText} (${args.url})`);
|
|
292
|
+
}
|
|
293
|
+
frameId = nav.frameId;
|
|
294
|
+
resolvedUrl = args.url;
|
|
295
|
+
}
|
|
296
|
+
const settled = await Promise.race([milestone, stopped]);
|
|
297
|
+
const waitedFor = settled ?? "navigate-only";
|
|
298
|
+
return reload ? { url: resolvedUrl, frameId, reloaded: true, waitedFor } : { url: resolvedUrl, frameId, waitedFor };
|
|
299
|
+
}, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
|
|
300
|
+
}
|
|
301
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 15000;
|
|
302
|
+
var DEFAULT_POLL_MS = 250;
|
|
303
|
+
async function waitForText(args) {
|
|
304
|
+
if (typeof args.text !== "string" || args.text.length === 0) {
|
|
305
|
+
throw new CdpError("wait_for: 'text' is required");
|
|
306
|
+
}
|
|
307
|
+
const timeoutMs = args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
308
|
+
const pollMs = args.pollMs ?? DEFAULT_POLL_MS;
|
|
309
|
+
const start = Date.now();
|
|
310
|
+
return withPage(args.target, async (conn, _target) => {
|
|
311
|
+
await conn.send("Runtime.enable");
|
|
312
|
+
const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(args.text)}); })()`;
|
|
313
|
+
for (;; ) {
|
|
314
|
+
const { result } = await conn.send("Runtime.evaluate", {
|
|
315
|
+
expression: expr,
|
|
316
|
+
returnByValue: true
|
|
317
|
+
});
|
|
318
|
+
if (result?.value === true) {
|
|
319
|
+
return { found: true, waitedMs: Date.now() - start };
|
|
320
|
+
}
|
|
321
|
+
if (Date.now() - start >= timeoutMs) {
|
|
322
|
+
throw new CdpError(`wait_for: text not found within ${timeoutMs}ms: ${JSON.stringify(args.text)}`);
|
|
323
|
+
}
|
|
324
|
+
await delay(Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start))));
|
|
325
|
+
}
|
|
326
|
+
}, { timeoutMs: Math.max(timeoutMs, DEFAULT_WAIT_TIMEOUT_MS) });
|
|
327
|
+
}
|
|
328
|
+
function delay(ms) {
|
|
329
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/tools/evaluate.ts
|
|
333
|
+
function exceptionMessage(details) {
|
|
334
|
+
const ex = details.exception;
|
|
335
|
+
const fromObject = ex?.description ?? (typeof ex?.value === "string" ? ex.value : ex?.value !== undefined ? JSON.stringify(ex.value) : undefined);
|
|
336
|
+
return fromObject ?? details.text ?? "evaluation threw";
|
|
337
|
+
}
|
|
338
|
+
function unwrap(obj) {
|
|
339
|
+
if (Object.prototype.hasOwnProperty.call(obj, "value"))
|
|
340
|
+
return obj.value;
|
|
341
|
+
if (obj.unserializableValue !== undefined)
|
|
342
|
+
return obj.unserializableValue;
|
|
343
|
+
if (obj.type === "undefined")
|
|
344
|
+
return;
|
|
345
|
+
return obj.description ?? null;
|
|
346
|
+
}
|
|
347
|
+
async function evaluateExpression(conn, expression, awaitPromise) {
|
|
348
|
+
const res = await conn.send("Runtime.evaluate", {
|
|
349
|
+
expression,
|
|
350
|
+
returnByValue: true,
|
|
351
|
+
awaitPromise,
|
|
352
|
+
replMode: false,
|
|
353
|
+
silent: false
|
|
354
|
+
});
|
|
355
|
+
if (res.exceptionDetails)
|
|
356
|
+
throw new CdpError(exceptionMessage(res.exceptionDetails));
|
|
357
|
+
return unwrap(res.result);
|
|
358
|
+
}
|
|
359
|
+
async function evaluateFunction(conn, expression, awaitPromise, args) {
|
|
360
|
+
const globalObj = await conn.send("Runtime.evaluate", {
|
|
361
|
+
expression: "globalThis",
|
|
362
|
+
returnByValue: false
|
|
363
|
+
});
|
|
364
|
+
const objectId = globalObj.result.objectId;
|
|
365
|
+
if (!objectId)
|
|
366
|
+
throw new CdpError("could not resolve global object for function call");
|
|
367
|
+
const res = await conn.send("Runtime.callFunctionOn", {
|
|
368
|
+
objectId,
|
|
369
|
+
functionDeclaration: expression,
|
|
370
|
+
arguments: args.map((value) => ({ value })),
|
|
371
|
+
returnByValue: true,
|
|
372
|
+
awaitPromise,
|
|
373
|
+
silent: false
|
|
374
|
+
});
|
|
375
|
+
if (res.exceptionDetails)
|
|
376
|
+
throw new CdpError(exceptionMessage(res.exceptionDetails));
|
|
377
|
+
return unwrap(res.result);
|
|
378
|
+
}
|
|
379
|
+
async function evaluateScript(args) {
|
|
380
|
+
const { expression } = args;
|
|
381
|
+
if (typeof expression !== "string" || expression.length === 0) {
|
|
382
|
+
throw new CdpError("evaluateScript: 'expression' must be a non-empty string");
|
|
383
|
+
}
|
|
384
|
+
const awaitPromise = args.awaitPromise ?? true;
|
|
385
|
+
const callArgs = args.args;
|
|
386
|
+
return withPage(args.target, async (conn) => {
|
|
387
|
+
await conn.send("Runtime.enable");
|
|
388
|
+
if (callArgs && callArgs.length > 0) {
|
|
389
|
+
return evaluateFunction(conn, expression, awaitPromise, callArgs);
|
|
390
|
+
}
|
|
391
|
+
return evaluateExpression(conn, expression, awaitPromise);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/tools/snapshot.ts
|
|
396
|
+
var INTERACTIVE_ROLES = new Set([
|
|
397
|
+
"button",
|
|
398
|
+
"link",
|
|
399
|
+
"textbox",
|
|
400
|
+
"searchbox",
|
|
401
|
+
"checkbox",
|
|
402
|
+
"radio",
|
|
403
|
+
"combobox",
|
|
404
|
+
"listbox",
|
|
405
|
+
"option",
|
|
406
|
+
"menuitem",
|
|
407
|
+
"menuitemcheckbox",
|
|
408
|
+
"menuitemradio",
|
|
409
|
+
"tab",
|
|
410
|
+
"switch",
|
|
411
|
+
"slider",
|
|
412
|
+
"spinbutton",
|
|
413
|
+
"textfield",
|
|
414
|
+
"MenuItem",
|
|
415
|
+
"Button"
|
|
416
|
+
]);
|
|
417
|
+
function axString(v) {
|
|
418
|
+
if (!v)
|
|
419
|
+
return;
|
|
420
|
+
const raw = v.value;
|
|
421
|
+
if (raw === undefined || raw === null)
|
|
422
|
+
return;
|
|
423
|
+
const s = String(raw).trim();
|
|
424
|
+
return s.length ? s : undefined;
|
|
425
|
+
}
|
|
426
|
+
function propValue(node, name) {
|
|
427
|
+
const p = node.properties?.find((x) => x.name === name);
|
|
428
|
+
return axString(p?.value);
|
|
429
|
+
}
|
|
430
|
+
function isInteractive(role) {
|
|
431
|
+
if (!role)
|
|
432
|
+
return false;
|
|
433
|
+
return INTERACTIVE_ROLES.has(role) || INTERACTIVE_ROLES.has(role.toLowerCase());
|
|
434
|
+
}
|
|
435
|
+
function formatNode(node, interactiveOnly) {
|
|
436
|
+
const role = axString(node.role);
|
|
437
|
+
const name = axString(node.name);
|
|
438
|
+
const uid = node.backendDOMNodeId;
|
|
439
|
+
if (uid === undefined)
|
|
440
|
+
return;
|
|
441
|
+
if (interactiveOnly && !isInteractive(role))
|
|
442
|
+
return;
|
|
443
|
+
if (!interactiveOnly && !role && !name)
|
|
444
|
+
return;
|
|
445
|
+
const extras = [];
|
|
446
|
+
const value = axString(node.value);
|
|
447
|
+
if (value)
|
|
448
|
+
extras.push(`value=${JSON.stringify(value)}`);
|
|
449
|
+
const checked = propValue(node, "checked");
|
|
450
|
+
if (checked && checked !== "false")
|
|
451
|
+
extras.push(`checked=${checked}`);
|
|
452
|
+
const expanded = propValue(node, "expanded");
|
|
453
|
+
if (expanded)
|
|
454
|
+
extras.push(`expanded=${expanded}`);
|
|
455
|
+
const disabled = propValue(node, "disabled");
|
|
456
|
+
if (disabled === "true")
|
|
457
|
+
extras.push("disabled");
|
|
458
|
+
const url = propValue(node, "url");
|
|
459
|
+
if (url)
|
|
460
|
+
extras.push(`url=${url}`);
|
|
461
|
+
const focused = propValue(node, "focused");
|
|
462
|
+
if (focused === "true")
|
|
463
|
+
extras.push("focused");
|
|
464
|
+
const label = name ? ` ${JSON.stringify(name)}` : "";
|
|
465
|
+
const extra = extras.length ? ` [${extras.join(" ")}]` : "";
|
|
466
|
+
return `[${uid}] ${role ?? "generic"}${label}${extra}`;
|
|
467
|
+
}
|
|
468
|
+
async function takeSnapshot(args = {}) {
|
|
469
|
+
const interactiveOnly = args.interactiveOnly ?? false;
|
|
470
|
+
return withPage(args.target, async (conn, target) => {
|
|
471
|
+
await conn.send("Accessibility.enable");
|
|
472
|
+
const { nodes } = await conn.send("Accessibility.getFullAXTree");
|
|
473
|
+
const byId = new Map;
|
|
474
|
+
for (const n of nodes)
|
|
475
|
+
byId.set(n.nodeId, n);
|
|
476
|
+
const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
|
|
477
|
+
const lines = [];
|
|
478
|
+
let nodeCount = 0;
|
|
479
|
+
const walk = (node, depth) => {
|
|
480
|
+
const line = node.ignored ? undefined : formatNode(node, interactiveOnly);
|
|
481
|
+
if (line !== undefined) {
|
|
482
|
+
const indent = interactiveOnly ? "" : " ".repeat(depth);
|
|
483
|
+
lines.push(`${indent}${line}`);
|
|
484
|
+
nodeCount++;
|
|
485
|
+
}
|
|
486
|
+
const childDepth = line !== undefined && !interactiveOnly ? depth + 1 : depth;
|
|
487
|
+
for (const childId of node.childIds ?? []) {
|
|
488
|
+
const child = byId.get(childId);
|
|
489
|
+
if (child)
|
|
490
|
+
walk(child, childDepth);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
for (const root of roots)
|
|
494
|
+
walk(root, 0);
|
|
495
|
+
return {
|
|
496
|
+
snapshot: lines.join(`
|
|
497
|
+
`),
|
|
498
|
+
target: { id: target.id, url: target.url, title: target.title },
|
|
499
|
+
nodeCount
|
|
500
|
+
};
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
async function resolveUid(conn, uid) {
|
|
504
|
+
const { object } = await conn.send("DOM.resolveNode", {
|
|
505
|
+
backendNodeId: uid
|
|
506
|
+
});
|
|
507
|
+
if (!object?.objectId) {
|
|
508
|
+
throw new Error(`resolveUid: DOM.resolveNode returned no objectId for uid ${uid}`);
|
|
509
|
+
}
|
|
510
|
+
return { objectId: object.objectId };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/tools/input.ts
|
|
514
|
+
async function resolveElement(conn, t) {
|
|
515
|
+
const hasUid = t.uid !== undefined && t.uid !== null;
|
|
516
|
+
const hasSelector = typeof t.selector === "string" && t.selector.length > 0;
|
|
517
|
+
if (hasUid === hasSelector) {
|
|
518
|
+
throw new CdpError("provide exactly one of { uid } or { selector }");
|
|
519
|
+
}
|
|
520
|
+
if (hasUid) {
|
|
521
|
+
return resolveUid(conn, t.uid);
|
|
522
|
+
}
|
|
523
|
+
const { result, exceptionDetails } = await conn.send("Runtime.evaluate", {
|
|
524
|
+
expression: `document.querySelector(${JSON.stringify(t.selector)})`,
|
|
525
|
+
returnByValue: false
|
|
526
|
+
});
|
|
527
|
+
if (exceptionDetails) {
|
|
528
|
+
throw new CdpError(`selector '${t.selector}' evaluation failed: ${exceptionDetails.text ?? "error"}`);
|
|
529
|
+
}
|
|
530
|
+
if (!result.objectId || result.subtype === "null") {
|
|
531
|
+
throw new CdpError(`selector '${t.selector}' matched no element`);
|
|
532
|
+
}
|
|
533
|
+
return { objectId: result.objectId };
|
|
534
|
+
}
|
|
535
|
+
async function centerOf(conn, objectId) {
|
|
536
|
+
const { result, exceptionDetails } = await conn.send("Runtime.callFunctionOn", {
|
|
537
|
+
objectId,
|
|
538
|
+
functionDeclaration: "function(){this.scrollIntoView({block:'center',inline:'center'});const r=this.getBoundingClientRect();if(r.width===0&&r.height===0)return null;return {x:r.left+r.width/2,y:r.top+r.height/2};}",
|
|
539
|
+
returnByValue: true
|
|
540
|
+
});
|
|
541
|
+
if (exceptionDetails) {
|
|
542
|
+
throw new CdpError(`could not measure element: ${exceptionDetails.text ?? "error"}`);
|
|
543
|
+
}
|
|
544
|
+
if (!result.value) {
|
|
545
|
+
throw new CdpError("element has zero size / is not visible; cannot compute a click point");
|
|
546
|
+
}
|
|
547
|
+
return result.value;
|
|
548
|
+
}
|
|
549
|
+
async function focusElement(conn, objectId) {
|
|
550
|
+
await conn.send("Runtime.callFunctionOn", {
|
|
551
|
+
objectId,
|
|
552
|
+
functionDeclaration: "function(){this.focus&&this.focus();}",
|
|
553
|
+
returnByValue: true
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async function click(args) {
|
|
557
|
+
const button = args.button ?? "left";
|
|
558
|
+
const clickCount = args.clickCount ?? 1;
|
|
559
|
+
return withPage(args.target, async (conn) => {
|
|
560
|
+
const { objectId } = await resolveElement(conn, args);
|
|
561
|
+
const { x, y } = await centerOf(conn, objectId);
|
|
562
|
+
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
563
|
+
await conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
|
|
564
|
+
await conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
|
|
565
|
+
return { clicked: true, x, y };
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
async function hover(args) {
|
|
569
|
+
return withPage(args.target, async (conn) => {
|
|
570
|
+
const { objectId } = await resolveElement(conn, args);
|
|
571
|
+
const { x, y } = await centerOf(conn, objectId);
|
|
572
|
+
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
573
|
+
return { hovered: true, x, y };
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
async function drag(args) {
|
|
577
|
+
if (!args.from || !args.to)
|
|
578
|
+
throw new CdpError("drag requires { from } and { to }");
|
|
579
|
+
return withPage(args.target, async (conn) => {
|
|
580
|
+
const { objectId: srcId } = await resolveElement(conn, args.from);
|
|
581
|
+
const from = await centerOf(conn, srcId);
|
|
582
|
+
const { objectId: dstId } = await resolveElement(conn, args.to);
|
|
583
|
+
const to = await centerOf(conn, dstId);
|
|
584
|
+
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from.x, y: from.y });
|
|
585
|
+
await conn.send("Input.dispatchMouseEvent", {
|
|
586
|
+
type: "mousePressed",
|
|
587
|
+
x: from.x,
|
|
588
|
+
y: from.y,
|
|
589
|
+
button: "left",
|
|
590
|
+
clickCount: 1
|
|
591
|
+
});
|
|
592
|
+
await conn.send("Input.dispatchMouseEvent", {
|
|
593
|
+
type: "mouseMoved",
|
|
594
|
+
x: (from.x + to.x) / 2,
|
|
595
|
+
y: (from.y + to.y) / 2,
|
|
596
|
+
button: "left"
|
|
597
|
+
});
|
|
598
|
+
await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: to.x, y: to.y, button: "left" });
|
|
599
|
+
await conn.send("Input.dispatchMouseEvent", {
|
|
600
|
+
type: "mouseReleased",
|
|
601
|
+
x: to.x,
|
|
602
|
+
y: to.y,
|
|
603
|
+
button: "left",
|
|
604
|
+
clickCount: 1
|
|
605
|
+
});
|
|
606
|
+
return { dragged: true, from, to };
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
async function setValue(conn, objectId, value) {
|
|
610
|
+
await focusElement(conn, objectId);
|
|
611
|
+
await conn.send("Runtime.callFunctionOn", {
|
|
612
|
+
objectId,
|
|
613
|
+
functionDeclaration: "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}",
|
|
614
|
+
returnByValue: true
|
|
615
|
+
});
|
|
616
|
+
if (value.length) {
|
|
617
|
+
await conn.send("Input.insertText", { text: value });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async function fill(args) {
|
|
621
|
+
if (typeof args.value !== "string")
|
|
622
|
+
throw new CdpError("fill requires a string { value }");
|
|
623
|
+
return withPage(args.target, async (conn) => {
|
|
624
|
+
const { objectId } = await resolveElement(conn, args);
|
|
625
|
+
await setValue(conn, objectId, args.value);
|
|
626
|
+
return { filled: true, value: args.value };
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
async function typeText(args) {
|
|
630
|
+
if (typeof args.text !== "string")
|
|
631
|
+
throw new CdpError("type_text requires a string { text }");
|
|
632
|
+
return withPage(args.target, async (conn) => {
|
|
633
|
+
const { objectId } = await resolveElement(conn, args);
|
|
634
|
+
await focusElement(conn, objectId);
|
|
635
|
+
if (args.text.length)
|
|
636
|
+
await conn.send("Input.insertText", { text: args.text });
|
|
637
|
+
return { typed: true, text: args.text };
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async function fillForm(args) {
|
|
641
|
+
if (!Array.isArray(args.fields) || args.fields.length === 0) {
|
|
642
|
+
throw new CdpError("fill_form requires a non-empty { fields } array");
|
|
643
|
+
}
|
|
644
|
+
return withPage(args.target, async (conn) => {
|
|
645
|
+
let filled = 0;
|
|
646
|
+
for (const field of args.fields) {
|
|
647
|
+
if (typeof field.value !== "string") {
|
|
648
|
+
throw new CdpError("each fill_form field requires a string value");
|
|
649
|
+
}
|
|
650
|
+
const { objectId } = await resolveElement(conn, field);
|
|
651
|
+
await setValue(conn, objectId, field.value);
|
|
652
|
+
filled++;
|
|
653
|
+
}
|
|
654
|
+
return { filled, fields: args.fields.length };
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
var NAMED_KEYS = {
|
|
658
|
+
enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
659
|
+
return: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
660
|
+
tab: { key: "Tab", code: "Tab", keyCode: 9 },
|
|
661
|
+
escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
662
|
+
esc: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
663
|
+
backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
|
|
664
|
+
delete: { key: "Delete", code: "Delete", keyCode: 46 },
|
|
665
|
+
space: { key: " ", code: "Space", keyCode: 32, text: " " },
|
|
666
|
+
arrowup: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
667
|
+
arrowdown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
668
|
+
arrowleft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
669
|
+
arrowright: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
670
|
+
up: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
671
|
+
down: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
672
|
+
left: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
673
|
+
right: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
674
|
+
home: { key: "Home", code: "Home", keyCode: 36 },
|
|
675
|
+
end: { key: "End", code: "End", keyCode: 35 },
|
|
676
|
+
pageup: { key: "PageUp", code: "PageUp", keyCode: 33 },
|
|
677
|
+
pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
|
|
678
|
+
};
|
|
679
|
+
var MODIFIER_BITS = {
|
|
680
|
+
alt: 1,
|
|
681
|
+
control: 2,
|
|
682
|
+
ctrl: 2,
|
|
683
|
+
meta: 4,
|
|
684
|
+
cmd: 4,
|
|
685
|
+
command: 4,
|
|
686
|
+
shift: 8
|
|
687
|
+
};
|
|
688
|
+
function resolveKey(key) {
|
|
689
|
+
const named = NAMED_KEYS[key.toLowerCase()];
|
|
690
|
+
if (named)
|
|
691
|
+
return named;
|
|
692
|
+
if (key.length === 1) {
|
|
693
|
+
const upper = key.toUpperCase();
|
|
694
|
+
const isLetter = upper >= "A" && upper <= "Z";
|
|
695
|
+
const isDigit = key >= "0" && key <= "9";
|
|
696
|
+
const code = isLetter ? `Key${upper}` : isDigit ? `Digit${key}` : `Key${upper}`;
|
|
697
|
+
const keyCode = upper.charCodeAt(0);
|
|
698
|
+
return { key, code, keyCode, text: key };
|
|
699
|
+
}
|
|
700
|
+
throw new CdpError(`unknown key '${key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
|
|
701
|
+
}
|
|
702
|
+
async function pressKey(args) {
|
|
703
|
+
if (typeof args.key !== "string" || !args.key.length) {
|
|
704
|
+
throw new CdpError("press_key requires a non-empty { key }");
|
|
705
|
+
}
|
|
706
|
+
const spec = resolveKey(args.key);
|
|
707
|
+
let modBits = 0;
|
|
708
|
+
const mods = args.modifiers ?? [];
|
|
709
|
+
for (const m of mods) {
|
|
710
|
+
const bit = MODIFIER_BITS[m.toLowerCase()];
|
|
711
|
+
if (bit === undefined)
|
|
712
|
+
throw new CdpError(`unknown modifier '${m}'`);
|
|
713
|
+
modBits |= bit;
|
|
714
|
+
}
|
|
715
|
+
const suppressText = (modBits & ~MODIFIER_BITS.shift) !== 0;
|
|
716
|
+
return withPage(args.target, async (conn) => {
|
|
717
|
+
const down = {
|
|
718
|
+
type: "keyDown",
|
|
719
|
+
key: spec.key,
|
|
720
|
+
code: spec.code,
|
|
721
|
+
windowsVirtualKeyCode: spec.keyCode,
|
|
722
|
+
nativeVirtualKeyCode: spec.keyCode,
|
|
723
|
+
modifiers: modBits
|
|
724
|
+
};
|
|
725
|
+
if (spec.text && !suppressText) {
|
|
726
|
+
down.text = spec.text;
|
|
727
|
+
}
|
|
728
|
+
await conn.send("Input.dispatchKeyEvent", down);
|
|
729
|
+
await conn.send("Input.dispatchKeyEvent", {
|
|
730
|
+
type: "keyUp",
|
|
731
|
+
key: spec.key,
|
|
732
|
+
code: spec.code,
|
|
733
|
+
windowsVirtualKeyCode: spec.keyCode,
|
|
734
|
+
nativeVirtualKeyCode: spec.keyCode,
|
|
735
|
+
modifiers: modBits
|
|
736
|
+
});
|
|
737
|
+
return { pressed: spec.key, modifiers: mods };
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
async function uploadFile(args) {
|
|
741
|
+
const files = Array.isArray(args.files) ? args.files : [args.files];
|
|
742
|
+
if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
|
|
743
|
+
throw new CdpError("upload_file requires { files } as a non-empty path or array of paths");
|
|
744
|
+
}
|
|
745
|
+
return withPage(args.target, async (conn) => {
|
|
746
|
+
const { objectId } = await resolveElement(conn, args);
|
|
747
|
+
await conn.send("DOM.setFileInputFiles", { files, objectId });
|
|
748
|
+
return { uploaded: files };
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/tools/screenshot.ts
|
|
753
|
+
import { writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
|
|
754
|
+
import { join } from "node:path";
|
|
755
|
+
var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
756
|
+
function shortId(id) {
|
|
757
|
+
return id.slice(0, 8);
|
|
758
|
+
}
|
|
759
|
+
function stamp() {
|
|
760
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
761
|
+
}
|
|
762
|
+
async function fullPageClip(conn) {
|
|
763
|
+
const m = await conn.send("Page.getLayoutMetrics");
|
|
764
|
+
const size = m.cssContentSize ?? m.contentSize;
|
|
765
|
+
if (!size)
|
|
766
|
+
throw new CdpError("Page.getLayoutMetrics returned no content size");
|
|
767
|
+
return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height), scale: 1 };
|
|
768
|
+
}
|
|
769
|
+
async function elementClip(conn, uid, selector) {
|
|
770
|
+
let backendNodeId;
|
|
771
|
+
if (uid != null) {
|
|
772
|
+
backendNodeId = uid;
|
|
773
|
+
} else if (selector) {
|
|
774
|
+
const { root } = await conn.send("DOM.getDocument", { depth: 0 });
|
|
775
|
+
const { nodeId } = await conn.send("DOM.querySelector", {
|
|
776
|
+
nodeId: root.nodeId,
|
|
777
|
+
selector
|
|
778
|
+
});
|
|
779
|
+
if (!nodeId)
|
|
780
|
+
throw new CdpError(`selector matched no element: ${selector}`);
|
|
781
|
+
const desc = await conn.send("DOM.describeNode", { nodeId });
|
|
782
|
+
backendNodeId = desc.node.backendNodeId;
|
|
783
|
+
} else {
|
|
784
|
+
throw new CdpError("elementClip requires uid or selector");
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId });
|
|
788
|
+
} catch {}
|
|
789
|
+
const box = await conn.send("DOM.getBoxModel", { backendNodeId });
|
|
790
|
+
const q = box.model.content;
|
|
791
|
+
const q0 = q[0];
|
|
792
|
+
const q1 = q[1];
|
|
793
|
+
if (q0 == null || q1 == null)
|
|
794
|
+
throw new CdpError("DOM.getBoxModel returned an empty content quad");
|
|
795
|
+
const xs = [q[0], q[2], q[4], q[6]].filter((n) => n != null);
|
|
796
|
+
const ys = [q[1], q[3], q[5], q[7]].filter((n) => n != null);
|
|
797
|
+
const minX = Math.min(...xs);
|
|
798
|
+
const minY = Math.min(...ys);
|
|
799
|
+
const maxX = Math.max(...xs);
|
|
800
|
+
const maxY = Math.max(...ys);
|
|
801
|
+
const width = Math.ceil(maxX - minX);
|
|
802
|
+
const height = Math.ceil(maxY - minY);
|
|
803
|
+
if (width <= 0 || height <= 0)
|
|
804
|
+
throw new CdpError("resolved element has zero area");
|
|
805
|
+
return { x: minX, y: minY, width, height, scale: 1 };
|
|
806
|
+
}
|
|
807
|
+
async function takeScreenshot(args = {}) {
|
|
808
|
+
const { target, fullPage, uid, selector, savePath, returnBase64 } = args;
|
|
809
|
+
if (uid != null && selector)
|
|
810
|
+
throw new CdpError("provide exactly one of uid or selector, not both");
|
|
811
|
+
const format = args.format ?? "png";
|
|
812
|
+
const quality = format === "jpeg" ? args.quality ?? 80 : undefined;
|
|
813
|
+
return withPage(target, async (conn, t) => {
|
|
814
|
+
await conn.send("Page.enable");
|
|
815
|
+
const params = { format, captureBeyondViewport: true };
|
|
816
|
+
if (quality != null)
|
|
817
|
+
params.quality = quality;
|
|
818
|
+
if (uid != null || selector) {
|
|
819
|
+
params.clip = await elementClip(conn, uid, selector);
|
|
820
|
+
} else if (fullPage) {
|
|
821
|
+
params.clip = await fullPageClip(conn);
|
|
822
|
+
}
|
|
823
|
+
const { data } = await conn.send("Page.captureScreenshot", params);
|
|
824
|
+
const buf = Buffer.from(data, "base64");
|
|
825
|
+
if (buf.byteLength === 0)
|
|
826
|
+
throw new CdpError("Page.captureScreenshot returned empty data");
|
|
827
|
+
await mkdir2(ARTIFACT_DIR, { recursive: true });
|
|
828
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
829
|
+
const path = savePath ?? join(ARTIFACT_DIR, `screenshot-${shortId(t.id)}-${stamp()}.${ext}`);
|
|
830
|
+
await writeFile2(path, buf);
|
|
831
|
+
const result = {
|
|
832
|
+
path,
|
|
833
|
+
bytes: buf.byteLength,
|
|
834
|
+
format,
|
|
835
|
+
target: { id: t.id, url: t.url, title: t.title }
|
|
836
|
+
};
|
|
837
|
+
if (returnBase64)
|
|
838
|
+
result.base64 = data;
|
|
839
|
+
return result;
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/tools/emulation.ts
|
|
844
|
+
async function clearAll(conn) {
|
|
845
|
+
await conn.send("Emulation.clearDeviceMetricsOverride").catch(() => {
|
|
846
|
+
return;
|
|
847
|
+
});
|
|
848
|
+
await conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(() => {
|
|
849
|
+
return;
|
|
850
|
+
});
|
|
851
|
+
await conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => {
|
|
852
|
+
return;
|
|
853
|
+
});
|
|
854
|
+
await conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(() => {
|
|
855
|
+
return;
|
|
856
|
+
});
|
|
857
|
+
await conn.send("Network.enable").catch(() => {
|
|
858
|
+
return;
|
|
859
|
+
});
|
|
860
|
+
await conn.send("Network.emulateNetworkConditions", {
|
|
861
|
+
offline: false,
|
|
862
|
+
latency: 0,
|
|
863
|
+
downloadThroughput: -1,
|
|
864
|
+
uploadThroughput: -1
|
|
865
|
+
}).catch(() => {
|
|
866
|
+
return;
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
async function emulate(args = {}) {
|
|
870
|
+
const { target } = args;
|
|
871
|
+
return withPage(target, async (conn, t) => {
|
|
872
|
+
if (args.clearOverrides) {
|
|
873
|
+
await clearAll(conn);
|
|
874
|
+
return {
|
|
875
|
+
target: { id: t.id, url: t.url, title: t.title },
|
|
876
|
+
applied: [],
|
|
877
|
+
cleared: true
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const applied = [];
|
|
881
|
+
if (args.width != null || args.height != null) {
|
|
882
|
+
if (args.width == null || args.height == null) {
|
|
883
|
+
throw new CdpError("emulate device metrics require both width and height");
|
|
884
|
+
}
|
|
885
|
+
await conn.send("Emulation.setDeviceMetricsOverride", {
|
|
886
|
+
width: args.width,
|
|
887
|
+
height: args.height,
|
|
888
|
+
deviceScaleFactor: args.deviceScaleFactor ?? 0,
|
|
889
|
+
mobile: args.mobile ?? false
|
|
890
|
+
});
|
|
891
|
+
applied.push("deviceMetrics");
|
|
892
|
+
}
|
|
893
|
+
if (args.userAgent != null) {
|
|
894
|
+
await conn.send("Emulation.setUserAgentOverride", { userAgent: args.userAgent });
|
|
895
|
+
applied.push("userAgent");
|
|
896
|
+
}
|
|
897
|
+
if (args.cpuThrottlingRate != null) {
|
|
898
|
+
if (args.cpuThrottlingRate < 1)
|
|
899
|
+
throw new CdpError("cpuThrottlingRate must be >= 1");
|
|
900
|
+
await conn.send("Emulation.setCPUThrottlingRate", { rate: args.cpuThrottlingRate });
|
|
901
|
+
applied.push("cpuThrottlingRate");
|
|
902
|
+
}
|
|
903
|
+
if (args.media != null || args.mediaFeatures != null) {
|
|
904
|
+
await conn.send("Emulation.setEmulatedMedia", {
|
|
905
|
+
media: args.media ?? "",
|
|
906
|
+
features: args.mediaFeatures ?? []
|
|
907
|
+
});
|
|
908
|
+
applied.push("emulatedMedia");
|
|
909
|
+
}
|
|
910
|
+
if (args.networkConditions != null) {
|
|
911
|
+
const n = args.networkConditions;
|
|
912
|
+
await conn.send("Network.enable").catch(() => {
|
|
913
|
+
return;
|
|
914
|
+
});
|
|
915
|
+
await conn.send("Network.emulateNetworkConditions", {
|
|
916
|
+
offline: n.offline ?? false,
|
|
917
|
+
latency: n.latency ?? 0,
|
|
918
|
+
downloadThroughput: n.downloadThroughput ?? -1,
|
|
919
|
+
uploadThroughput: n.uploadThroughput ?? -1,
|
|
920
|
+
...n.connectionType ? { connectionType: n.connectionType } : {}
|
|
921
|
+
});
|
|
922
|
+
applied.push("networkConditions");
|
|
923
|
+
}
|
|
924
|
+
if (applied.length === 0) {
|
|
925
|
+
throw new CdpError("emulate: no overrides specified (pass clearOverrides:true to reset)");
|
|
926
|
+
}
|
|
927
|
+
return { target: { id: t.id, url: t.url, title: t.title }, applied };
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
async function resizePage(args) {
|
|
931
|
+
const { target, width, height } = args;
|
|
932
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
933
|
+
throw new CdpError("resize_page requires positive numeric width and height");
|
|
934
|
+
}
|
|
935
|
+
return withPage(target, async (conn, t) => {
|
|
936
|
+
await conn.send("Emulation.setDeviceMetricsOverride", {
|
|
937
|
+
width,
|
|
938
|
+
height,
|
|
939
|
+
deviceScaleFactor: args.deviceScaleFactor ?? 0,
|
|
940
|
+
mobile: args.mobile ?? false
|
|
941
|
+
});
|
|
942
|
+
await conn.send("Runtime.enable").catch(() => {
|
|
943
|
+
return;
|
|
944
|
+
});
|
|
945
|
+
const { result } = await conn.send("Runtime.evaluate", {
|
|
946
|
+
expression: "({ w: window.innerWidth, h: window.innerHeight })",
|
|
947
|
+
returnByValue: true
|
|
948
|
+
});
|
|
949
|
+
const v = result.value;
|
|
950
|
+
return {
|
|
951
|
+
target: { id: t.id, url: t.url, title: t.title },
|
|
952
|
+
width,
|
|
953
|
+
height,
|
|
954
|
+
innerWidth: v?.w ?? width,
|
|
955
|
+
innerHeight: v?.h ?? height
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// src/tools/dialogs.ts
|
|
961
|
+
function armDialogHandler(conn, accept, promptText, onHandled) {
|
|
962
|
+
return conn.on("Page.javascriptDialogOpening", (params) => {
|
|
963
|
+
const ev = params;
|
|
964
|
+
(async () => {
|
|
965
|
+
try {
|
|
966
|
+
await conn.send("Page.handleJavaScriptDialog", {
|
|
967
|
+
accept,
|
|
968
|
+
...promptText !== undefined ? { promptText } : {}
|
|
969
|
+
});
|
|
970
|
+
onHandled({
|
|
971
|
+
type: ev.type,
|
|
972
|
+
message: ev.message,
|
|
973
|
+
url: ev.url,
|
|
974
|
+
...ev.defaultPrompt !== undefined ? { defaultPrompt: ev.defaultPrompt } : {},
|
|
975
|
+
accept,
|
|
976
|
+
...promptText !== undefined ? { promptText } : {},
|
|
977
|
+
handled: true
|
|
978
|
+
});
|
|
979
|
+
} catch {}
|
|
980
|
+
})();
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
async function handleDialog(args) {
|
|
984
|
+
const { target, accept, promptText, timeoutMs = 15000, autoMs } = args;
|
|
985
|
+
const { conn } = await openPage(target);
|
|
986
|
+
try {
|
|
987
|
+
await conn.send("Page.enable");
|
|
988
|
+
if (autoMs !== undefined) {
|
|
989
|
+
const handled = [];
|
|
990
|
+
const disarm = armDialogHandler(conn, accept, promptText, (d) => handled.push(d));
|
|
991
|
+
await new Promise((resolve) => setTimeout(resolve, autoMs));
|
|
992
|
+
disarm();
|
|
993
|
+
return { handled, count: handled.length };
|
|
994
|
+
}
|
|
995
|
+
return await new Promise((resolve, reject) => {
|
|
996
|
+
const timer = setTimeout(() => {
|
|
997
|
+
disarm();
|
|
998
|
+
reject(new CdpError(`handle_dialog: no dialog opened within ${timeoutMs}ms`));
|
|
999
|
+
}, timeoutMs);
|
|
1000
|
+
const disarm = armDialogHandler(conn, accept, promptText, (d) => {
|
|
1001
|
+
clearTimeout(timer);
|
|
1002
|
+
disarm();
|
|
1003
|
+
resolve(d);
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
} finally {
|
|
1007
|
+
conn.close();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/tools/console.ts
|
|
1012
|
+
import { readFile } from "node:fs/promises";
|
|
1013
|
+
|
|
1014
|
+
// src/tools/recorder.ts
|
|
1015
|
+
import { mkdir as mkdir3, appendFile, writeFile as writeFile3, copyFile } from "node:fs/promises";
|
|
1016
|
+
var ARTIFACT_DIR2 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1017
|
+
function recFile(targetId) {
|
|
1018
|
+
return `${ARTIFACT_DIR2}/rec-${targetId}.jsonl`;
|
|
1019
|
+
}
|
|
1020
|
+
function captureFile(targetId, captureId) {
|
|
1021
|
+
return `${ARTIFACT_DIR2}/rec-${targetId}-${captureId}.jsonl`;
|
|
1022
|
+
}
|
|
1023
|
+
function delay2(ms) {
|
|
1024
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1025
|
+
}
|
|
1026
|
+
var NETWORK_EVENTS = [
|
|
1027
|
+
"Network.requestWillBeSent",
|
|
1028
|
+
"Network.responseReceived",
|
|
1029
|
+
"Network.loadingFinished",
|
|
1030
|
+
"Network.loadingFailed"
|
|
1031
|
+
];
|
|
1032
|
+
var CONSOLE_EVENTS = ["Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Log.entryAdded"];
|
|
1033
|
+
async function startRecorder(target, opts = {}) {
|
|
1034
|
+
const network = opts.network ?? true;
|
|
1035
|
+
const consoleCap = opts.console ?? true;
|
|
1036
|
+
await mkdir3(ARTIFACT_DIR2, { recursive: true });
|
|
1037
|
+
const { conn, target: resolved } = await openPage(target, { timeoutMs: opts.timeoutMs });
|
|
1038
|
+
const file = opts.file ?? recFile(resolved.id);
|
|
1039
|
+
if (opts.truncate) {
|
|
1040
|
+
await writeFile3(file, "");
|
|
1041
|
+
}
|
|
1042
|
+
let chain = Promise.resolve();
|
|
1043
|
+
let dropped = 0;
|
|
1044
|
+
const persist = (kind, method, params) => {
|
|
1045
|
+
const line = { kind, method, ts: Date.now(), params };
|
|
1046
|
+
const text = `${JSON.stringify(line)}
|
|
1047
|
+
`;
|
|
1048
|
+
chain = chain.then(() => appendFile(file, text)).catch(() => {
|
|
1049
|
+
dropped += 1;
|
|
1050
|
+
});
|
|
1051
|
+
};
|
|
1052
|
+
const unsubs = [];
|
|
1053
|
+
try {
|
|
1054
|
+
if (network) {
|
|
1055
|
+
await conn.send("Network.enable");
|
|
1056
|
+
for (const ev of NETWORK_EVENTS) {
|
|
1057
|
+
unsubs.push(conn.on(ev, (params) => persist("network", ev, params)));
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (consoleCap) {
|
|
1061
|
+
await conn.send("Runtime.enable");
|
|
1062
|
+
try {
|
|
1063
|
+
await conn.send("Log.enable");
|
|
1064
|
+
} catch {}
|
|
1065
|
+
for (const ev of CONSOLE_EVENTS) {
|
|
1066
|
+
unsubs.push(conn.on(ev, (params) => persist("console", ev, params)));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
conn.close();
|
|
1071
|
+
throw err;
|
|
1072
|
+
}
|
|
1073
|
+
const flush = async () => {
|
|
1074
|
+
for (const off of unsubs)
|
|
1075
|
+
off();
|
|
1076
|
+
await chain;
|
|
1077
|
+
};
|
|
1078
|
+
return {
|
|
1079
|
+
file,
|
|
1080
|
+
target: resolved,
|
|
1081
|
+
conn,
|
|
1082
|
+
droppedWrites: () => dropped,
|
|
1083
|
+
flush,
|
|
1084
|
+
async stop() {
|
|
1085
|
+
await flush();
|
|
1086
|
+
conn.close();
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
async function captureWindow(target, durationMs, opts = {}) {
|
|
1091
|
+
const resolved = await resolveTarget(target);
|
|
1092
|
+
const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1093
|
+
const file = captureFile(resolved.id, captureId);
|
|
1094
|
+
const rec = await startRecorder(resolved.id, {
|
|
1095
|
+
network: true,
|
|
1096
|
+
console: true,
|
|
1097
|
+
truncate: true,
|
|
1098
|
+
file,
|
|
1099
|
+
timeoutMs: opts.timeoutMs
|
|
1100
|
+
});
|
|
1101
|
+
await rec.conn.send("Page.enable");
|
|
1102
|
+
await rec.conn.send("Page.reload", { ignoreCache: false });
|
|
1103
|
+
await delay2(durationMs);
|
|
1104
|
+
return {
|
|
1105
|
+
file: rec.file,
|
|
1106
|
+
conn: rec.conn,
|
|
1107
|
+
resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
|
|
1108
|
+
droppedWrites: rec.droppedWrites,
|
|
1109
|
+
flush: () => rec.flush(),
|
|
1110
|
+
async stop() {
|
|
1111
|
+
await rec.stop();
|
|
1112
|
+
try {
|
|
1113
|
+
await copyFile(rec.file, recFile(rec.target.id));
|
|
1114
|
+
} catch {}
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// src/tools/console.ts
|
|
1120
|
+
var DEFAULT_CAPTURE_MS = 2500;
|
|
1121
|
+
function remoteObjectToText(arg) {
|
|
1122
|
+
if (!arg)
|
|
1123
|
+
return "";
|
|
1124
|
+
if (arg.value !== undefined && arg.value !== null)
|
|
1125
|
+
return String(arg.value);
|
|
1126
|
+
if (typeof arg.description === "string")
|
|
1127
|
+
return arg.description;
|
|
1128
|
+
if (typeof arg.unserializableValue === "string")
|
|
1129
|
+
return arg.unserializableValue;
|
|
1130
|
+
if (arg.type === "undefined")
|
|
1131
|
+
return "undefined";
|
|
1132
|
+
if (arg.type === "object" && arg.subtype === "null")
|
|
1133
|
+
return "null";
|
|
1134
|
+
if (typeof arg.type === "string")
|
|
1135
|
+
return `[${arg.type}]`;
|
|
1136
|
+
return "";
|
|
1137
|
+
}
|
|
1138
|
+
function lineToEntry(line, index) {
|
|
1139
|
+
const p = line.params;
|
|
1140
|
+
if (line.method === "Runtime.consoleAPICalled") {
|
|
1141
|
+
const args = Array.isArray(p.args) ? p.args : [];
|
|
1142
|
+
return {
|
|
1143
|
+
index,
|
|
1144
|
+
source: "console",
|
|
1145
|
+
level: typeof p.type === "string" ? p.type : "log",
|
|
1146
|
+
text: args.map(remoteObjectToText).join(" "),
|
|
1147
|
+
url: undefined,
|
|
1148
|
+
ts: line.ts
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
if (line.method === "Runtime.exceptionThrown") {
|
|
1152
|
+
const detail = p.exceptionDetails ?? {};
|
|
1153
|
+
const exception = detail.exception ?? {};
|
|
1154
|
+
const text = remoteObjectToText(exception) || (typeof detail.text === "string" ? detail.text : "uncaught exception");
|
|
1155
|
+
return {
|
|
1156
|
+
index,
|
|
1157
|
+
source: "exception",
|
|
1158
|
+
level: "error",
|
|
1159
|
+
text,
|
|
1160
|
+
url: typeof detail.url === "string" ? detail.url : undefined,
|
|
1161
|
+
lineNumber: typeof detail.lineNumber === "number" ? detail.lineNumber : undefined,
|
|
1162
|
+
ts: line.ts
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (line.method === "Log.entryAdded") {
|
|
1166
|
+
const entry = p.entry ?? {};
|
|
1167
|
+
return {
|
|
1168
|
+
index,
|
|
1169
|
+
source: "log",
|
|
1170
|
+
level: typeof entry.level === "string" ? entry.level : "info",
|
|
1171
|
+
text: typeof entry.text === "string" ? entry.text : "",
|
|
1172
|
+
url: typeof entry.url === "string" ? entry.url : undefined,
|
|
1173
|
+
lineNumber: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined,
|
|
1174
|
+
ts: line.ts
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
async function readConsoleEntries(file) {
|
|
1180
|
+
let raw;
|
|
1181
|
+
try {
|
|
1182
|
+
raw = await readFile(file, "utf8");
|
|
1183
|
+
} catch {
|
|
1184
|
+
return [];
|
|
1185
|
+
}
|
|
1186
|
+
const out = [];
|
|
1187
|
+
for (const rawLine of raw.split(`
|
|
1188
|
+
`)) {
|
|
1189
|
+
const trimmed = rawLine.trim();
|
|
1190
|
+
if (!trimmed)
|
|
1191
|
+
continue;
|
|
1192
|
+
let line;
|
|
1193
|
+
try {
|
|
1194
|
+
line = JSON.parse(trimmed);
|
|
1195
|
+
} catch {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (line.kind !== "console")
|
|
1199
|
+
continue;
|
|
1200
|
+
const entry = lineToEntry(line, out.length);
|
|
1201
|
+
if (entry)
|
|
1202
|
+
out.push(entry);
|
|
1203
|
+
}
|
|
1204
|
+
return out;
|
|
1205
|
+
}
|
|
1206
|
+
async function listConsoleMessages(args = {}) {
|
|
1207
|
+
if (args.reload) {
|
|
1208
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
1209
|
+
await cap.stop();
|
|
1210
|
+
const messages2 = await readConsoleEntries(cap.file);
|
|
1211
|
+
return {
|
|
1212
|
+
target: cap.resolved,
|
|
1213
|
+
count: messages2.length,
|
|
1214
|
+
messages: messages2,
|
|
1215
|
+
droppedWrites: cap.droppedWrites()
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const target = await resolveTarget(args.target);
|
|
1219
|
+
const messages = await readConsoleEntries(recFile(target.id));
|
|
1220
|
+
return {
|
|
1221
|
+
target: { id: target.id, url: target.url, title: target.title },
|
|
1222
|
+
count: messages.length,
|
|
1223
|
+
messages
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
async function getConsoleMessage(args = {}) {
|
|
1227
|
+
const target = await resolveTarget(args.target);
|
|
1228
|
+
const messages = await readConsoleEntries(recFile(target.id));
|
|
1229
|
+
const index = args.index ?? 0;
|
|
1230
|
+
const entry = messages[index];
|
|
1231
|
+
if (!entry) {
|
|
1232
|
+
throw new CdpError(`no console message at index ${index} (have ${messages.length}); run list_console_messages or list with reload:true first`);
|
|
1233
|
+
}
|
|
1234
|
+
return entry;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// src/tools/network.ts
|
|
1238
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
1239
|
+
var DEFAULT_CAPTURE_MS2 = 2500;
|
|
1240
|
+
function asHeaders(v) {
|
|
1241
|
+
if (!v || typeof v !== "object")
|
|
1242
|
+
return;
|
|
1243
|
+
const out = {};
|
|
1244
|
+
for (const [k, val] of Object.entries(v))
|
|
1245
|
+
out[k] = String(val);
|
|
1246
|
+
return out;
|
|
1247
|
+
}
|
|
1248
|
+
function correlate(lines) {
|
|
1249
|
+
const byId = new Map;
|
|
1250
|
+
const order = [];
|
|
1251
|
+
for (const line of lines) {
|
|
1252
|
+
if (line.kind !== "network")
|
|
1253
|
+
continue;
|
|
1254
|
+
const p = line.params;
|
|
1255
|
+
const requestId = typeof p.requestId === "string" ? p.requestId : undefined;
|
|
1256
|
+
if (!requestId)
|
|
1257
|
+
continue;
|
|
1258
|
+
if (line.method === "Network.requestWillBeSent") {
|
|
1259
|
+
const req = p.request ?? {};
|
|
1260
|
+
if (!byId.has(requestId))
|
|
1261
|
+
order.push(requestId);
|
|
1262
|
+
const existing = byId.get(requestId);
|
|
1263
|
+
const row = existing ?? {
|
|
1264
|
+
requestId,
|
|
1265
|
+
url: typeof req.url === "string" ? req.url : "",
|
|
1266
|
+
method: typeof req.method === "string" ? req.method : "GET",
|
|
1267
|
+
resourceType: typeof p.type === "string" ? p.type : undefined,
|
|
1268
|
+
requestHeaders: asHeaders(req.headers),
|
|
1269
|
+
state: "pending",
|
|
1270
|
+
ts: line.ts
|
|
1271
|
+
};
|
|
1272
|
+
byId.set(requestId, row);
|
|
1273
|
+
} else if (line.method === "Network.responseReceived") {
|
|
1274
|
+
const resp = p.response ?? {};
|
|
1275
|
+
const row = byId.get(requestId);
|
|
1276
|
+
if (row) {
|
|
1277
|
+
row.status = typeof resp.status === "number" ? resp.status : row.status;
|
|
1278
|
+
row.statusText = typeof resp.statusText === "string" ? resp.statusText : row.statusText;
|
|
1279
|
+
row.mimeType = typeof resp.mimeType === "string" ? resp.mimeType : row.mimeType;
|
|
1280
|
+
row.responseHeaders = asHeaders(resp.headers) ?? row.responseHeaders;
|
|
1281
|
+
if (typeof p.type === "string")
|
|
1282
|
+
row.resourceType = p.type;
|
|
1283
|
+
}
|
|
1284
|
+
} else if (line.method === "Network.loadingFinished") {
|
|
1285
|
+
const row = byId.get(requestId);
|
|
1286
|
+
if (row) {
|
|
1287
|
+
row.state = "finished";
|
|
1288
|
+
if (typeof p.encodedDataLength === "number")
|
|
1289
|
+
row.encodedDataLength = p.encodedDataLength;
|
|
1290
|
+
}
|
|
1291
|
+
} else if (line.method === "Network.loadingFailed") {
|
|
1292
|
+
const row = byId.get(requestId);
|
|
1293
|
+
if (row) {
|
|
1294
|
+
row.state = "failed";
|
|
1295
|
+
if (typeof p.errorText === "string")
|
|
1296
|
+
row.errorText = p.errorText;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return order.map((id) => byId.get(id)).filter(Boolean);
|
|
1301
|
+
}
|
|
1302
|
+
async function readNetworkRequests(file) {
|
|
1303
|
+
let raw;
|
|
1304
|
+
try {
|
|
1305
|
+
raw = await readFile2(file, "utf8");
|
|
1306
|
+
} catch {
|
|
1307
|
+
return [];
|
|
1308
|
+
}
|
|
1309
|
+
const lines = [];
|
|
1310
|
+
for (const rawLine of raw.split(`
|
|
1311
|
+
`)) {
|
|
1312
|
+
const trimmed = rawLine.trim();
|
|
1313
|
+
if (!trimmed)
|
|
1314
|
+
continue;
|
|
1315
|
+
try {
|
|
1316
|
+
lines.push(JSON.parse(trimmed));
|
|
1317
|
+
} catch {}
|
|
1318
|
+
}
|
|
1319
|
+
return correlate(lines);
|
|
1320
|
+
}
|
|
1321
|
+
async function listNetworkRequests(args = {}) {
|
|
1322
|
+
let file;
|
|
1323
|
+
let resolved;
|
|
1324
|
+
let droppedWrites;
|
|
1325
|
+
if (args.reload) {
|
|
1326
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
1327
|
+
await cap.stop();
|
|
1328
|
+
file = cap.file;
|
|
1329
|
+
resolved = cap.resolved;
|
|
1330
|
+
droppedWrites = cap.droppedWrites();
|
|
1331
|
+
} else {
|
|
1332
|
+
const target = await resolveTarget(args.target);
|
|
1333
|
+
file = recFile(target.id);
|
|
1334
|
+
resolved = { id: target.id, url: target.url, title: target.title };
|
|
1335
|
+
}
|
|
1336
|
+
let requests = await readNetworkRequests(file);
|
|
1337
|
+
if (args.filterUrl) {
|
|
1338
|
+
const needle = args.filterUrl;
|
|
1339
|
+
requests = requests.filter((r) => r.url.includes(needle));
|
|
1340
|
+
}
|
|
1341
|
+
return { target: resolved, count: requests.length, requests, droppedWrites };
|
|
1342
|
+
}
|
|
1343
|
+
async function getNetworkRequest(args = {}) {
|
|
1344
|
+
if (!args.requestId && !args.url) {
|
|
1345
|
+
throw new CdpError("get_network_request requires either requestId or url");
|
|
1346
|
+
}
|
|
1347
|
+
const pick = (requests) => {
|
|
1348
|
+
if (args.requestId)
|
|
1349
|
+
return requests.find((r) => r.requestId === args.requestId);
|
|
1350
|
+
return requests.find((r) => r.url.includes(args.url));
|
|
1351
|
+
};
|
|
1352
|
+
const describeSelector = () => args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
|
|
1353
|
+
if (args.includeBody && args.url) {
|
|
1354
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
1355
|
+
try {
|
|
1356
|
+
await cap.flush();
|
|
1357
|
+
const match2 = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url));
|
|
1358
|
+
if (!match2) {
|
|
1359
|
+
throw new CdpError(`no network request matched ${describeSelector()} in fresh capture`);
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
const { body, base64Encoded } = await cap.conn.send("Network.getResponseBody", { requestId: match2.requestId });
|
|
1363
|
+
return { ...match2, body, bodyBase64Encoded: base64Encoded };
|
|
1364
|
+
} catch {
|
|
1365
|
+
return { ...match2, body: undefined, bodyBase64Encoded: undefined };
|
|
1366
|
+
}
|
|
1367
|
+
} finally {
|
|
1368
|
+
await cap.stop();
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const target = await resolveTarget(args.target);
|
|
1372
|
+
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
1373
|
+
if (!match) {
|
|
1374
|
+
throw new CdpError(`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` + (args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``));
|
|
1375
|
+
}
|
|
1376
|
+
if (args.includeBody && args.requestId) {
|
|
1377
|
+
return {
|
|
1378
|
+
...match,
|
|
1379
|
+
body: undefined,
|
|
1380
|
+
bodyUnavailableReason: "response bodies are only fetchable via a fresh reload capture, which re-mints requestIds; re-request with the `url` selector to retrieve the body"
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
return match;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// src/tools/performance.ts
|
|
1387
|
+
import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile4, stat, unlink } from "node:fs/promises";
|
|
1388
|
+
import { join as join2 } from "node:path";
|
|
1389
|
+
var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1390
|
+
var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
1391
|
+
var DEFAULT_CATEGORIES = [
|
|
1392
|
+
"devtools.timeline",
|
|
1393
|
+
"blink.user_timing",
|
|
1394
|
+
"loading",
|
|
1395
|
+
"disabled-by-default-devtools.timeline",
|
|
1396
|
+
"disabled-by-default-devtools.timeline.frame"
|
|
1397
|
+
];
|
|
1398
|
+
var liveTraces = new Map;
|
|
1399
|
+
function stamp2() {
|
|
1400
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
1401
|
+
}
|
|
1402
|
+
function shortId2(id) {
|
|
1403
|
+
return id.slice(0, 8);
|
|
1404
|
+
}
|
|
1405
|
+
function stateFilePath() {
|
|
1406
|
+
return join2(STATE_DIR2, "trace-state.json");
|
|
1407
|
+
}
|
|
1408
|
+
function bufferTraceData(conn, sink) {
|
|
1409
|
+
return conn.on("Tracing.dataCollected", (params) => {
|
|
1410
|
+
const value = params.value;
|
|
1411
|
+
if (Array.isArray(value))
|
|
1412
|
+
sink.push(...value);
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
async function beginTrace(conn, categories) {
|
|
1416
|
+
await conn.send("Tracing.start", {
|
|
1417
|
+
traceConfig: { includedCategories: categories },
|
|
1418
|
+
transferMode: "ReportEvents"
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
async function endTrace(conn, sink, timeoutMs = 30000) {
|
|
1422
|
+
const complete = conn.waitFor("Tracing.tracingComplete", undefined, timeoutMs);
|
|
1423
|
+
await conn.send("Tracing.end");
|
|
1424
|
+
await complete;
|
|
1425
|
+
return sink;
|
|
1426
|
+
}
|
|
1427
|
+
async function writeTrace(events, targetId) {
|
|
1428
|
+
await mkdir4(ARTIFACT_DIR3, { recursive: true });
|
|
1429
|
+
const path = join2(ARTIFACT_DIR3, `trace-${shortId2(targetId)}-${stamp2()}.json`);
|
|
1430
|
+
const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
|
|
1431
|
+
await writeFile4(path, body, "utf8");
|
|
1432
|
+
const { size } = await stat(path);
|
|
1433
|
+
return { path, bytes: size };
|
|
1434
|
+
}
|
|
1435
|
+
async function performanceStartTrace(args = {}) {
|
|
1436
|
+
const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
|
|
1437
|
+
const { conn, target } = await openPage(args.target);
|
|
1438
|
+
if (liveTraces.has(target.id)) {
|
|
1439
|
+
conn.close();
|
|
1440
|
+
throw new CdpError(`a trace is already in progress for target ${shortId2(target.id)}; call performanceStopTrace first`);
|
|
1441
|
+
}
|
|
1442
|
+
const events = [];
|
|
1443
|
+
bufferTraceData(conn, events);
|
|
1444
|
+
try {
|
|
1445
|
+
await beginTrace(conn, categories);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
conn.close();
|
|
1448
|
+
throw err;
|
|
1449
|
+
}
|
|
1450
|
+
liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
|
|
1451
|
+
await mkdir4(STATE_DIR2, { recursive: true });
|
|
1452
|
+
const state = {
|
|
1453
|
+
targetId: target.id,
|
|
1454
|
+
url: target.url,
|
|
1455
|
+
startedAt: Date.now(),
|
|
1456
|
+
pid: process.pid,
|
|
1457
|
+
categories
|
|
1458
|
+
};
|
|
1459
|
+
await writeFile4(stateFilePath(), JSON.stringify(state), "utf8");
|
|
1460
|
+
return {
|
|
1461
|
+
target: { id: target.id, url: target.url, title: target.title },
|
|
1462
|
+
categories,
|
|
1463
|
+
note: "Trace recording. Call performanceStopTrace in THIS process to finalize. Cross-process? use performanceTrace instead."
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
async function performanceStopTrace(args = {}) {
|
|
1467
|
+
args.target;
|
|
1468
|
+
const entry = liveTraces.size === 1 ? [...liveTraces.values()][0] : undefined;
|
|
1469
|
+
if (!entry) {
|
|
1470
|
+
let hint = "";
|
|
1471
|
+
try {
|
|
1472
|
+
const raw = await readFile3(stateFilePath(), "utf8");
|
|
1473
|
+
const state = JSON.parse(raw);
|
|
1474
|
+
hint = state.pid === process.pid ? ` (state file lists target ${shortId2(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
|
|
1475
|
+
} catch {}
|
|
1476
|
+
throw new CdpError(`no in-process trace to stop${hint}. Use performanceTrace for a robust single-call trace.`);
|
|
1477
|
+
}
|
|
1478
|
+
const { conn, target, events } = entry;
|
|
1479
|
+
try {
|
|
1480
|
+
await endTrace(conn, events);
|
|
1481
|
+
} finally {
|
|
1482
|
+
conn.close();
|
|
1483
|
+
liveTraces.delete(target.id);
|
|
1484
|
+
await unlink(stateFilePath()).catch(() => {});
|
|
1485
|
+
}
|
|
1486
|
+
const written = await writeTrace(events, target.id);
|
|
1487
|
+
const metrics = analyzeEvents(events);
|
|
1488
|
+
return { ...written, events: events.length, metrics };
|
|
1489
|
+
}
|
|
1490
|
+
async function performanceTrace(args = {}) {
|
|
1491
|
+
const durationMs = args.durationMs ?? 3000;
|
|
1492
|
+
const categories = args.categories?.length ? args.categories : DEFAULT_CATEGORIES;
|
|
1493
|
+
const { conn, target } = await openPage(args.target);
|
|
1494
|
+
const events = [];
|
|
1495
|
+
bufferTraceData(conn, events);
|
|
1496
|
+
try {
|
|
1497
|
+
await conn.send("Page.enable");
|
|
1498
|
+
await beginTrace(conn, categories);
|
|
1499
|
+
if (args.navigateTo) {
|
|
1500
|
+
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5000);
|
|
1501
|
+
await conn.send("Page.navigate", { url: args.navigateTo });
|
|
1502
|
+
await loaded.catch(() => {});
|
|
1503
|
+
} else if (args.reload) {
|
|
1504
|
+
const loaded = conn.waitFor("Page.loadEventFired", undefined, durationMs + 5000);
|
|
1505
|
+
await conn.send("Page.reload", { ignoreCache: false });
|
|
1506
|
+
await loaded.catch(() => {});
|
|
1507
|
+
}
|
|
1508
|
+
await new Promise((r) => setTimeout(r, durationMs));
|
|
1509
|
+
await endTrace(conn, events);
|
|
1510
|
+
} finally {
|
|
1511
|
+
conn.close();
|
|
1512
|
+
}
|
|
1513
|
+
const written = await writeTrace(events, target.id);
|
|
1514
|
+
const metrics = analyzeEvents(events);
|
|
1515
|
+
return { ...written, events: events.length, metrics, target: { id: target.id, url: target.url } };
|
|
1516
|
+
}
|
|
1517
|
+
function analyzeEvents(events) {
|
|
1518
|
+
let navigationStartUs;
|
|
1519
|
+
let fcpUs;
|
|
1520
|
+
let lcpUs;
|
|
1521
|
+
let dclUs;
|
|
1522
|
+
let loadUs;
|
|
1523
|
+
let cls = 0;
|
|
1524
|
+
let layoutShifts = 0;
|
|
1525
|
+
let longTasks = 0;
|
|
1526
|
+
let longTasksTotalMs = 0;
|
|
1527
|
+
let totalBlockingTimeMs = 0;
|
|
1528
|
+
for (const e of events) {
|
|
1529
|
+
const name = e.name;
|
|
1530
|
+
if (!name)
|
|
1531
|
+
continue;
|
|
1532
|
+
if (name === "navigationStart") {
|
|
1533
|
+
if (typeof e.ts === "number" && (navigationStartUs === undefined || e.ts < navigationStartUs)) {
|
|
1534
|
+
navigationStartUs = e.ts;
|
|
1535
|
+
}
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
if ((name === "firstContentfulPaint" || name === "FirstContentfulPaint") && typeof e.ts === "number") {
|
|
1539
|
+
if (fcpUs === undefined || e.ts < fcpUs)
|
|
1540
|
+
fcpUs = e.ts;
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
if (name === "largestContentfulPaint::Candidate" && typeof e.ts === "number") {
|
|
1544
|
+
if (lcpUs === undefined || e.ts > lcpUs)
|
|
1545
|
+
lcpUs = e.ts;
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
if ((name === "domContentLoadedEventEnd" || name === "DOMContentLoaded") && typeof e.ts === "number") {
|
|
1549
|
+
if (dclUs === undefined || e.ts > dclUs)
|
|
1550
|
+
dclUs = e.ts;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if ((name === "loadEventEnd" || name === "MarkLoad") && typeof e.ts === "number") {
|
|
1554
|
+
if (loadUs === undefined || e.ts > loadUs)
|
|
1555
|
+
loadUs = e.ts;
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
if (name === "LayoutShift") {
|
|
1559
|
+
const data = e.args?.data;
|
|
1560
|
+
const hadRecentInput = data?.had_recent_input === true;
|
|
1561
|
+
const score = typeof data?.score === "number" ? data.score : undefined;
|
|
1562
|
+
if (!hadRecentInput && typeof score === "number") {
|
|
1563
|
+
cls += score;
|
|
1564
|
+
layoutShifts += 1;
|
|
1565
|
+
}
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (name === "RunTask" && typeof e.dur === "number") {
|
|
1569
|
+
const durMs = e.dur / 1000;
|
|
1570
|
+
if (durMs > 50) {
|
|
1571
|
+
longTasks += 1;
|
|
1572
|
+
longTasksTotalMs += durMs;
|
|
1573
|
+
totalBlockingTimeMs += durMs - 50;
|
|
1574
|
+
}
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
const relMs = (us) => us !== undefined && navigationStartUs !== undefined ? (us - navigationStartUs) / 1000 : undefined;
|
|
1579
|
+
const metrics = {
|
|
1580
|
+
navigationStartUs,
|
|
1581
|
+
fcpMs: relMs(fcpUs),
|
|
1582
|
+
lcpMs: relMs(lcpUs),
|
|
1583
|
+
domContentLoadedMs: relMs(dclUs),
|
|
1584
|
+
loadMs: relMs(loadUs),
|
|
1585
|
+
cls: layoutShifts > 0 ? Number(cls.toFixed(4)) : 0,
|
|
1586
|
+
totalBlockingTimeMs: Number(totalBlockingTimeMs.toFixed(2))
|
|
1587
|
+
};
|
|
1588
|
+
return {
|
|
1589
|
+
metrics,
|
|
1590
|
+
longTasks,
|
|
1591
|
+
longTasksTotalMs: Number(longTasksTotalMs.toFixed(2)),
|
|
1592
|
+
layoutShifts,
|
|
1593
|
+
eventCount: events.length
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
async function performanceAnalyzeInsight(args = {}) {
|
|
1597
|
+
if (!args.tracePath) {
|
|
1598
|
+
throw new CdpError("performanceAnalyzeInsight requires { tracePath } pointing at a trace JSON file");
|
|
1599
|
+
}
|
|
1600
|
+
let raw;
|
|
1601
|
+
try {
|
|
1602
|
+
raw = await readFile3(args.tracePath, "utf8");
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
|
|
1605
|
+
}
|
|
1606
|
+
let parsed;
|
|
1607
|
+
try {
|
|
1608
|
+
parsed = JSON.parse(raw);
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
throw new CdpError(`trace file '${args.tracePath}' is not valid JSON: ${err.message}`);
|
|
1611
|
+
}
|
|
1612
|
+
const events = Array.isArray(parsed) ? parsed : Array.isArray(parsed.traceEvents) ? parsed.traceEvents : (() => {
|
|
1613
|
+
throw new CdpError(`trace file '${args.tracePath}' has no traceEvents array`);
|
|
1614
|
+
})();
|
|
1615
|
+
return { ...analyzeEvents(events), tracePath: args.tracePath };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// src/tools/heap.ts
|
|
1619
|
+
import { mkdir as mkdir5, writeFile as writeFile5 } from "node:fs/promises";
|
|
1620
|
+
import { join as join3 } from "node:path";
|
|
1621
|
+
var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1622
|
+
function stamp3(targetId) {
|
|
1623
|
+
const short = targetId.slice(0, 8);
|
|
1624
|
+
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1625
|
+
return `${short}-${iso}`;
|
|
1626
|
+
}
|
|
1627
|
+
async function takeHeapsnapshot(args = {}) {
|
|
1628
|
+
return withPage(args.target, async (conn, target) => {
|
|
1629
|
+
await conn.send("HeapProfiler.enable");
|
|
1630
|
+
const parts = [];
|
|
1631
|
+
const off = conn.on("HeapProfiler.addHeapSnapshotChunk", (params) => {
|
|
1632
|
+
const chunk = params.chunk;
|
|
1633
|
+
if (typeof chunk === "string")
|
|
1634
|
+
parts.push(chunk);
|
|
1635
|
+
});
|
|
1636
|
+
try {
|
|
1637
|
+
await conn.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false, captureNumericValue: false }, { timeoutMs: 120000 });
|
|
1638
|
+
} finally {
|
|
1639
|
+
off();
|
|
1640
|
+
}
|
|
1641
|
+
const snapshot = parts.join("");
|
|
1642
|
+
if (snapshot.length === 0) {
|
|
1643
|
+
throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
|
|
1644
|
+
}
|
|
1645
|
+
await mkdir5(ARTIFACT_DIR4, { recursive: true });
|
|
1646
|
+
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join3(ARTIFACT_DIR4, args.savePath) : join3(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
|
|
1647
|
+
await writeFile5(path, snapshot, "utf8");
|
|
1648
|
+
const bytes = Buffer.byteLength(snapshot, "utf8");
|
|
1649
|
+
return {
|
|
1650
|
+
path,
|
|
1651
|
+
bytes,
|
|
1652
|
+
chunks: parts.length,
|
|
1653
|
+
target: { id: target.id, url: target.url, title: target.title }
|
|
1654
|
+
};
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// src/tools/lighthouse.ts
|
|
1659
|
+
import { spawn } from "node:child_process";
|
|
1660
|
+
import { mkdir as mkdir6, readFile as readFile4, stat as stat2 } from "node:fs/promises";
|
|
1661
|
+
var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1662
|
+
function cdpPort() {
|
|
1663
|
+
const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
1664
|
+
const m = base.match(/:(\d+)/);
|
|
1665
|
+
return m?.[1] ? Number(m[1]) : 9222;
|
|
1666
|
+
}
|
|
1667
|
+
function stamp4() {
|
|
1668
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
1669
|
+
}
|
|
1670
|
+
function shortHost(url) {
|
|
1671
|
+
try {
|
|
1672
|
+
const u = new URL(url);
|
|
1673
|
+
const h = u.hostname || u.protocol.replace(/:$/, "");
|
|
1674
|
+
return (h || "page").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 24);
|
|
1675
|
+
} catch {
|
|
1676
|
+
return "page";
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
function runProcess(cmd, args, opts) {
|
|
1680
|
+
return new Promise((resolve) => {
|
|
1681
|
+
let stdout = "";
|
|
1682
|
+
let stderr = "";
|
|
1683
|
+
let settled = false;
|
|
1684
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1685
|
+
const timer = setTimeout(() => {
|
|
1686
|
+
if (settled)
|
|
1687
|
+
return;
|
|
1688
|
+
try {
|
|
1689
|
+
child.kill("SIGKILL");
|
|
1690
|
+
} catch {}
|
|
1691
|
+
}, opts.timeoutMs);
|
|
1692
|
+
child.stdout.on("data", (d) => {
|
|
1693
|
+
stdout += d.toString();
|
|
1694
|
+
});
|
|
1695
|
+
child.stderr.on("data", (d) => {
|
|
1696
|
+
stderr += d.toString();
|
|
1697
|
+
});
|
|
1698
|
+
child.on("error", (err) => {
|
|
1699
|
+
if (settled)
|
|
1700
|
+
return;
|
|
1701
|
+
settled = true;
|
|
1702
|
+
clearTimeout(timer);
|
|
1703
|
+
resolve({ code: null, signal: null, stdout, stderr, spawnError: err });
|
|
1704
|
+
});
|
|
1705
|
+
child.on("close", (code, signal) => {
|
|
1706
|
+
if (settled)
|
|
1707
|
+
return;
|
|
1708
|
+
settled = true;
|
|
1709
|
+
clearTimeout(timer);
|
|
1710
|
+
resolve({ code, signal, stdout, stderr });
|
|
1711
|
+
});
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
async function lighthouseAudit(args) {
|
|
1715
|
+
if (!args || typeof args.url !== "string" || args.url.trim() === "") {
|
|
1716
|
+
throw new CdpError("lighthouse_audit requires a non-empty 'url'");
|
|
1717
|
+
}
|
|
1718
|
+
const url = args.url.trim();
|
|
1719
|
+
const timeoutMs = args.timeoutMs ?? 120000;
|
|
1720
|
+
const port = cdpPort();
|
|
1721
|
+
await mkdir6(ARTIFACT_DIR5, { recursive: true });
|
|
1722
|
+
const outPath = args.savePath ?? `${ARTIFACT_DIR5}/lighthouse-${shortHost(url)}-${stamp4()}.json`;
|
|
1723
|
+
const formFactor = args.formFactor ?? "desktop";
|
|
1724
|
+
const lhArgs = [
|
|
1725
|
+
"--yes",
|
|
1726
|
+
"lighthouse",
|
|
1727
|
+
url,
|
|
1728
|
+
`--port=${port}`,
|
|
1729
|
+
"--output=json",
|
|
1730
|
+
`--output-path=${outPath}`,
|
|
1731
|
+
"--quiet",
|
|
1732
|
+
"--chrome-flags=--headless=new"
|
|
1733
|
+
];
|
|
1734
|
+
if (formFactor === "desktop") {
|
|
1735
|
+
lhArgs.push("--preset=desktop");
|
|
1736
|
+
}
|
|
1737
|
+
if (Array.isArray(args.categories) && args.categories.length > 0) {
|
|
1738
|
+
lhArgs.push(`--only-categories=${args.categories.join(",")}`);
|
|
1739
|
+
}
|
|
1740
|
+
const res = await runProcess("npx", lhArgs, { timeoutMs });
|
|
1741
|
+
if (res.spawnError) {
|
|
1742
|
+
const code = res.spawnError.code;
|
|
1743
|
+
if (code === "ENOENT") {
|
|
1744
|
+
throw new CdpError("lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.");
|
|
1745
|
+
}
|
|
1746
|
+
throw new CdpError(`lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`);
|
|
1747
|
+
}
|
|
1748
|
+
let raw;
|
|
1749
|
+
try {
|
|
1750
|
+
raw = await readFile4(outPath, "utf8");
|
|
1751
|
+
} catch {
|
|
1752
|
+
raw = undefined;
|
|
1753
|
+
}
|
|
1754
|
+
if (raw === undefined) {
|
|
1755
|
+
if (res.signal === "SIGKILL") {
|
|
1756
|
+
throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
|
|
1757
|
+
}
|
|
1758
|
+
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx — ensure network access and that 'lighthouse' is installable." : "";
|
|
1759
|
+
throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
|
|
1760
|
+
}
|
|
1761
|
+
let report;
|
|
1762
|
+
try {
|
|
1763
|
+
report = JSON.parse(raw);
|
|
1764
|
+
} catch (e) {
|
|
1765
|
+
throw new CdpError(`lighthouse_audit wrote a report that is not valid JSON (${e.message})`);
|
|
1766
|
+
}
|
|
1767
|
+
if (report.runtimeError?.code && report.runtimeError.code !== "NO_ERROR") {
|
|
1768
|
+
throw new CdpError(`lighthouse_audit runtime error [${report.runtimeError.code}]: ${report.runtimeError.message ?? "unknown"}`);
|
|
1769
|
+
}
|
|
1770
|
+
const categories = {};
|
|
1771
|
+
for (const [key, cat] of Object.entries(report.categories ?? {})) {
|
|
1772
|
+
categories[key] = cat?.score ?? null;
|
|
1773
|
+
}
|
|
1774
|
+
if (Object.keys(categories).length === 0) {
|
|
1775
|
+
throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
|
|
1776
|
+
}
|
|
1777
|
+
const { size } = await stat2(outPath);
|
|
1778
|
+
return {
|
|
1779
|
+
path: outPath,
|
|
1780
|
+
bytes: size,
|
|
1781
|
+
url,
|
|
1782
|
+
finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
|
|
1783
|
+
lighthouseVersion: report.lighthouseVersion,
|
|
1784
|
+
fetchTime: report.fetchTime,
|
|
1785
|
+
categories,
|
|
1786
|
+
warnings: Array.isArray(report.runWarnings) && report.runWarnings.length > 0 ? report.runWarnings : undefined
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
function truncate(s, max = 600) {
|
|
1790
|
+
const t = s.trim();
|
|
1791
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// src/tools/network_mock.ts
|
|
1795
|
+
function escapeRegExpChar(ch) {
|
|
1796
|
+
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
1797
|
+
}
|
|
1798
|
+
function globToRegExp(pattern) {
|
|
1799
|
+
let re = "";
|
|
1800
|
+
for (let i = 0;i < pattern.length; i++) {
|
|
1801
|
+
const ch = pattern[i];
|
|
1802
|
+
if (ch === "\\") {
|
|
1803
|
+
const next = pattern[i + 1];
|
|
1804
|
+
if (next !== undefined) {
|
|
1805
|
+
re += escapeRegExpChar(next);
|
|
1806
|
+
i++;
|
|
1807
|
+
} else {
|
|
1808
|
+
re += "\\\\";
|
|
1809
|
+
}
|
|
1810
|
+
} else if (ch === "*") {
|
|
1811
|
+
re += ".*";
|
|
1812
|
+
} else if (ch === "?") {
|
|
1813
|
+
re += ".";
|
|
1814
|
+
} else {
|
|
1815
|
+
re += escapeRegExpChar(ch);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return new RegExp(`^${re}$`);
|
|
1819
|
+
}
|
|
1820
|
+
function urlMatches(pattern, url) {
|
|
1821
|
+
return globToRegExp(pattern).test(url);
|
|
1822
|
+
}
|
|
1823
|
+
function selectRule(rules, url, method) {
|
|
1824
|
+
return rules.find((r) => urlMatches(r.urlPattern, url) && (!r.method || r.method.toUpperCase() === method.toUpperCase()));
|
|
1825
|
+
}
|
|
1826
|
+
function buildFulfillParams(requestId, rule) {
|
|
1827
|
+
const merged = new Map;
|
|
1828
|
+
merged.set("content-type", { name: "Content-Type", value: rule.contentType ?? "application/json" });
|
|
1829
|
+
for (const [name, value] of Object.entries(rule.headers ?? {})) {
|
|
1830
|
+
merged.set(name.toLowerCase(), { name, value });
|
|
1831
|
+
}
|
|
1832
|
+
return {
|
|
1833
|
+
requestId,
|
|
1834
|
+
responseCode: rule.status ?? 200,
|
|
1835
|
+
responseHeaders: [...merged.values()],
|
|
1836
|
+
body: Buffer.from(rule.body ?? "", "utf8").toString("base64")
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
function effectiveAction(rule, rnd) {
|
|
1840
|
+
if (rule.failRate !== undefined && rule.failRate > 0 && rnd < rule.failRate)
|
|
1841
|
+
return "fail";
|
|
1842
|
+
return rule.action;
|
|
1843
|
+
}
|
|
1844
|
+
var sessions = new Map;
|
|
1845
|
+
function delay3(ms) {
|
|
1846
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1847
|
+
}
|
|
1848
|
+
function registerHandler(session) {
|
|
1849
|
+
session.conn.on("Fetch.requestPaused", (params) => {
|
|
1850
|
+
const p = params;
|
|
1851
|
+
session.chain = session.chain.then(async () => {
|
|
1852
|
+
const rule = selectRule(session.rules, p.request.url, p.request.method);
|
|
1853
|
+
if (!rule) {
|
|
1854
|
+
await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (rule.delayMs)
|
|
1858
|
+
await delay3(rule.delayMs);
|
|
1859
|
+
const act = effectiveAction(rule, Math.random());
|
|
1860
|
+
if (act === "fulfill") {
|
|
1861
|
+
await session.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
|
|
1862
|
+
} else if (act === "fail") {
|
|
1863
|
+
await session.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
|
|
1864
|
+
} else {
|
|
1865
|
+
await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
1866
|
+
}
|
|
1867
|
+
rule.hits++;
|
|
1868
|
+
session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
|
|
1869
|
+
}).catch((e) => {
|
|
1870
|
+
if (/closed|connection|Target|Inspected/i.test(String(e)))
|
|
1871
|
+
session.dead = true;
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
async function mockRequest(args) {
|
|
1876
|
+
if (!args.urlPattern)
|
|
1877
|
+
throw new CdpError("mock_request requires a { urlPattern }");
|
|
1878
|
+
const rule = {
|
|
1879
|
+
urlPattern: args.urlPattern,
|
|
1880
|
+
action: args.action ?? "fulfill",
|
|
1881
|
+
status: args.status,
|
|
1882
|
+
body: args.body,
|
|
1883
|
+
contentType: args.contentType,
|
|
1884
|
+
headers: args.headers,
|
|
1885
|
+
errorReason: args.errorReason,
|
|
1886
|
+
method: args.method,
|
|
1887
|
+
delayMs: args.delayMs,
|
|
1888
|
+
failRate: args.failRate,
|
|
1889
|
+
hits: 0
|
|
1890
|
+
};
|
|
1891
|
+
const resolved = await resolveTarget(args.target);
|
|
1892
|
+
let session = sessions.get(resolved.id);
|
|
1893
|
+
if (session && !session.dead) {
|
|
1894
|
+
const idx = session.rules.findIndex((r) => r.urlPattern === rule.urlPattern && (r.method ?? "") === (rule.method ?? ""));
|
|
1895
|
+
if (idx >= 0)
|
|
1896
|
+
session.rules[idx] = rule;
|
|
1897
|
+
else
|
|
1898
|
+
session.rules.push(rule);
|
|
1899
|
+
await session.conn.send("Fetch.enable", { patterns: session.rules.map((r) => ({ urlPattern: r.urlPattern })) });
|
|
1900
|
+
} else {
|
|
1901
|
+
const { conn, target } = await openPage(resolved.id);
|
|
1902
|
+
session = { conn, target, rules: [rule], intercepted: [], chain: Promise.resolve(), dead: false };
|
|
1903
|
+
registerHandler(session);
|
|
1904
|
+
await conn.send("Fetch.enable", { patterns: [{ urlPattern: rule.urlPattern }] });
|
|
1905
|
+
sessions.set(target.id, session);
|
|
1906
|
+
}
|
|
1907
|
+
let reloaded = false;
|
|
1908
|
+
if (args.reload) {
|
|
1909
|
+
await session.conn.send("Page.enable");
|
|
1910
|
+
await session.conn.send("Page.reload", { ignoreCache: true });
|
|
1911
|
+
await delay3(800);
|
|
1912
|
+
reloaded = true;
|
|
1913
|
+
}
|
|
1914
|
+
return {
|
|
1915
|
+
target: { id: session.target.id, url: session.target.url, title: session.target.title },
|
|
1916
|
+
pattern: rule.urlPattern,
|
|
1917
|
+
ruleCount: session.rules.length,
|
|
1918
|
+
reloaded
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
async function listMocks(_args = {}) {
|
|
1922
|
+
for (const [id, s] of sessions) {
|
|
1923
|
+
if (s.dead) {
|
|
1924
|
+
try {
|
|
1925
|
+
s.conn.close();
|
|
1926
|
+
} catch {}
|
|
1927
|
+
sessions.delete(id);
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
try {
|
|
1931
|
+
await s.conn.send("Runtime.evaluate", { expression: "1", returnByValue: true }, { timeoutMs: 3000 });
|
|
1932
|
+
} catch {
|
|
1933
|
+
try {
|
|
1934
|
+
s.conn.close();
|
|
1935
|
+
} catch {}
|
|
1936
|
+
sessions.delete(id);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
const mocks = [...sessions.values()].map((s) => ({
|
|
1940
|
+
target: { id: s.target.id, url: s.target.url, title: s.target.title },
|
|
1941
|
+
rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method, hits: r.hits })),
|
|
1942
|
+
hits: s.intercepted.length
|
|
1943
|
+
}));
|
|
1944
|
+
return { count: mocks.length, mocks };
|
|
1945
|
+
}
|
|
1946
|
+
async function clearMocks(args = {}) {
|
|
1947
|
+
let ids;
|
|
1948
|
+
if (args.all) {
|
|
1949
|
+
ids = [...sessions.keys()];
|
|
1950
|
+
} else {
|
|
1951
|
+
const resolved = await resolveTarget(args.target);
|
|
1952
|
+
ids = sessions.has(resolved.id) ? [resolved.id] : [];
|
|
1953
|
+
}
|
|
1954
|
+
let cleared = 0;
|
|
1955
|
+
for (const id of ids) {
|
|
1956
|
+
const s = sessions.get(id);
|
|
1957
|
+
if (!s)
|
|
1958
|
+
continue;
|
|
1959
|
+
try {
|
|
1960
|
+
await s.conn.send("Fetch.disable");
|
|
1961
|
+
} catch {}
|
|
1962
|
+
try {
|
|
1963
|
+
s.conn.close();
|
|
1964
|
+
} catch {}
|
|
1965
|
+
sessions.delete(id);
|
|
1966
|
+
cleared++;
|
|
1967
|
+
}
|
|
1968
|
+
return { cleared };
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// src/index.ts
|
|
1972
|
+
var TOOLS = {
|
|
1973
|
+
list_pages: listPages,
|
|
1974
|
+
new_page: newPage,
|
|
1975
|
+
close_page: closePage,
|
|
1976
|
+
select_page: selectPage,
|
|
1977
|
+
navigate_page: navigatePage,
|
|
1978
|
+
wait_for: waitForText,
|
|
1979
|
+
evaluate_script: evaluateScript,
|
|
1980
|
+
take_snapshot: takeSnapshot,
|
|
1981
|
+
click,
|
|
1982
|
+
hover,
|
|
1983
|
+
drag,
|
|
1984
|
+
fill,
|
|
1985
|
+
fill_form: fillForm,
|
|
1986
|
+
type_text: typeText,
|
|
1987
|
+
press_key: pressKey,
|
|
1988
|
+
upload_file: uploadFile,
|
|
1989
|
+
take_screenshot: takeScreenshot,
|
|
1990
|
+
emulate,
|
|
1991
|
+
resize_page: resizePage,
|
|
1992
|
+
handle_dialog: handleDialog,
|
|
1993
|
+
list_console_messages: listConsoleMessages,
|
|
1994
|
+
get_console_message: getConsoleMessage,
|
|
1995
|
+
list_network_requests: listNetworkRequests,
|
|
1996
|
+
get_network_request: getNetworkRequest,
|
|
1997
|
+
performance_start_trace: performanceStartTrace,
|
|
1998
|
+
performance_stop_trace: performanceStopTrace,
|
|
1999
|
+
performance_analyze_insight: performanceAnalyzeInsight,
|
|
2000
|
+
performance_trace: performanceTrace,
|
|
2001
|
+
take_heapsnapshot: takeHeapsnapshot,
|
|
2002
|
+
lighthouse_audit: lighthouseAudit,
|
|
2003
|
+
mock_request: mockRequest,
|
|
2004
|
+
list_mocks: listMocks,
|
|
2005
|
+
clear_mocks: clearMocks
|
|
2006
|
+
};
|
|
2007
|
+
var TOOL_NAMES = Object.keys(TOOLS);
|
|
2008
|
+
|
|
2009
|
+
// src/cli.ts
|
|
2010
|
+
var USAGE = `cdp-toolkit — raw single-target CDP, 29-tool chrome-devtools-mcp parity.
|
|
2011
|
+
|
|
2012
|
+
Usage:
|
|
2013
|
+
bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
|
|
2014
|
+
bun run src/cli.ts --list
|
|
2015
|
+
|
|
2016
|
+
Examples:
|
|
2017
|
+
bun run src/cli.ts list_pages
|
|
2018
|
+
bun run src/cli.ts navigate_page --target index:0 --url https://example.com
|
|
2019
|
+
bun run src/cli.ts click --target index:0 --uid 42
|
|
2020
|
+
bun run src/cli.ts evaluate_script --json '{"expression":"1+2"}'
|
|
2021
|
+
bun run src/cli.ts take_screenshot --target url:example --fullPage true
|
|
2022
|
+
|
|
2023
|
+
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
2024
|
+
Run with --list to print every available tool name.`;
|
|
2025
|
+
function coerce(raw) {
|
|
2026
|
+
if (raw === "true")
|
|
2027
|
+
return true;
|
|
2028
|
+
if (raw === "false")
|
|
2029
|
+
return false;
|
|
2030
|
+
if (raw.trim() !== "" && !Number.isNaN(Number(raw)) && /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim())) {
|
|
2031
|
+
return Number(raw);
|
|
2032
|
+
}
|
|
2033
|
+
return raw;
|
|
2034
|
+
}
|
|
2035
|
+
function parseArgv(argv) {
|
|
2036
|
+
const out = { list: false, args: {} };
|
|
2037
|
+
let jsonObj;
|
|
2038
|
+
const flagPairs = [];
|
|
2039
|
+
for (let i = 0;i < argv.length; i++) {
|
|
2040
|
+
const token = argv[i];
|
|
2041
|
+
if (token === "--list") {
|
|
2042
|
+
out.list = true;
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
if (token.startsWith("--")) {
|
|
2046
|
+
const key = token.slice(2);
|
|
2047
|
+
const next = argv[i + 1];
|
|
2048
|
+
const hasValue = next !== undefined && !next.startsWith("--");
|
|
2049
|
+
const rawValue = hasValue ? next : "true";
|
|
2050
|
+
if (hasValue)
|
|
2051
|
+
i++;
|
|
2052
|
+
if (key === "json") {
|
|
2053
|
+
const parsed = JSON.parse(rawValue);
|
|
2054
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2055
|
+
throw new Error("--json must be a JSON object");
|
|
2056
|
+
}
|
|
2057
|
+
jsonObj = parsed;
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
if (key === "target") {
|
|
2061
|
+
flagPairs.push(["target", rawValue]);
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
flagPairs.push([key, hasValue ? coerce(rawValue) : true]);
|
|
2065
|
+
continue;
|
|
2066
|
+
}
|
|
2067
|
+
if (out.tool === undefined)
|
|
2068
|
+
out.tool = token;
|
|
2069
|
+
}
|
|
2070
|
+
if (jsonObj)
|
|
2071
|
+
Object.assign(out.args, jsonObj);
|
|
2072
|
+
for (const [k, v] of flagPairs)
|
|
2073
|
+
out.args[k] = v;
|
|
2074
|
+
return out;
|
|
2075
|
+
}
|
|
2076
|
+
function isToolName(name) {
|
|
2077
|
+
return Object.prototype.hasOwnProperty.call(TOOLS, name);
|
|
2078
|
+
}
|
|
2079
|
+
async function main() {
|
|
2080
|
+
const parsed = parseArgv(process.argv.slice(2));
|
|
2081
|
+
if (parsed.list) {
|
|
2082
|
+
process.stdout.write(`${TOOL_NAMES.join(`
|
|
2083
|
+
`)}
|
|
2084
|
+
`);
|
|
2085
|
+
return 0;
|
|
2086
|
+
}
|
|
2087
|
+
if (!parsed.tool) {
|
|
2088
|
+
process.stderr.write(`${USAGE}
|
|
2089
|
+
`);
|
|
2090
|
+
return 1;
|
|
2091
|
+
}
|
|
2092
|
+
if (!isToolName(parsed.tool)) {
|
|
2093
|
+
process.stderr.write(`${JSON.stringify({ error: `unknown tool '${parsed.tool}'. Run --list to see all ${TOOL_NAMES.length} tools.` })}
|
|
2094
|
+
`);
|
|
2095
|
+
return 1;
|
|
2096
|
+
}
|
|
2097
|
+
const fn = TOOLS[parsed.tool];
|
|
2098
|
+
const result = await fn(parsed.args);
|
|
2099
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2100
|
+
`);
|
|
2101
|
+
return 0;
|
|
2102
|
+
}
|
|
2103
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
2104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2105
|
+
process.stderr.write(`${JSON.stringify({ error: message })}
|
|
2106
|
+
`);
|
|
2107
|
+
process.exit(1);
|
|
2108
|
+
});
|