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/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
+ }