cdp-skill 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/SKILL.md +543 -0
- package/install.js +92 -0
- package/package.json +47 -0
- package/src/aria.js +1302 -0
- package/src/capture.js +1359 -0
- package/src/cdp.js +905 -0
- package/src/cli.js +244 -0
- package/src/dom.js +3525 -0
- package/src/index.js +155 -0
- package/src/page.js +1720 -0
- package/src/runner.js +2111 -0
- package/src/tests/BrowserClient.test.js +588 -0
- package/src/tests/CDPConnection.test.js +598 -0
- package/src/tests/ChromeDiscovery.test.js +181 -0
- package/src/tests/ConsoleCapture.test.js +302 -0
- package/src/tests/ElementHandle.test.js +586 -0
- package/src/tests/ElementLocator.test.js +586 -0
- package/src/tests/ErrorAggregator.test.js +327 -0
- package/src/tests/InputEmulator.test.js +641 -0
- package/src/tests/NetworkErrorCapture.test.js +458 -0
- package/src/tests/PageController.test.js +822 -0
- package/src/tests/ScreenshotCapture.test.js +356 -0
- package/src/tests/SessionRegistry.test.js +257 -0
- package/src/tests/TargetManager.test.js +274 -0
- package/src/tests/TestRunner.test.js +1529 -0
- package/src/tests/WaitStrategy.test.js +406 -0
- package/src/tests/integration.test.js +431 -0
- package/src/utils.js +1034 -0
- package/uninstall.js +44 -0
package/src/capture.js
ADDED
|
@@ -0,0 +1,1359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture and Monitoring
|
|
3
|
+
* Screenshots, console capture, network monitoring, error aggregation,
|
|
4
|
+
* debug capture, and eval serialization
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Console Capture (from ConsoleCapture.js)
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_MESSAGES = 10000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a console capture utility for capturing console messages and exceptions
|
|
19
|
+
* Listens only to Runtime.consoleAPICalled to avoid duplicate messages
|
|
20
|
+
* @param {Object} session - CDP session
|
|
21
|
+
* @param {Object} [options] - Configuration options
|
|
22
|
+
* @param {number} [options.maxMessages=10000] - Maximum messages to store
|
|
23
|
+
* @returns {Object} Console capture interface
|
|
24
|
+
*/
|
|
25
|
+
export function createConsoleCapture(session, options = {}) {
|
|
26
|
+
const maxMessages = options.maxMessages || DEFAULT_MAX_MESSAGES;
|
|
27
|
+
let messages = [];
|
|
28
|
+
let capturing = false;
|
|
29
|
+
const handlers = {
|
|
30
|
+
consoleAPICalled: null,
|
|
31
|
+
exceptionThrown: null
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function mapConsoleType(type) {
|
|
35
|
+
const mapping = {
|
|
36
|
+
'log': 'log',
|
|
37
|
+
'debug': 'debug',
|
|
38
|
+
'info': 'info',
|
|
39
|
+
'error': 'error',
|
|
40
|
+
'warning': 'warning',
|
|
41
|
+
'warn': 'warning',
|
|
42
|
+
'dir': 'log',
|
|
43
|
+
'dirxml': 'log',
|
|
44
|
+
'table': 'log',
|
|
45
|
+
'trace': 'log',
|
|
46
|
+
'assert': 'error',
|
|
47
|
+
'count': 'log',
|
|
48
|
+
'timeEnd': 'log'
|
|
49
|
+
};
|
|
50
|
+
return mapping[type] || 'log';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatArgs(args) {
|
|
54
|
+
if (!Array.isArray(args)) return '[invalid args]';
|
|
55
|
+
return args.map(arg => {
|
|
56
|
+
try {
|
|
57
|
+
if (arg.value !== undefined) return String(arg.value);
|
|
58
|
+
if (arg.description) return arg.description;
|
|
59
|
+
if (arg.unserializableValue) return arg.unserializableValue;
|
|
60
|
+
if (arg.preview?.description) return arg.preview.description;
|
|
61
|
+
return `[${arg.type || 'unknown'}]`;
|
|
62
|
+
} catch {
|
|
63
|
+
return '[unserializable]';
|
|
64
|
+
}
|
|
65
|
+
}).join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractExceptionMessage(exceptionDetails) {
|
|
69
|
+
if (exceptionDetails.exception?.description) return exceptionDetails.exception.description;
|
|
70
|
+
if (exceptionDetails.text) return exceptionDetails.text;
|
|
71
|
+
return 'Unknown exception';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function addMessage(message) {
|
|
75
|
+
messages.push(message);
|
|
76
|
+
if (messages.length > maxMessages) {
|
|
77
|
+
messages.shift();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function startCapture() {
|
|
82
|
+
if (capturing) return;
|
|
83
|
+
|
|
84
|
+
await session.send('Runtime.enable');
|
|
85
|
+
|
|
86
|
+
handlers.consoleAPICalled = (params) => {
|
|
87
|
+
addMessage({
|
|
88
|
+
type: 'console',
|
|
89
|
+
level: mapConsoleType(params.type),
|
|
90
|
+
text: formatArgs(params.args),
|
|
91
|
+
args: params.args,
|
|
92
|
+
stackTrace: params.stackTrace,
|
|
93
|
+
timestamp: params.timestamp
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
handlers.exceptionThrown = (params) => {
|
|
98
|
+
const exception = params.exceptionDetails;
|
|
99
|
+
addMessage({
|
|
100
|
+
type: 'exception',
|
|
101
|
+
level: 'error',
|
|
102
|
+
text: exception.text || extractExceptionMessage(exception),
|
|
103
|
+
exception: exception.exception,
|
|
104
|
+
stackTrace: exception.stackTrace,
|
|
105
|
+
url: exception.url,
|
|
106
|
+
line: exception.lineNumber,
|
|
107
|
+
column: exception.columnNumber,
|
|
108
|
+
timestamp: params.timestamp
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
session.on('Runtime.consoleAPICalled', handlers.consoleAPICalled);
|
|
113
|
+
session.on('Runtime.exceptionThrown', handlers.exceptionThrown);
|
|
114
|
+
|
|
115
|
+
capturing = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function stopCapture() {
|
|
119
|
+
if (!capturing) return;
|
|
120
|
+
|
|
121
|
+
if (handlers.consoleAPICalled) {
|
|
122
|
+
session.off('Runtime.consoleAPICalled', handlers.consoleAPICalled);
|
|
123
|
+
handlers.consoleAPICalled = null;
|
|
124
|
+
}
|
|
125
|
+
if (handlers.exceptionThrown) {
|
|
126
|
+
session.off('Runtime.exceptionThrown', handlers.exceptionThrown);
|
|
127
|
+
handlers.exceptionThrown = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await session.send('Runtime.disable');
|
|
131
|
+
|
|
132
|
+
capturing = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getMessages() {
|
|
136
|
+
return [...messages];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getMessagesByLevel(levels) {
|
|
140
|
+
const levelSet = new Set(Array.isArray(levels) ? levels : [levels]);
|
|
141
|
+
return messages.filter(m => levelSet.has(m.level));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getMessagesByType(types) {
|
|
145
|
+
const typeSet = new Set(Array.isArray(types) ? types : [types]);
|
|
146
|
+
return messages.filter(m => typeSet.has(m.type));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getErrors() {
|
|
150
|
+
return messages.filter(m => m.level === 'error' || m.type === 'exception');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getWarnings() {
|
|
154
|
+
return messages.filter(m => m.level === 'warning');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hasErrors() {
|
|
158
|
+
return messages.some(m => m.level === 'error' || m.type === 'exception');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clear() {
|
|
162
|
+
messages = [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function clearBrowserConsole() {
|
|
166
|
+
await session.send('Console.clearMessages');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
startCapture,
|
|
171
|
+
stopCapture,
|
|
172
|
+
getMessages,
|
|
173
|
+
getMessagesByLevel,
|
|
174
|
+
getMessagesByType,
|
|
175
|
+
getErrors,
|
|
176
|
+
getWarnings,
|
|
177
|
+
hasErrors,
|
|
178
|
+
clear,
|
|
179
|
+
clearBrowserConsole
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// Screenshot Capture
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
const DEFAULT_MAX_DIMENSION = 16384;
|
|
188
|
+
const VALID_FORMATS = ['png', 'jpeg', 'webp'];
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a screenshot capture utility
|
|
192
|
+
* @param {Object} session - CDP session
|
|
193
|
+
* @param {Object} [options] - Configuration options
|
|
194
|
+
* @param {number} [options.maxDimension=16384] - Maximum dimension for full page captures
|
|
195
|
+
* @returns {Object} Screenshot capture interface
|
|
196
|
+
*/
|
|
197
|
+
export function createScreenshotCapture(session, options = {}) {
|
|
198
|
+
const maxDimension = options.maxDimension || DEFAULT_MAX_DIMENSION;
|
|
199
|
+
|
|
200
|
+
function validateFormat(format) {
|
|
201
|
+
if (!VALID_FORMATS.includes(format)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Invalid screenshot format "${format}". Valid formats are: ${VALID_FORMATS.join(', ')}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validateQuality(quality, format) {
|
|
209
|
+
if (quality === undefined) return;
|
|
210
|
+
if (format === 'png') {
|
|
211
|
+
throw new Error('Quality option is only supported for jpeg and webp formats, not png');
|
|
212
|
+
}
|
|
213
|
+
if (typeof quality !== 'number' || quality < 0 || quality > 100) {
|
|
214
|
+
throw new Error('Quality must be a number between 0 and 100');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateOptions(opts = {}) {
|
|
219
|
+
const format = opts.format || 'png';
|
|
220
|
+
validateFormat(format);
|
|
221
|
+
validateQuality(opts.quality, format);
|
|
222
|
+
return { ...opts, format };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function captureViewport(captureOptions = {}) {
|
|
226
|
+
const validated = validateOptions(captureOptions);
|
|
227
|
+
const params = { format: validated.format };
|
|
228
|
+
|
|
229
|
+
if (params.format !== 'png' && validated.quality !== undefined) {
|
|
230
|
+
params.quality = validated.quality;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Support omitBackground option
|
|
234
|
+
if (captureOptions.omitBackground) {
|
|
235
|
+
params.fromSurface = true;
|
|
236
|
+
// Enable transparent background
|
|
237
|
+
await session.send('Emulation.setDefaultBackgroundColorOverride', {
|
|
238
|
+
color: { r: 0, g: 0, b: 0, a: 0 }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Support clip option for region capture
|
|
243
|
+
if (captureOptions.clip) {
|
|
244
|
+
params.clip = {
|
|
245
|
+
x: captureOptions.clip.x,
|
|
246
|
+
y: captureOptions.clip.y,
|
|
247
|
+
width: captureOptions.clip.width,
|
|
248
|
+
height: captureOptions.clip.height,
|
|
249
|
+
scale: captureOptions.clip.scale || 1
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const result = await session.send('Page.captureScreenshot', params);
|
|
254
|
+
|
|
255
|
+
// Reset background override if we changed it
|
|
256
|
+
if (captureOptions.omitBackground) {
|
|
257
|
+
await session.send('Emulation.setDefaultBackgroundColorOverride');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return Buffer.from(result.data, 'base64');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function captureFullPage(captureOptions = {}) {
|
|
264
|
+
const validated = validateOptions(captureOptions);
|
|
265
|
+
|
|
266
|
+
const metrics = await session.send('Page.getLayoutMetrics');
|
|
267
|
+
const { contentSize } = metrics;
|
|
268
|
+
|
|
269
|
+
const width = Math.ceil(contentSize.width);
|
|
270
|
+
const height = Math.ceil(contentSize.height);
|
|
271
|
+
|
|
272
|
+
if (width > maxDimension || height > maxDimension) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Page dimensions (${width}x${height}) exceed maximum allowed (${maxDimension}x${maxDimension}). ` +
|
|
275
|
+
`Consider using captureViewport() or captureRegion() instead.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const params = {
|
|
280
|
+
format: validated.format,
|
|
281
|
+
captureBeyondViewport: true,
|
|
282
|
+
clip: { x: 0, y: 0, width, height, scale: 1 }
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
if (params.format !== 'png' && validated.quality !== undefined) {
|
|
286
|
+
params.quality = validated.quality;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const result = await session.send('Page.captureScreenshot', params);
|
|
290
|
+
return Buffer.from(result.data, 'base64');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function captureRegion(region, captureOptions = {}) {
|
|
294
|
+
const validated = validateOptions(captureOptions);
|
|
295
|
+
const params = {
|
|
296
|
+
format: validated.format,
|
|
297
|
+
clip: {
|
|
298
|
+
x: region.x,
|
|
299
|
+
y: region.y,
|
|
300
|
+
width: region.width,
|
|
301
|
+
height: region.height,
|
|
302
|
+
scale: captureOptions.scale || 1
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
if (params.format !== 'png' && validated.quality !== undefined) {
|
|
307
|
+
params.quality = validated.quality;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result = await session.send('Page.captureScreenshot', params);
|
|
311
|
+
return Buffer.from(result.data, 'base64');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function captureElement(boundingBox, captureOptions = {}) {
|
|
315
|
+
const padding = captureOptions.padding || 0;
|
|
316
|
+
return captureRegion({
|
|
317
|
+
x: Math.max(0, boundingBox.x - padding),
|
|
318
|
+
y: Math.max(0, boundingBox.y - padding),
|
|
319
|
+
width: boundingBox.width + (padding * 2),
|
|
320
|
+
height: boundingBox.height + (padding * 2)
|
|
321
|
+
}, captureOptions);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function saveToFile(buffer, filePath) {
|
|
325
|
+
const absolutePath = path.resolve(filePath);
|
|
326
|
+
const dir = path.dirname(absolutePath);
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
await fs.mkdir(dir, { recursive: true });
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
332
|
+
throw new Error(`Permission denied: cannot create directory "${dir}"`);
|
|
333
|
+
}
|
|
334
|
+
if (err.code === 'EROFS') {
|
|
335
|
+
throw new Error(`Read-only filesystem: cannot create directory "${dir}"`);
|
|
336
|
+
}
|
|
337
|
+
throw new Error(`Failed to create directory "${dir}": ${err.message}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await fs.writeFile(absolutePath, buffer);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (err.code === 'ENOSPC') {
|
|
344
|
+
throw new Error(`Disk full: cannot write screenshot to "${absolutePath}"`);
|
|
345
|
+
}
|
|
346
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
347
|
+
throw new Error(`Permission denied: cannot write to "${absolutePath}"`);
|
|
348
|
+
}
|
|
349
|
+
if (err.code === 'EROFS') {
|
|
350
|
+
throw new Error(`Read-only filesystem: cannot write to "${absolutePath}"`);
|
|
351
|
+
}
|
|
352
|
+
throw new Error(`Failed to save screenshot to "${absolutePath}": ${err.message}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return absolutePath;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function captureToFile(filePath, captureOptions = {}, elementLocator = null) {
|
|
359
|
+
let buffer;
|
|
360
|
+
let elementBox = null;
|
|
361
|
+
|
|
362
|
+
// Support element screenshot via selector
|
|
363
|
+
if (captureOptions.selector && elementLocator) {
|
|
364
|
+
const element = await elementLocator.querySelector(captureOptions.selector);
|
|
365
|
+
if (!element) {
|
|
366
|
+
throw new Error(`Element not found: ${captureOptions.selector}`);
|
|
367
|
+
}
|
|
368
|
+
const box = await element.getBoundingBox();
|
|
369
|
+
await element.dispose();
|
|
370
|
+
|
|
371
|
+
if (!box || box.width === 0 || box.height === 0) {
|
|
372
|
+
throw new Error(`Element has no visible dimensions: ${captureOptions.selector}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
elementBox = box;
|
|
376
|
+
buffer = await captureElement(box, captureOptions);
|
|
377
|
+
} else if (captureOptions.fullPage) {
|
|
378
|
+
buffer = await captureFullPage(captureOptions);
|
|
379
|
+
} else {
|
|
380
|
+
buffer = await captureViewport(captureOptions);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return saveToFile(buffer, filePath);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function getViewportDimensions() {
|
|
387
|
+
const result = await session.send('Runtime.evaluate', {
|
|
388
|
+
expression: '({ width: window.innerWidth, height: window.innerHeight })',
|
|
389
|
+
returnByValue: true
|
|
390
|
+
});
|
|
391
|
+
return result.result.value;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
captureViewport,
|
|
396
|
+
captureFullPage,
|
|
397
|
+
captureRegion,
|
|
398
|
+
captureElement,
|
|
399
|
+
saveToFile,
|
|
400
|
+
captureToFile,
|
|
401
|
+
getViewportDimensions
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Convenience function to capture viewport
|
|
407
|
+
* @param {Object} session - CDP session
|
|
408
|
+
* @param {Object} [options] - Screenshot options
|
|
409
|
+
* @returns {Promise<Buffer>}
|
|
410
|
+
*/
|
|
411
|
+
export async function captureViewport(session, options = {}) {
|
|
412
|
+
const capture = createScreenshotCapture(session, options);
|
|
413
|
+
return capture.captureViewport(options);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Convenience function to capture full page
|
|
418
|
+
* @param {Object} session - CDP session
|
|
419
|
+
* @param {Object} [options] - Screenshot options
|
|
420
|
+
* @returns {Promise<Buffer>}
|
|
421
|
+
*/
|
|
422
|
+
export async function captureFullPage(session, options = {}) {
|
|
423
|
+
const capture = createScreenshotCapture(session, options);
|
|
424
|
+
return capture.captureFullPage(options);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Convenience function to capture a region
|
|
429
|
+
* @param {Object} session - CDP session
|
|
430
|
+
* @param {Object} region - Region to capture
|
|
431
|
+
* @param {Object} [options] - Screenshot options
|
|
432
|
+
* @returns {Promise<Buffer>}
|
|
433
|
+
*/
|
|
434
|
+
export async function captureRegion(session, region, options = {}) {
|
|
435
|
+
const capture = createScreenshotCapture(session, options);
|
|
436
|
+
return capture.captureRegion(region, options);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Save a screenshot buffer to file
|
|
441
|
+
* @param {Buffer} buffer - Screenshot buffer
|
|
442
|
+
* @param {string} filePath - Destination path
|
|
443
|
+
* @returns {Promise<string>}
|
|
444
|
+
*/
|
|
445
|
+
export async function saveScreenshot(buffer, filePath) {
|
|
446
|
+
const absolutePath = path.resolve(filePath);
|
|
447
|
+
const dir = path.dirname(absolutePath);
|
|
448
|
+
|
|
449
|
+
await fs.mkdir(dir, { recursive: true });
|
|
450
|
+
await fs.writeFile(absolutePath, buffer);
|
|
451
|
+
return absolutePath;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ============================================================================
|
|
455
|
+
// Network Error Capture
|
|
456
|
+
// ============================================================================
|
|
457
|
+
|
|
458
|
+
const DEFAULT_MAX_PENDING_REQUESTS = 10000;
|
|
459
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Create a network error capture utility
|
|
463
|
+
* @param {Object} session - CDP session
|
|
464
|
+
* @param {Object} [config] - Configuration options
|
|
465
|
+
* @param {number} [config.maxPendingRequests=10000] - Maximum pending requests
|
|
466
|
+
* @param {number} [config.requestTimeoutMs=300000] - Stale request timeout
|
|
467
|
+
* @returns {Object} Network capture interface
|
|
468
|
+
*/
|
|
469
|
+
export function createNetworkCapture(session, config = {}) {
|
|
470
|
+
const maxPendingRequests = config.maxPendingRequests || DEFAULT_MAX_PENDING_REQUESTS;
|
|
471
|
+
const requestTimeoutMs = config.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
|
|
472
|
+
|
|
473
|
+
const requests = new Map();
|
|
474
|
+
let errors = [];
|
|
475
|
+
let httpErrors = [];
|
|
476
|
+
let capturing = false;
|
|
477
|
+
const handlers = {};
|
|
478
|
+
let captureOptions = {};
|
|
479
|
+
let cleanupIntervalId = null;
|
|
480
|
+
|
|
481
|
+
function cleanupStaleRequests() {
|
|
482
|
+
const now = Date.now() / 1000;
|
|
483
|
+
const timeoutSec = requestTimeoutMs / 1000;
|
|
484
|
+
|
|
485
|
+
for (const [requestId, request] of requests) {
|
|
486
|
+
if (now - request.timestamp > timeoutSec) {
|
|
487
|
+
requests.delete(requestId);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function startCapture(startOptions = {}) {
|
|
493
|
+
if (capturing) return;
|
|
494
|
+
|
|
495
|
+
captureOptions = {
|
|
496
|
+
captureHttpErrors: startOptions.captureHttpErrors !== false,
|
|
497
|
+
ignoreStatusCodes: new Set(startOptions.ignoreStatusCodes || [])
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
await session.send('Network.enable');
|
|
501
|
+
|
|
502
|
+
handlers.requestWillBeSent = (params) => {
|
|
503
|
+
if (requests.size >= maxPendingRequests) {
|
|
504
|
+
const oldestKey = requests.keys().next().value;
|
|
505
|
+
requests.delete(oldestKey);
|
|
506
|
+
}
|
|
507
|
+
requests.set(params.requestId, {
|
|
508
|
+
url: params.request.url,
|
|
509
|
+
method: params.request.method,
|
|
510
|
+
timestamp: params.timestamp,
|
|
511
|
+
type: params.type
|
|
512
|
+
});
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
handlers.loadingFailed = (params) => {
|
|
516
|
+
const request = requests.get(params.requestId);
|
|
517
|
+
errors.push({
|
|
518
|
+
type: 'network-failure',
|
|
519
|
+
requestId: params.requestId,
|
|
520
|
+
url: request?.url || 'unknown',
|
|
521
|
+
method: request?.method || 'unknown',
|
|
522
|
+
resourceType: params.type,
|
|
523
|
+
errorText: params.errorText,
|
|
524
|
+
canceled: params.canceled || false,
|
|
525
|
+
blockedReason: params.blockedReason,
|
|
526
|
+
timestamp: params.timestamp
|
|
527
|
+
});
|
|
528
|
+
requests.delete(params.requestId);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
handlers.responseReceived = (params) => {
|
|
532
|
+
const status = params.response.status;
|
|
533
|
+
|
|
534
|
+
if (captureOptions.captureHttpErrors && status >= 400 &&
|
|
535
|
+
!captureOptions.ignoreStatusCodes.has(status)) {
|
|
536
|
+
const request = requests.get(params.requestId);
|
|
537
|
+
httpErrors.push({
|
|
538
|
+
type: 'http-error',
|
|
539
|
+
requestId: params.requestId,
|
|
540
|
+
url: params.response.url,
|
|
541
|
+
method: request?.method || 'unknown',
|
|
542
|
+
status,
|
|
543
|
+
statusText: params.response.statusText,
|
|
544
|
+
resourceType: params.type,
|
|
545
|
+
mimeType: params.response.mimeType,
|
|
546
|
+
timestamp: params.timestamp
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
handlers.loadingFinished = (params) => {
|
|
552
|
+
requests.delete(params.requestId);
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
session.on('Network.requestWillBeSent', handlers.requestWillBeSent);
|
|
556
|
+
session.on('Network.loadingFailed', handlers.loadingFailed);
|
|
557
|
+
session.on('Network.responseReceived', handlers.responseReceived);
|
|
558
|
+
session.on('Network.loadingFinished', handlers.loadingFinished);
|
|
559
|
+
|
|
560
|
+
cleanupIntervalId = setInterval(
|
|
561
|
+
cleanupStaleRequests,
|
|
562
|
+
Math.min(requestTimeoutMs / 2, 60000)
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
capturing = true;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function stopCapture() {
|
|
569
|
+
if (!capturing) return;
|
|
570
|
+
|
|
571
|
+
session.off('Network.requestWillBeSent', handlers.requestWillBeSent);
|
|
572
|
+
session.off('Network.loadingFailed', handlers.loadingFailed);
|
|
573
|
+
session.off('Network.responseReceived', handlers.responseReceived);
|
|
574
|
+
session.off('Network.loadingFinished', handlers.loadingFinished);
|
|
575
|
+
|
|
576
|
+
if (cleanupIntervalId) {
|
|
577
|
+
clearInterval(cleanupIntervalId);
|
|
578
|
+
cleanupIntervalId = null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
requests.clear();
|
|
582
|
+
await session.send('Network.disable');
|
|
583
|
+
capturing = false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function getNetworkFailures() {
|
|
587
|
+
return [...errors];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function getHttpErrors() {
|
|
591
|
+
return [...httpErrors];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function getAllErrors() {
|
|
595
|
+
return [...errors, ...httpErrors].sort((a, b) => a.timestamp - b.timestamp);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function hasErrors() {
|
|
599
|
+
return errors.length > 0 || httpErrors.length > 0;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function getErrorsByType(types) {
|
|
603
|
+
const typeSet = new Set(Array.isArray(types) ? types : [types]);
|
|
604
|
+
return getAllErrors().filter(e => typeSet.has(e.resourceType));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function clear() {
|
|
608
|
+
errors = [];
|
|
609
|
+
httpErrors = [];
|
|
610
|
+
requests.clear();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
startCapture,
|
|
615
|
+
stopCapture,
|
|
616
|
+
getNetworkFailures,
|
|
617
|
+
getHttpErrors,
|
|
618
|
+
getAllErrors,
|
|
619
|
+
hasErrors,
|
|
620
|
+
getErrorsByType,
|
|
621
|
+
clear
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ============================================================================
|
|
626
|
+
// Error Aggregator
|
|
627
|
+
// ============================================================================
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Create an error aggregator that combines console and network errors
|
|
631
|
+
* @param {Object} consoleCapture - Console capture instance
|
|
632
|
+
* @param {Object} networkCapture - Network capture instance
|
|
633
|
+
* @returns {Object} Error aggregator interface
|
|
634
|
+
*/
|
|
635
|
+
export function createErrorAggregator(consoleCapture, networkCapture) {
|
|
636
|
+
if (!consoleCapture) throw new Error('consoleCapture is required');
|
|
637
|
+
if (!networkCapture) throw new Error('networkCapture is required');
|
|
638
|
+
|
|
639
|
+
function getSummary() {
|
|
640
|
+
const consoleErrors = consoleCapture.getErrors();
|
|
641
|
+
const consoleWarnings = consoleCapture.getWarnings();
|
|
642
|
+
const networkFailures = networkCapture.getNetworkFailures();
|
|
643
|
+
const httpErrs = networkCapture.getHttpErrors();
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
hasErrors: consoleErrors.length > 0 || networkFailures.length > 0 ||
|
|
647
|
+
httpErrs.some(e => e.status >= 500),
|
|
648
|
+
hasWarnings: consoleWarnings.length > 0 ||
|
|
649
|
+
httpErrs.some(e => e.status >= 400 && e.status < 500),
|
|
650
|
+
counts: {
|
|
651
|
+
consoleErrors: consoleErrors.length,
|
|
652
|
+
consoleWarnings: consoleWarnings.length,
|
|
653
|
+
networkFailures: networkFailures.length,
|
|
654
|
+
httpClientErrors: httpErrs.filter(e => e.status >= 400 && e.status < 500).length,
|
|
655
|
+
httpServerErrors: httpErrs.filter(e => e.status >= 500).length
|
|
656
|
+
},
|
|
657
|
+
errors: {
|
|
658
|
+
console: consoleErrors,
|
|
659
|
+
network: networkFailures,
|
|
660
|
+
http: httpErrs
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function getAllErrorsChronological() {
|
|
666
|
+
const all = [
|
|
667
|
+
...consoleCapture.getErrors().map(e => ({ ...e, source: 'console' })),
|
|
668
|
+
...networkCapture.getAllErrors().map(e => ({ ...e, source: 'network' }))
|
|
669
|
+
];
|
|
670
|
+
|
|
671
|
+
return all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function getCriticalErrors() {
|
|
675
|
+
return [
|
|
676
|
+
...consoleCapture.getErrors().filter(e => e.type === 'exception'),
|
|
677
|
+
...networkCapture.getNetworkFailures(),
|
|
678
|
+
...networkCapture.getHttpErrors().filter(e => e.status >= 500)
|
|
679
|
+
];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function formatReport() {
|
|
683
|
+
const summary = getSummary();
|
|
684
|
+
const lines = ['=== Error Report ==='];
|
|
685
|
+
|
|
686
|
+
if (summary.counts.consoleErrors > 0) {
|
|
687
|
+
lines.push('\n## Console Errors');
|
|
688
|
+
for (const error of summary.errors.console) {
|
|
689
|
+
lines.push(` [${error.level.toUpperCase()}] ${error.text}`);
|
|
690
|
+
if (error.url) {
|
|
691
|
+
lines.push(` at ${error.url}:${error.line || '?'}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (summary.counts.networkFailures > 0) {
|
|
697
|
+
lines.push('\n## Network Failures');
|
|
698
|
+
for (const error of summary.errors.network) {
|
|
699
|
+
lines.push(` [FAILED] ${error.method} ${error.url}`);
|
|
700
|
+
lines.push(` Error: ${error.errorText}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (summary.counts.httpServerErrors > 0 || summary.counts.httpClientErrors > 0) {
|
|
705
|
+
lines.push('\n## HTTP Errors');
|
|
706
|
+
for (const error of summary.errors.http) {
|
|
707
|
+
lines.push(` [${error.status}] ${error.method} ${error.url}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (!summary.hasErrors && !summary.hasWarnings) {
|
|
712
|
+
lines.push('\nNo errors or warnings captured.');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return lines.join('\n');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function toJSON() {
|
|
719
|
+
return {
|
|
720
|
+
timestamp: new Date().toISOString(),
|
|
721
|
+
summary: getSummary(),
|
|
722
|
+
all: getAllErrorsChronological()
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
getSummary,
|
|
728
|
+
getAllErrorsChronological,
|
|
729
|
+
getCriticalErrors,
|
|
730
|
+
formatReport,
|
|
731
|
+
toJSON
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Aggregate errors from console and network captures
|
|
737
|
+
* @param {Object} consoleCapture - Console capture instance
|
|
738
|
+
* @param {Object} networkCapture - Network capture instance
|
|
739
|
+
* @returns {{summary: Object, critical: Array, report: string}}
|
|
740
|
+
*/
|
|
741
|
+
export function aggregateErrors(consoleCapture, networkCapture) {
|
|
742
|
+
const aggregator = createErrorAggregator(consoleCapture, networkCapture);
|
|
743
|
+
return {
|
|
744
|
+
summary: aggregator.getSummary(),
|
|
745
|
+
critical: aggregator.getCriticalErrors(),
|
|
746
|
+
report: aggregator.formatReport()
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ============================================================================
|
|
751
|
+
// PDF Capture
|
|
752
|
+
// ============================================================================
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Create a PDF capture utility
|
|
756
|
+
* @param {Object} session - CDP session
|
|
757
|
+
* @returns {Object} PDF capture interface
|
|
758
|
+
*/
|
|
759
|
+
export function createPdfCapture(session) {
|
|
760
|
+
async function generatePdf(options = {}) {
|
|
761
|
+
const params = {
|
|
762
|
+
landscape: options.landscape || false,
|
|
763
|
+
displayHeaderFooter: options.displayHeaderFooter || false,
|
|
764
|
+
headerTemplate: options.headerTemplate || '',
|
|
765
|
+
footerTemplate: options.footerTemplate || '',
|
|
766
|
+
printBackground: options.printBackground !== false,
|
|
767
|
+
scale: options.scale || 1,
|
|
768
|
+
paperWidth: options.paperWidth || 8.5,
|
|
769
|
+
paperHeight: options.paperHeight || 11,
|
|
770
|
+
marginTop: options.marginTop || 0.4,
|
|
771
|
+
marginBottom: options.marginBottom || 0.4,
|
|
772
|
+
marginLeft: options.marginLeft || 0.4,
|
|
773
|
+
marginRight: options.marginRight || 0.4,
|
|
774
|
+
pageRanges: options.pageRanges || '',
|
|
775
|
+
preferCSSPageSize: options.preferCSSPageSize || false
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const result = await session.send('Page.printToPDF', params);
|
|
779
|
+
return Buffer.from(result.data, 'base64');
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function formatFileSize(bytes) {
|
|
783
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
784
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
785
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function extractPdfMetadata(buffer) {
|
|
789
|
+
const fileSize = buffer.length;
|
|
790
|
+
const content = buffer.toString('binary');
|
|
791
|
+
|
|
792
|
+
// Count pages by looking for /Type /Page entries
|
|
793
|
+
const pageMatches = content.match(/\/Type\s*\/Page[^s]/g);
|
|
794
|
+
const pageCount = pageMatches ? pageMatches.length : 1;
|
|
795
|
+
|
|
796
|
+
// Try to extract media box dimensions (default page size)
|
|
797
|
+
let dimensions = { width: 612, height: 792 }; // Default Letter size in points
|
|
798
|
+
const mediaBoxMatch = content.match(/\/MediaBox\s*\[\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\]/);
|
|
799
|
+
if (mediaBoxMatch) {
|
|
800
|
+
dimensions = {
|
|
801
|
+
width: parseFloat(mediaBoxMatch[3]) - parseFloat(mediaBoxMatch[1]),
|
|
802
|
+
height: parseFloat(mediaBoxMatch[4]) - parseFloat(mediaBoxMatch[2])
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
fileSize,
|
|
808
|
+
fileSizeFormatted: formatFileSize(fileSize),
|
|
809
|
+
pageCount,
|
|
810
|
+
dimensions: {
|
|
811
|
+
width: dimensions.width,
|
|
812
|
+
height: dimensions.height,
|
|
813
|
+
unit: 'points'
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function validatePdf(buffer) {
|
|
819
|
+
const content = buffer.toString('binary');
|
|
820
|
+
const errors = [];
|
|
821
|
+
const warnings = [];
|
|
822
|
+
|
|
823
|
+
// Check PDF header
|
|
824
|
+
if (!content.startsWith('%PDF-')) {
|
|
825
|
+
errors.push('Invalid PDF: missing PDF header');
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Check for EOF marker
|
|
829
|
+
if (!content.includes('%%EOF')) {
|
|
830
|
+
warnings.push('PDF may be truncated: missing EOF marker');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Check for xref table or xref stream
|
|
834
|
+
if (!content.includes('xref') && !content.includes('/XRef')) {
|
|
835
|
+
warnings.push('PDF may have structural issues: no cross-reference found');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Check minimum size (a valid PDF should be at least a few hundred bytes)
|
|
839
|
+
if (buffer.length < 100) {
|
|
840
|
+
errors.push('PDF file is too small to be valid');
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return {
|
|
844
|
+
valid: errors.length === 0,
|
|
845
|
+
errors,
|
|
846
|
+
warnings
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function generateElementPdf(selector, options = {}, elementLocator) {
|
|
851
|
+
if (!elementLocator) {
|
|
852
|
+
throw new Error('Element locator required for element PDF');
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Find the element
|
|
856
|
+
const element = await elementLocator.querySelector(selector);
|
|
857
|
+
if (!element) {
|
|
858
|
+
throw new Error(`Element not found: ${selector}`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
try {
|
|
862
|
+
// Get the element's HTML and create a print-optimized version
|
|
863
|
+
const elementHtml = await element.evaluate(`function() {
|
|
864
|
+
const clone = this.cloneNode(true);
|
|
865
|
+
// Create a wrapper with print-friendly styles
|
|
866
|
+
const wrapper = document.createElement('div');
|
|
867
|
+
wrapper.style.cssText = 'width: 100%; margin: 0; padding: 0;';
|
|
868
|
+
wrapper.appendChild(clone);
|
|
869
|
+
return wrapper.outerHTML;
|
|
870
|
+
}`);
|
|
871
|
+
|
|
872
|
+
// Store original body content
|
|
873
|
+
await session.send('Runtime.evaluate', {
|
|
874
|
+
expression: `
|
|
875
|
+
window.__originalBody = document.body.innerHTML;
|
|
876
|
+
window.__originalStyles = document.body.style.cssText;
|
|
877
|
+
`
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Replace body with element content for printing
|
|
881
|
+
await session.send('Runtime.evaluate', {
|
|
882
|
+
expression: `
|
|
883
|
+
document.body.innerHTML = ${JSON.stringify(elementHtml)};
|
|
884
|
+
document.body.style.cssText = 'margin: 0; padding: 20px;';
|
|
885
|
+
`
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// Generate the PDF
|
|
889
|
+
const buffer = await generatePdf(options);
|
|
890
|
+
|
|
891
|
+
// Restore original body
|
|
892
|
+
await session.send('Runtime.evaluate', {
|
|
893
|
+
expression: `
|
|
894
|
+
document.body.innerHTML = window.__originalBody;
|
|
895
|
+
document.body.style.cssText = window.__originalStyles;
|
|
896
|
+
delete window.__originalBody;
|
|
897
|
+
delete window.__originalStyles;
|
|
898
|
+
`
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
return buffer;
|
|
902
|
+
} finally {
|
|
903
|
+
await element.dispose();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function saveToFile(filePath, options = {}, elementLocator = null) {
|
|
908
|
+
let buffer;
|
|
909
|
+
|
|
910
|
+
// Support element PDF via selector
|
|
911
|
+
if (options.selector && elementLocator) {
|
|
912
|
+
buffer = await generateElementPdf(options.selector, options, elementLocator);
|
|
913
|
+
} else {
|
|
914
|
+
buffer = await generatePdf(options);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const absolutePath = path.resolve(filePath);
|
|
918
|
+
const dir = path.dirname(absolutePath);
|
|
919
|
+
|
|
920
|
+
try {
|
|
921
|
+
await fs.mkdir(dir, { recursive: true });
|
|
922
|
+
} catch (err) {
|
|
923
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
924
|
+
throw new Error(`Permission denied: cannot create directory "${dir}"`);
|
|
925
|
+
}
|
|
926
|
+
if (err.code === 'EROFS') {
|
|
927
|
+
throw new Error(`Read-only filesystem: cannot create directory "${dir}"`);
|
|
928
|
+
}
|
|
929
|
+
throw new Error(`Failed to create directory "${dir}": ${err.message}`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
try {
|
|
933
|
+
await fs.writeFile(absolutePath, buffer);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
if (err.code === 'ENOSPC') {
|
|
936
|
+
throw new Error(`Disk full: cannot write PDF to "${absolutePath}"`);
|
|
937
|
+
}
|
|
938
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
939
|
+
throw new Error(`Permission denied: cannot write to "${absolutePath}"`);
|
|
940
|
+
}
|
|
941
|
+
if (err.code === 'EROFS') {
|
|
942
|
+
throw new Error(`Read-only filesystem: cannot write to "${absolutePath}"`);
|
|
943
|
+
}
|
|
944
|
+
throw new Error(`Failed to save PDF to "${absolutePath}": ${err.message}`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Extract metadata
|
|
948
|
+
const metadata = extractPdfMetadata(buffer);
|
|
949
|
+
|
|
950
|
+
// Optionally validate
|
|
951
|
+
let validation = null;
|
|
952
|
+
if (options.validate) {
|
|
953
|
+
validation = validatePdf(buffer);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return {
|
|
957
|
+
path: absolutePath,
|
|
958
|
+
...metadata,
|
|
959
|
+
validation,
|
|
960
|
+
selector: options.selector || null
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return { generatePdf, saveToFile, extractPdfMetadata, validatePdf };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ============================================================================
|
|
968
|
+
// Debug Capture (from DebugCapture.js)
|
|
969
|
+
// ============================================================================
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Create a debug capture utility for capturing debugging state before/after actions
|
|
973
|
+
* @param {Object} session - CDP session
|
|
974
|
+
* @param {Object} screenshotCapture - Screenshot capture instance
|
|
975
|
+
* @param {Object} [options] - Configuration options
|
|
976
|
+
* @param {string} [options.outputDir] - Output directory (defaults to platform temp dir)
|
|
977
|
+
* @param {boolean} [options.captureScreenshots=true] - Whether to capture screenshots
|
|
978
|
+
* @param {boolean} [options.captureDom=true] - Whether to capture DOM
|
|
979
|
+
* @returns {Object} Debug capture interface
|
|
980
|
+
*/
|
|
981
|
+
export function createDebugCapture(session, screenshotCapture, options = {}) {
|
|
982
|
+
// Default to platform-specific temp directory
|
|
983
|
+
const defaultOutputDir = path.join(os.tmpdir(), 'cdp-skill', 'debug-captures');
|
|
984
|
+
const outputDir = options.outputDir || defaultOutputDir;
|
|
985
|
+
const captureScreenshots = options.captureScreenshots !== false;
|
|
986
|
+
const captureDom = options.captureDom !== false;
|
|
987
|
+
let stepIndex = 0;
|
|
988
|
+
|
|
989
|
+
async function ensureOutputDir() {
|
|
990
|
+
try {
|
|
991
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
992
|
+
} catch (e) {
|
|
993
|
+
// Ignore if already exists
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async function captureState(prefix) {
|
|
998
|
+
await ensureOutputDir();
|
|
999
|
+
const result = { timestamp: new Date().toISOString() };
|
|
1000
|
+
|
|
1001
|
+
if (captureScreenshots) {
|
|
1002
|
+
try {
|
|
1003
|
+
const screenshotPath = path.join(outputDir, `${prefix}.png`);
|
|
1004
|
+
const buffer = await screenshotCapture.captureViewport();
|
|
1005
|
+
await fs.writeFile(screenshotPath, buffer);
|
|
1006
|
+
result.screenshot = screenshotPath;
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
result.screenshotError = e.message;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (captureDom) {
|
|
1013
|
+
try {
|
|
1014
|
+
const domPath = path.join(outputDir, `${prefix}.html`);
|
|
1015
|
+
const domResult = await session.send('Runtime.evaluate', {
|
|
1016
|
+
expression: 'document.documentElement.outerHTML',
|
|
1017
|
+
returnByValue: true
|
|
1018
|
+
});
|
|
1019
|
+
if (domResult.result && domResult.result.value) {
|
|
1020
|
+
await fs.writeFile(domPath, domResult.result.value);
|
|
1021
|
+
result.dom = domPath;
|
|
1022
|
+
}
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
result.domError = e.message;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return result;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function captureBefore(action, params) {
|
|
1032
|
+
stepIndex++;
|
|
1033
|
+
const prefix = `step-${String(stepIndex).padStart(3, '0')}-${action}-before`;
|
|
1034
|
+
return captureState(prefix);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function captureAfter(action, params, status) {
|
|
1038
|
+
const prefix = `step-${String(stepIndex).padStart(3, '0')}-${action}-after-${status}`;
|
|
1039
|
+
return captureState(prefix);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function getPageInfo() {
|
|
1043
|
+
try {
|
|
1044
|
+
const result = await session.send('Runtime.evaluate', {
|
|
1045
|
+
expression: `({
|
|
1046
|
+
url: window.location.href,
|
|
1047
|
+
title: document.title,
|
|
1048
|
+
readyState: document.readyState,
|
|
1049
|
+
scrollX: window.scrollX,
|
|
1050
|
+
scrollY: window.scrollY,
|
|
1051
|
+
innerWidth: window.innerWidth,
|
|
1052
|
+
innerHeight: window.innerHeight,
|
|
1053
|
+
documentWidth: document.documentElement.scrollWidth,
|
|
1054
|
+
documentHeight: document.documentElement.scrollHeight
|
|
1055
|
+
})`,
|
|
1056
|
+
returnByValue: true
|
|
1057
|
+
});
|
|
1058
|
+
return result.result.value;
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
return { error: e.message };
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function reset() {
|
|
1065
|
+
stepIndex = 0;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return {
|
|
1069
|
+
captureBefore,
|
|
1070
|
+
captureAfter,
|
|
1071
|
+
captureState,
|
|
1072
|
+
getPageInfo,
|
|
1073
|
+
reset
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ============================================================================
|
|
1078
|
+
// Eval Serializer (from EvalSerializer.js)
|
|
1079
|
+
// ============================================================================
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Create an eval serializer for handling serialization of JavaScript values
|
|
1083
|
+
* Provides special handling for non-JSON-serializable values
|
|
1084
|
+
* @returns {Object} Eval serializer interface
|
|
1085
|
+
*/
|
|
1086
|
+
export function createEvalSerializer() {
|
|
1087
|
+
/**
|
|
1088
|
+
* Get the serialization function that runs in browser context
|
|
1089
|
+
* @returns {string} JavaScript function declaration
|
|
1090
|
+
*/
|
|
1091
|
+
function getSerializationFunction() {
|
|
1092
|
+
return `function(value) {
|
|
1093
|
+
// Handle primitives and null
|
|
1094
|
+
if (value === null) return { type: 'null', value: null };
|
|
1095
|
+
if (value === undefined) return { type: 'undefined', value: null };
|
|
1096
|
+
|
|
1097
|
+
const type = typeof value;
|
|
1098
|
+
|
|
1099
|
+
// Handle special number values (FR-039)
|
|
1100
|
+
if (type === 'number') {
|
|
1101
|
+
if (Number.isNaN(value)) return { type: 'number', value: null, repr: 'NaN' };
|
|
1102
|
+
if (value === Infinity) return { type: 'number', value: null, repr: 'Infinity' };
|
|
1103
|
+
if (value === -Infinity) return { type: 'number', value: null, repr: '-Infinity' };
|
|
1104
|
+
return { type: 'number', value: value };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Handle strings, booleans, bigint
|
|
1108
|
+
if (type === 'string') return { type: 'string', value: value };
|
|
1109
|
+
if (type === 'boolean') return { type: 'boolean', value: value };
|
|
1110
|
+
if (type === 'bigint') return { type: 'bigint', value: null, repr: value.toString() + 'n' };
|
|
1111
|
+
if (type === 'symbol') return { type: 'symbol', value: null, repr: value.toString() };
|
|
1112
|
+
if (type === 'function') return { type: 'function', value: null, repr: value.toString().substring(0, 100) };
|
|
1113
|
+
|
|
1114
|
+
// Handle Date (FR-040)
|
|
1115
|
+
if (value instanceof Date) {
|
|
1116
|
+
return {
|
|
1117
|
+
type: 'Date',
|
|
1118
|
+
value: value.toISOString(),
|
|
1119
|
+
timestamp: value.getTime()
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Handle Map (FR-040)
|
|
1124
|
+
if (value instanceof Map) {
|
|
1125
|
+
const entries = [];
|
|
1126
|
+
let count = 0;
|
|
1127
|
+
for (const [k, v] of value) {
|
|
1128
|
+
if (count >= 50) break; // Limit entries
|
|
1129
|
+
try {
|
|
1130
|
+
entries.push([
|
|
1131
|
+
typeof k === 'object' ? JSON.stringify(k) : String(k),
|
|
1132
|
+
typeof v === 'object' ? JSON.stringify(v) : String(v)
|
|
1133
|
+
]);
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
entries.push([String(k), '[Circular]']);
|
|
1136
|
+
}
|
|
1137
|
+
count++;
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
type: 'Map',
|
|
1141
|
+
size: value.size,
|
|
1142
|
+
entries: entries
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Handle Set (FR-040)
|
|
1147
|
+
if (value instanceof Set) {
|
|
1148
|
+
const items = [];
|
|
1149
|
+
let count = 0;
|
|
1150
|
+
for (const item of value) {
|
|
1151
|
+
if (count >= 50) break; // Limit items
|
|
1152
|
+
try {
|
|
1153
|
+
items.push(typeof item === 'object' ? JSON.stringify(item) : item);
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
items.push('[Circular]');
|
|
1156
|
+
}
|
|
1157
|
+
count++;
|
|
1158
|
+
}
|
|
1159
|
+
return {
|
|
1160
|
+
type: 'Set',
|
|
1161
|
+
size: value.size,
|
|
1162
|
+
values: items
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Handle RegExp
|
|
1167
|
+
if (value instanceof RegExp) {
|
|
1168
|
+
return { type: 'RegExp', value: value.toString() };
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Handle Error
|
|
1172
|
+
if (value instanceof Error) {
|
|
1173
|
+
return {
|
|
1174
|
+
type: 'Error',
|
|
1175
|
+
name: value.name,
|
|
1176
|
+
message: value.message,
|
|
1177
|
+
stack: value.stack ? value.stack.substring(0, 500) : null
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Handle DOM Element (FR-041)
|
|
1182
|
+
if (value instanceof Element) {
|
|
1183
|
+
const attrs = {};
|
|
1184
|
+
for (const attr of value.attributes) {
|
|
1185
|
+
attrs[attr.name] = attr.value.substring(0, 100);
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
type: 'Element',
|
|
1189
|
+
tagName: value.tagName.toLowerCase(),
|
|
1190
|
+
id: value.id || null,
|
|
1191
|
+
className: value.className || null,
|
|
1192
|
+
attributes: attrs,
|
|
1193
|
+
textContent: value.textContent ? value.textContent.trim().substring(0, 200) : null,
|
|
1194
|
+
innerHTML: value.innerHTML ? value.innerHTML.substring(0, 200) : null,
|
|
1195
|
+
isConnected: value.isConnected,
|
|
1196
|
+
childElementCount: value.childElementCount
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Handle NodeList
|
|
1201
|
+
if (value instanceof NodeList || value instanceof HTMLCollection) {
|
|
1202
|
+
const items = [];
|
|
1203
|
+
const len = Math.min(value.length, 20);
|
|
1204
|
+
for (let i = 0; i < len; i++) {
|
|
1205
|
+
const el = value[i];
|
|
1206
|
+
if (el instanceof Element) {
|
|
1207
|
+
items.push({
|
|
1208
|
+
tagName: el.tagName.toLowerCase(),
|
|
1209
|
+
id: el.id || null,
|
|
1210
|
+
className: el.className || null
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return {
|
|
1215
|
+
type: value instanceof NodeList ? 'NodeList' : 'HTMLCollection',
|
|
1216
|
+
length: value.length,
|
|
1217
|
+
items: items
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Handle Document
|
|
1222
|
+
if (value instanceof Document) {
|
|
1223
|
+
return {
|
|
1224
|
+
type: 'Document',
|
|
1225
|
+
title: value.title,
|
|
1226
|
+
url: value.URL,
|
|
1227
|
+
readyState: value.readyState
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Handle Window
|
|
1232
|
+
if (value === window) {
|
|
1233
|
+
return {
|
|
1234
|
+
type: 'Window',
|
|
1235
|
+
location: value.location.href,
|
|
1236
|
+
innerWidth: value.innerWidth,
|
|
1237
|
+
innerHeight: value.innerHeight
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Handle arrays
|
|
1242
|
+
if (Array.isArray(value)) {
|
|
1243
|
+
try {
|
|
1244
|
+
return { type: 'array', value: JSON.parse(JSON.stringify(value)) };
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
return { type: 'array', length: value.length, repr: '[Array with circular references]' };
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Handle plain objects
|
|
1251
|
+
if (type === 'object') {
|
|
1252
|
+
try {
|
|
1253
|
+
return { type: 'object', value: JSON.parse(JSON.stringify(value)) };
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
const keys = Object.keys(value).slice(0, 20);
|
|
1256
|
+
return { type: 'object', keys: keys, repr: '[Object with circular references]' };
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return { type: 'unknown', repr: String(value) };
|
|
1261
|
+
}`;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Process the serialized result into a clean output format
|
|
1266
|
+
* @param {Object} serialized - The serialized result from browser
|
|
1267
|
+
* @returns {Object} Processed output
|
|
1268
|
+
*/
|
|
1269
|
+
function processResult(serialized) {
|
|
1270
|
+
if (!serialized || typeof serialized !== 'object') {
|
|
1271
|
+
return { type: 'unknown', value: serialized };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const result = {
|
|
1275
|
+
type: serialized.type
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
// Include value if present
|
|
1279
|
+
if (serialized.value !== undefined) {
|
|
1280
|
+
result.value = serialized.value;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Include repr for non-serializable values
|
|
1284
|
+
if (serialized.repr !== undefined) {
|
|
1285
|
+
result.repr = serialized.repr;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Include additional properties based on type
|
|
1289
|
+
switch (serialized.type) {
|
|
1290
|
+
case 'Date':
|
|
1291
|
+
result.timestamp = serialized.timestamp;
|
|
1292
|
+
break;
|
|
1293
|
+
case 'Map':
|
|
1294
|
+
result.size = serialized.size;
|
|
1295
|
+
result.entries = serialized.entries;
|
|
1296
|
+
break;
|
|
1297
|
+
case 'Set':
|
|
1298
|
+
result.size = serialized.size;
|
|
1299
|
+
result.values = serialized.values;
|
|
1300
|
+
break;
|
|
1301
|
+
case 'Element':
|
|
1302
|
+
result.tagName = serialized.tagName;
|
|
1303
|
+
result.id = serialized.id;
|
|
1304
|
+
result.className = serialized.className;
|
|
1305
|
+
result.attributes = serialized.attributes;
|
|
1306
|
+
result.textContent = serialized.textContent;
|
|
1307
|
+
result.isConnected = serialized.isConnected;
|
|
1308
|
+
result.childElementCount = serialized.childElementCount;
|
|
1309
|
+
break;
|
|
1310
|
+
case 'NodeList':
|
|
1311
|
+
case 'HTMLCollection':
|
|
1312
|
+
result.length = serialized.length;
|
|
1313
|
+
result.items = serialized.items;
|
|
1314
|
+
break;
|
|
1315
|
+
case 'Error':
|
|
1316
|
+
result.name = serialized.name;
|
|
1317
|
+
result.message = serialized.message;
|
|
1318
|
+
if (serialized.stack) result.stack = serialized.stack;
|
|
1319
|
+
break;
|
|
1320
|
+
case 'Document':
|
|
1321
|
+
result.title = serialized.title;
|
|
1322
|
+
result.url = serialized.url;
|
|
1323
|
+
result.readyState = serialized.readyState;
|
|
1324
|
+
break;
|
|
1325
|
+
case 'Window':
|
|
1326
|
+
result.location = serialized.location;
|
|
1327
|
+
result.innerWidth = serialized.innerWidth;
|
|
1328
|
+
result.innerHeight = serialized.innerHeight;
|
|
1329
|
+
break;
|
|
1330
|
+
case 'array':
|
|
1331
|
+
result.length = Array.isArray(serialized.value) ? serialized.value.length : serialized.length;
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
return result;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return {
|
|
1339
|
+
getSerializationFunction,
|
|
1340
|
+
processResult
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Get the serialization function (convenience export)
|
|
1346
|
+
* @returns {string} JavaScript function declaration
|
|
1347
|
+
*/
|
|
1348
|
+
export function getEvalSerializationFunction() {
|
|
1349
|
+
return createEvalSerializer().getSerializationFunction();
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Process a serialized eval result (convenience export)
|
|
1354
|
+
* @param {Object} serialized - The serialized result from browser
|
|
1355
|
+
* @returns {Object} Processed output
|
|
1356
|
+
*/
|
|
1357
|
+
export function processEvalResult(serialized) {
|
|
1358
|
+
return createEvalSerializer().processResult(serialized);
|
|
1359
|
+
}
|