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.
- package/.claude/mcp-config.json +12 -0
- package/.claude/qa-engineer-prompt.md +194 -0
- package/.eslintrc.json +69 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +79 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +50 -0
- package/.github/ISSUE_TEMPLATE/security.md +43 -0
- package/.github/dependabot.yml +51 -0
- package/.github/pull_request_template.md +11 -0
- package/.github/workflows/lint.yml +35 -0
- package/.github/workflows/playwright-qa.yml +223 -0
- package/.github/workflows/qa-engineer.yml +309 -0
- package/.github/workflows/visual-regression.yml +192 -0
- package/.prettierrc.json +10 -0
- package/README.md +111 -0
- package/action.yml +149 -0
- package/docs/BUGS.md +43 -0
- package/docs/app.js +101 -0
- package/docs/index.html +129 -0
- package/docs/style.css +315 -0
- package/examples/workflow-local.yml +22 -0
- package/examples/workflow-with-vercel.yml +40 -0
- package/package.json +83 -0
- package/qa-report-agent.md +30 -0
- package/qa-report-kudos.md +35 -0
- package/scripts/aria-snapshot.js +328 -0
- package/scripts/page-utils.js +357 -0
- package/scripts/visual-regression.cjs +339 -0
- package/src/analyze.js +365 -0
- package/src/capture.js +133 -0
- package/src/index.js +204 -0
- package/src/providers/anthropic.js +59 -0
- package/src/providers/base.js +164 -0
- package/src/providers/gemini.js +42 -0
- package/src/providers/index.js +132 -0
- package/src/providers/ollama.js +49 -0
- package/src/providers/openai.js +54 -0
- package/src/types.d.ts +148 -0
|
@@ -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
|
+
*/
|