qai-cli 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Visual Regression Testing Utility
3
+ * Compares screenshots between runs to detect visual changes
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { PNG } = require('pngjs');
9
+ const pixelmatch = require('pixelmatch');
10
+
11
+ /**
12
+ * Compare two images and generate a diff
13
+ *
14
+ * @param {string} baselinePath - Path to baseline image
15
+ * @param {string} currentPath - Path to current image
16
+ * @param {string} diffPath - Path to save diff image
17
+ * @param {Object} options - Comparison options
18
+ * @returns {Promise<{match: boolean, diffPixels: number, diffPercent: number, dimensions: Object}>}
19
+ */
20
+ async function compareImages(baselinePath, currentPath, diffPath, options = {}) {
21
+ const { threshold = 0.1, includeAA = false } = options;
22
+
23
+ // Read images
24
+ const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
25
+ const current = PNG.sync.read(fs.readFileSync(currentPath));
26
+
27
+ // Check dimensions match
28
+ if (baseline.width !== current.width || baseline.height !== current.height) {
29
+ return {
30
+ match: false,
31
+ error: 'dimension_mismatch',
32
+ baseline: { width: baseline.width, height: baseline.height },
33
+ current: { width: current.width, height: current.height },
34
+ diffPixels: -1,
35
+ diffPercent: 100,
36
+ };
37
+ }
38
+
39
+ const { width, height } = baseline;
40
+ const diff = new PNG({ width, height });
41
+
42
+ // Compare pixels
43
+ const diffPixels = pixelmatch(baseline.data, current.data, diff.data, width, height, {
44
+ threshold,
45
+ includeAA,
46
+ alpha: 0.1,
47
+ diffColor: [255, 0, 0], // Red for differences
48
+ diffColorAlt: [0, 255, 0], // Green for anti-aliasing
49
+ });
50
+
51
+ // Calculate percentage
52
+ const totalPixels = width * height;
53
+ const diffPercent = (diffPixels / totalPixels) * 100;
54
+
55
+ // Save diff image
56
+ if (diffPath) {
57
+ fs.mkdirSync(path.dirname(diffPath), { recursive: true });
58
+ fs.writeFileSync(diffPath, PNG.sync.write(diff));
59
+ }
60
+
61
+ return {
62
+ match: diffPixels === 0,
63
+ diffPixels,
64
+ diffPercent: parseFloat(diffPercent.toFixed(2)),
65
+ dimensions: { width, height },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Compare all screenshots in two directories
71
+ *
72
+ * @param {string} baselineDir - Directory with baseline screenshots
73
+ * @param {string} currentDir - Directory with current screenshots
74
+ * @param {string} diffDir - Directory to save diff images
75
+ * @param {Object} options - Comparison options
76
+ * @returns {Promise<{summary: Object, results: Array}>}
77
+ */
78
+ async function compareDirectories(baselineDir, currentDir, diffDir, options = {}) {
79
+ const { threshold = 0.1, failThreshold = 0.5 } = options;
80
+
81
+ const results = [];
82
+ let passed = 0;
83
+ let failed = 0;
84
+ let missing = 0;
85
+ let newImages = 0;
86
+
87
+ // Get all PNG files from both directories
88
+ const baselineFiles = fs.existsSync(baselineDir)
89
+ ? fs.readdirSync(baselineDir).filter((f) => f.endsWith('.png'))
90
+ : [];
91
+ const currentFiles = fs.existsSync(currentDir)
92
+ ? fs.readdirSync(currentDir).filter((f) => f.endsWith('.png'))
93
+ : [];
94
+
95
+ const allFiles = new Set([...baselineFiles, ...currentFiles]);
96
+
97
+ for (const file of allFiles) {
98
+ const baselinePath = path.join(baselineDir, file);
99
+ const currentPath = path.join(currentDir, file);
100
+ const diffPath = path.join(diffDir, `diff-${file}`);
101
+
102
+ const result = { file };
103
+
104
+ if (!fs.existsSync(baselinePath)) {
105
+ // New screenshot (no baseline)
106
+ result.status = 'new';
107
+ result.message = 'New screenshot - no baseline to compare';
108
+ newImages++;
109
+ } else if (!fs.existsSync(currentPath)) {
110
+ // Missing screenshot (was in baseline)
111
+ result.status = 'missing';
112
+ result.message = 'Screenshot missing from current run';
113
+ missing++;
114
+ } else {
115
+ // Compare images
116
+ const comparison = await compareImages(baselinePath, currentPath, diffPath, { threshold });
117
+
118
+ if (comparison.error === 'dimension_mismatch') {
119
+ result.status = 'failed';
120
+ result.message = `Dimension mismatch: baseline ${comparison.baseline.width}x${comparison.baseline.height} vs current ${comparison.current.width}x${comparison.current.height}`;
121
+ result.diffPath = null;
122
+ failed++;
123
+ } else if (comparison.diffPercent > failThreshold) {
124
+ result.status = 'failed';
125
+ result.message = `${comparison.diffPercent}% pixels differ (threshold: ${failThreshold}%)`;
126
+ result.diffPixels = comparison.diffPixels;
127
+ result.diffPercent = comparison.diffPercent;
128
+ result.diffPath = diffPath;
129
+ failed++;
130
+ } else if (comparison.diffPixels > 0) {
131
+ result.status = 'warning';
132
+ result.message = `Minor differences: ${comparison.diffPercent}% pixels differ`;
133
+ result.diffPixels = comparison.diffPixels;
134
+ result.diffPercent = comparison.diffPercent;
135
+ result.diffPath = diffPath;
136
+ passed++;
137
+ } else {
138
+ result.status = 'passed';
139
+ result.message = 'Images match exactly';
140
+ result.diffPercent = 0;
141
+ passed++;
142
+ }
143
+ }
144
+
145
+ results.push(result);
146
+ }
147
+
148
+ return {
149
+ summary: {
150
+ total: allFiles.size,
151
+ passed,
152
+ failed,
153
+ missing,
154
+ new: newImages,
155
+ passRate: allFiles.size > 0 ? parseFloat(((passed / allFiles.size) * 100).toFixed(1)) : 100,
156
+ },
157
+ results,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Generate markdown report for visual regression results
163
+ *
164
+ * @param {Object} comparison - Result from compareDirectories
165
+ * @returns {string} Markdown formatted report
166
+ */
167
+ function generateReport(comparison) {
168
+ const { summary, results } = comparison;
169
+
170
+ let report = `## Visual Regression Report\n\n`;
171
+
172
+ // Summary
173
+ report += `### Summary\n`;
174
+ report += `- **Total Screenshots**: ${summary.total}\n`;
175
+ report += `- **Passed**: ${summary.passed} ✅\n`;
176
+ report += `- **Failed**: ${summary.failed} ❌\n`;
177
+ report += `- **New**: ${summary.new} 🆕\n`;
178
+ report += `- **Missing**: ${summary.missing} ⚠️\n`;
179
+ report += `- **Pass Rate**: ${summary.passRate}%\n\n`;
180
+
181
+ // Failed comparisons
182
+ const failed = results.filter((r) => r.status === 'failed');
183
+ if (failed.length > 0) {
184
+ report += `### Failed Comparisons ❌\n\n`;
185
+ failed.forEach((r) => {
186
+ report += `#### ${r.file}\n`;
187
+ report += `- **Status**: Failed\n`;
188
+ report += `- **Reason**: ${r.message}\n`;
189
+ if (r.diffPath) {
190
+ report += `- **Diff Image**: ${path.basename(r.diffPath)}\n`;
191
+ }
192
+ report += `\n`;
193
+ });
194
+ }
195
+
196
+ // Warnings (minor differences)
197
+ const warnings = results.filter((r) => r.status === 'warning');
198
+ if (warnings.length > 0) {
199
+ report += `### Minor Differences ⚠️\n\n`;
200
+ warnings.forEach((r) => {
201
+ report += `- **${r.file}**: ${r.diffPercent}% difference\n`;
202
+ });
203
+ report += `\n`;
204
+ }
205
+
206
+ // New screenshots
207
+ const newScreenshots = results.filter((r) => r.status === 'new');
208
+ if (newScreenshots.length > 0) {
209
+ report += `### New Screenshots 🆕\n\n`;
210
+ report += `These screenshots have no baseline yet:\n`;
211
+ newScreenshots.forEach((r) => {
212
+ report += `- ${r.file}\n`;
213
+ });
214
+ report += `\n`;
215
+ }
216
+
217
+ // Missing screenshots
218
+ const missingScreenshots = results.filter((r) => r.status === 'missing');
219
+ if (missingScreenshots.length > 0) {
220
+ report += `### Missing Screenshots ⚠️\n\n`;
221
+ report += `These screenshots existed in baseline but not in current run:\n`;
222
+ missingScreenshots.forEach((r) => {
223
+ report += `- ${r.file}\n`;
224
+ });
225
+ report += `\n`;
226
+ }
227
+
228
+ return report;
229
+ }
230
+
231
+ /**
232
+ * Update baseline screenshots from current run
233
+ *
234
+ * @param {string} currentDir - Directory with current screenshots
235
+ * @param {string} baselineDir - Directory to save as baseline
236
+ * @param {Object} options - Options
237
+ * @returns {Object} Update summary
238
+ */
239
+ function updateBaseline(currentDir, baselineDir, options = {}) {
240
+ const { overwrite = true } = options;
241
+
242
+ fs.mkdirSync(baselineDir, { recursive: true });
243
+
244
+ const currentFiles = fs.readdirSync(currentDir).filter((f) => f.endsWith('.png'));
245
+ let copied = 0;
246
+ let skipped = 0;
247
+
248
+ currentFiles.forEach((file) => {
249
+ const src = path.join(currentDir, file);
250
+ const dest = path.join(baselineDir, file);
251
+
252
+ if (!overwrite && fs.existsSync(dest)) {
253
+ skipped++;
254
+ } else {
255
+ fs.copyFileSync(src, dest);
256
+ copied++;
257
+ }
258
+ });
259
+
260
+ return {
261
+ copied,
262
+ skipped,
263
+ total: currentFiles.length,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * CLI interface
269
+ */
270
+ async function main() {
271
+ const args = process.argv.slice(2);
272
+ const command = args[0];
273
+
274
+ if (command === 'compare') {
275
+ const baselineDir = args[1] || './screenshots/baseline';
276
+ const currentDir = args[2] || './screenshots';
277
+ const diffDir = args[3] || './screenshots/diff';
278
+
279
+ console.log(`Comparing screenshots...`);
280
+ console.log(` Baseline: ${baselineDir}`);
281
+ console.log(` Current: ${currentDir}`);
282
+ console.log(` Diff output: ${diffDir}`);
283
+
284
+ const result = await compareDirectories(baselineDir, currentDir, diffDir);
285
+ const report = generateReport(result);
286
+
287
+ console.log('\n' + report);
288
+
289
+ // Save report
290
+ fs.writeFileSync('visual-regression-report.md', report);
291
+ console.log('Report saved to visual-regression-report.md');
292
+
293
+ // Exit with error if any failed
294
+ if (result.summary.failed > 0) {
295
+ process.exit(1);
296
+ }
297
+ } else if (command === 'update-baseline') {
298
+ const currentDir = args[1] || './screenshots';
299
+ const baselineDir = args[2] || './screenshots/baseline';
300
+
301
+ console.log(`Updating baseline screenshots...`);
302
+ console.log(` Source: ${currentDir}`);
303
+ console.log(` Baseline: ${baselineDir}`);
304
+
305
+ const result = updateBaseline(currentDir, baselineDir);
306
+ console.log(`\nBaseline updated:`);
307
+ console.log(` Copied: ${result.copied}`);
308
+ console.log(` Skipped: ${result.skipped}`);
309
+ console.log(` Total: ${result.total}`);
310
+ } else {
311
+ console.log(`
312
+ Visual Regression Testing Utility
313
+
314
+ Usage:
315
+ node visual-regression.js compare [baselineDir] [currentDir] [diffDir]
316
+ Compare screenshots and generate diff images
317
+
318
+ node visual-regression.js update-baseline [currentDir] [baselineDir]
319
+ Update baseline screenshots from current run
320
+
321
+ Examples:
322
+ node visual-regression.js compare ./baseline ./screenshots ./diff
323
+ node visual-regression.js update-baseline ./screenshots ./baseline
324
+ `);
325
+ }
326
+ }
327
+
328
+ // Export functions for programmatic use
329
+ module.exports = {
330
+ compareImages,
331
+ compareDirectories,
332
+ generateReport,
333
+ updateBaseline,
334
+ };
335
+
336
+ // Run CLI if executed directly
337
+ if (require.main === module) {
338
+ main().catch(console.error);
339
+ }
package/src/analyze.js ADDED
@@ -0,0 +1,365 @@
1
+ /**
2
+ * qaie - Library Export
3
+ *
4
+ * Use this in your Playwright tests to add AI-powered QA analysis.
5
+ *
6
+ * @example
7
+ * import { test, expect } from '@playwright/test';
8
+ * import { analyzeWithAI } from 'qaie';
9
+ *
10
+ * test('AI QA: homepage', async ({ page }) => {
11
+ * await page.goto('/');
12
+ * const report = await analyzeWithAI(page);
13
+ *
14
+ * // Attach screenshots to test report
15
+ * for (const screenshot of report.screenshots) {
16
+ * await test.info().attach(screenshot.name, {
17
+ * body: screenshot.buffer,
18
+ * contentType: 'image/png'
19
+ * });
20
+ * }
21
+ *
22
+ * expect(report.criticalBugs).toHaveLength(0);
23
+ * });
24
+ */
25
+
26
+ const { getProvider, createProvider } = require('./providers');
27
+
28
+ /**
29
+ * Viewport configurations
30
+ */
31
+ const VIEWPORT_CONFIGS = {
32
+ mobile: { width: 375, height: 667, name: 'mobile' },
33
+ tablet: { width: 768, height: 1024, name: 'tablet' },
34
+ desktop: { width: 1920, height: 1080, name: 'desktop' },
35
+ };
36
+
37
+ /**
38
+ * Analyze a page with AI
39
+ *
40
+ * @param {import('playwright').Page} page - Playwright page object
41
+ * @param {Object} options - Analysis options
42
+ * @param {string[]} [options.viewports=['desktop', 'mobile']] - Viewports to test
43
+ * @param {string} [options.focus='all'] - Focus area (all, accessibility, performance, forms, visual)
44
+ * @param {string} [options.provider] - LLM provider (anthropic, openai, gemini, ollama)
45
+ * @param {string} [options.apiKey] - API key (uses env var if not provided)
46
+ * @returns {Promise<AnalysisReport>} Analysis report with bugs, screenshots, and recommendations
47
+ */
48
+ async function analyzeWithAI(page, options = {}) {
49
+ const {
50
+ viewports = ['desktop', 'mobile'],
51
+ focus = 'all',
52
+ provider: providerName,
53
+ apiKey,
54
+ } = options;
55
+
56
+ const startTime = Date.now();
57
+
58
+ // Get or create provider
59
+ let provider;
60
+ if (providerName && apiKey) {
61
+ provider = createProvider(providerName, apiKey);
62
+ } else {
63
+ provider = getProvider();
64
+ }
65
+
66
+ // Capture page data
67
+ const captureData = await capturePageData(page, viewports);
68
+
69
+ // Analyze with AI
70
+ const analysis = await provider.analyze(captureData, { focus });
71
+
72
+ // Build report
73
+ const report = {
74
+ url: page.url(),
75
+ title: await page.title(),
76
+ timestamp: new Date().toISOString(),
77
+ duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`,
78
+ score: analysis.score,
79
+ summary: analysis.summary,
80
+ bugs: analysis.bugs || [],
81
+ criticalBugs: (analysis.bugs || []).filter(
82
+ (b) => b.severity === 'critical' || b.severity === 'high',
83
+ ),
84
+ recommendations: analysis.recommendations || [],
85
+ consoleErrors: captureData.consoleErrors,
86
+ networkErrors: captureData.networkErrors,
87
+ screenshots: captureData.screenshots,
88
+ viewports,
89
+ focus,
90
+ };
91
+
92
+ return report;
93
+ }
94
+
95
+ /**
96
+ * Capture page data including screenshots, console errors, and network errors
97
+ *
98
+ * @param {import('playwright').Page} page - Playwright page object
99
+ * @param {string[]} viewports - Viewports to capture
100
+ * @returns {Promise<CaptureData>}
101
+ */
102
+ async function capturePageData(page, viewports) {
103
+ const consoleErrors = [];
104
+ const networkErrors = [];
105
+ const screenshots = [];
106
+
107
+ // Set up console listener
108
+ const consoleHandler = (msg) => {
109
+ if (msg.type() === 'error') {
110
+ consoleErrors.push(msg.text());
111
+ }
112
+ };
113
+ page.on('console', consoleHandler);
114
+
115
+ // Set up network error listener
116
+ const requestFailedHandler = (request) => {
117
+ networkErrors.push({
118
+ url: request.url(),
119
+ method: request.method(),
120
+ failure: request.failure()?.errorText || 'Unknown error',
121
+ });
122
+ };
123
+ page.on('requestfailed', requestFailedHandler);
124
+
125
+ // Set up response listener for HTTP errors
126
+ const responseHandler = (response) => {
127
+ if (response.status() >= 400) {
128
+ networkErrors.push({
129
+ url: response.url(),
130
+ method: response.request().method(),
131
+ status: response.status(),
132
+ });
133
+ }
134
+ };
135
+ page.on('response', responseHandler);
136
+
137
+ // Capture ARIA snapshot for accessibility analysis
138
+ let ariaSnapshot = null;
139
+ try {
140
+ ariaSnapshot = await page.accessibility.snapshot();
141
+ if (ariaSnapshot) {
142
+ ariaSnapshot = JSON.stringify(ariaSnapshot, null, 2).slice(0, 8000);
143
+ }
144
+ } catch {
145
+ // accessibility.snapshot() may not be available in all contexts
146
+ }
147
+
148
+ // Capture DOM summary
149
+ let domSummary = null;
150
+ try {
151
+ /* eslint-disable no-undef */
152
+ domSummary = await page.evaluate(() => {
153
+ const headings = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(
154
+ (h) => `${h.tagName}: ${h.textContent.trim().slice(0, 80)}`,
155
+ );
156
+ const links = document.querySelectorAll('a[href]').length;
157
+ const buttons = document.querySelectorAll('button').length;
158
+ const inputs = document.querySelectorAll('input,textarea,select').length;
159
+ const images = [...document.querySelectorAll('img')].map((img) => ({
160
+ src: img.src.slice(0, 100),
161
+ alt: img.alt || '(no alt)',
162
+ }));
163
+ const noAlt = images.filter((i) => i.alt === '(no alt)').length;
164
+
165
+ return [
166
+ 'Headings: ' + (headings.join(' | ') || 'None'),
167
+ 'Links: ' + links + ', Buttons: ' + buttons + ', Inputs: ' + inputs,
168
+ 'Images: ' + images.length + ' total, ' + noAlt + ' missing alt text',
169
+ ].join('\n');
170
+ });
171
+ /* eslint-enable no-undef */
172
+ } catch {
173
+ // DOM evaluation may fail in some contexts
174
+ }
175
+
176
+ // Store original viewport
177
+ const originalViewport = page.viewportSize();
178
+
179
+ // Capture screenshots at each viewport
180
+ for (const viewportName of viewports) {
181
+ const config = VIEWPORT_CONFIGS[viewportName.toLowerCase()];
182
+ if (!config) {
183
+ console.warn(`Unknown viewport: ${viewportName}, skipping`);
184
+ continue;
185
+ }
186
+
187
+ // Set viewport
188
+ await page.setViewportSize({ width: config.width, height: config.height });
189
+
190
+ // Wait for any layout shifts
191
+ await page.waitForTimeout(500);
192
+
193
+ // Capture screenshot
194
+ const buffer = await page.screenshot({ fullPage: true });
195
+
196
+ screenshots.push({
197
+ name: `${config.name}-${config.width}x${config.height}`,
198
+ viewport: config.name,
199
+ width: config.width,
200
+ height: config.height,
201
+ buffer,
202
+ base64: buffer.toString('base64'),
203
+ });
204
+ }
205
+
206
+ // Restore original viewport
207
+ if (originalViewport) {
208
+ await page.setViewportSize(originalViewport);
209
+ }
210
+
211
+ // Clean up listeners
212
+ page.off('console', consoleHandler);
213
+ page.off('requestfailed', requestFailedHandler);
214
+ page.off('response', responseHandler);
215
+
216
+ return {
217
+ pageUrl: page.url(),
218
+ pageTitle: await page.title(),
219
+ timestamp: new Date().toISOString(),
220
+ consoleErrors,
221
+ networkErrors,
222
+ screenshots,
223
+ ariaSnapshot,
224
+ domSummary,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Create a Playwright test helper that runs AI analysis
230
+ * Use this to create reusable test fixtures
231
+ *
232
+ * @param {Object} defaultOptions - Default options for all analyses
233
+ * @returns {Function} Configured analyzeWithAI function
234
+ *
235
+ * @example
236
+ * // In your playwright fixtures
237
+ * import { createAnalyzer } from 'qaie';
238
+ *
239
+ * const analyzeWithAI = createAnalyzer({
240
+ * viewports: ['desktop', 'mobile', 'tablet'],
241
+ * focus: 'accessibility',
242
+ * });
243
+ *
244
+ * // In your tests
245
+ * test('homepage', async ({ page }) => {
246
+ * await page.goto('/');
247
+ * const report = await analyzeWithAI(page);
248
+ * });
249
+ */
250
+ function createAnalyzer(defaultOptions = {}) {
251
+ return (page, options = {}) => {
252
+ return analyzeWithAI(page, { ...defaultOptions, ...options });
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Attach all screenshots from a report to the Playwright test
258
+ * Convenience helper for test files
259
+ *
260
+ * @param {Object} testInfo - Playwright test.info() object
261
+ * @param {AnalysisReport} report - The AI analysis report
262
+ *
263
+ * @example
264
+ * test('AI QA', async ({ page }, testInfo) => {
265
+ * await page.goto('/');
266
+ * const report = await analyzeWithAI(page);
267
+ * await attachScreenshots(testInfo, report);
268
+ * expect(report.criticalBugs).toHaveLength(0);
269
+ * });
270
+ */
271
+ async function attachScreenshots(testInfo, report) {
272
+ for (const screenshot of report.screenshots) {
273
+ await testInfo.attach(screenshot.name, {
274
+ body: screenshot.buffer,
275
+ contentType: 'image/png',
276
+ });
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Attach bug details as a test attachment
282
+ *
283
+ * @param {Object} testInfo - Playwright test.info() object
284
+ * @param {AnalysisReport} report - The AI analysis report
285
+ */
286
+ async function attachBugReport(testInfo, report) {
287
+ const bugReport = {
288
+ score: report.score,
289
+ summary: report.summary,
290
+ bugs: report.bugs,
291
+ recommendations: report.recommendations,
292
+ consoleErrors: report.consoleErrors,
293
+ networkErrors: report.networkErrors,
294
+ };
295
+
296
+ await testInfo.attach('qai-report', {
297
+ body: JSON.stringify(bugReport, null, 2),
298
+ contentType: 'application/json',
299
+ });
300
+ }
301
+
302
+ module.exports = {
303
+ analyzeWithAI,
304
+ createAnalyzer,
305
+ attachScreenshots,
306
+ attachBugReport,
307
+ capturePageData,
308
+ VIEWPORT_CONFIGS,
309
+ };
310
+
311
+ /**
312
+ * @typedef {Object} AnalysisReport
313
+ * @property {string} url - Page URL
314
+ * @property {string} title - Page title
315
+ * @property {string} timestamp - ISO timestamp
316
+ * @property {string} duration - Analysis duration
317
+ * @property {number|null} score - QA score (0-100)
318
+ * @property {string} summary - AI-generated summary
319
+ * @property {Bug[]} bugs - All bugs found
320
+ * @property {Bug[]} criticalBugs - Only critical/high severity bugs
321
+ * @property {string[]} recommendations - AI recommendations
322
+ * @property {string[]} consoleErrors - Console errors captured
323
+ * @property {NetworkError[]} networkErrors - Network errors captured
324
+ * @property {Screenshot[]} screenshots - Screenshots taken
325
+ * @property {string[]} viewports - Viewports tested
326
+ * @property {string} focus - Focus area used
327
+ */
328
+
329
+ /**
330
+ * @typedef {Object} Bug
331
+ * @property {string} title - Bug title
332
+ * @property {string} description - Bug description
333
+ * @property {'critical'|'high'|'medium'|'low'} severity - Bug severity
334
+ * @property {string} category - Bug category
335
+ * @property {string} [viewport] - Viewport where bug was found
336
+ * @property {string} [recommendation] - How to fix
337
+ */
338
+
339
+ /**
340
+ * @typedef {Object} Screenshot
341
+ * @property {string} name - Screenshot name
342
+ * @property {string} viewport - Viewport name
343
+ * @property {number} width - Viewport width
344
+ * @property {number} height - Viewport height
345
+ * @property {Buffer} buffer - Screenshot buffer
346
+ * @property {string} base64 - Base64 encoded screenshot
347
+ */
348
+
349
+ /**
350
+ * @typedef {Object} NetworkError
351
+ * @property {string} url - Request URL
352
+ * @property {string} method - HTTP method
353
+ * @property {number} [status] - HTTP status code
354
+ * @property {string} [failure] - Failure reason
355
+ */
356
+
357
+ /**
358
+ * @typedef {Object} CaptureData
359
+ * @property {string} pageUrl - Page URL
360
+ * @property {string} pageTitle - Page title
361
+ * @property {string} timestamp - ISO timestamp
362
+ * @property {string[]} consoleErrors - Console errors
363
+ * @property {NetworkError[]} networkErrors - Network errors
364
+ * @property {Screenshot[]} screenshots - Screenshots
365
+ */