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
package/src/capture.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const { chromium } = require('playwright');
|
|
2
|
+
const fs = require('fs').promises;
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const VIEWPORTS = {
|
|
6
|
+
desktop: { width: 1920, height: 1080 },
|
|
7
|
+
tablet: { width: 768, height: 1024 },
|
|
8
|
+
mobile: { width: 375, height: 667 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Capture page data for QA analysis
|
|
13
|
+
* @param {string} url - URL to test
|
|
14
|
+
* @param {Object} options - Capture options
|
|
15
|
+
* @returns {Promise<Object>} Capture data
|
|
16
|
+
*/
|
|
17
|
+
async function capturePage(url, options = {}) {
|
|
18
|
+
const {
|
|
19
|
+
viewports = ['desktop', 'mobile'],
|
|
20
|
+
timeout = 30000,
|
|
21
|
+
screenshotDir = './screenshots',
|
|
22
|
+
} = options;
|
|
23
|
+
|
|
24
|
+
// Ensure screenshot directory exists
|
|
25
|
+
await fs.mkdir(screenshotDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const browser = await chromium.launch({
|
|
28
|
+
headless: true,
|
|
29
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const captureData = {
|
|
33
|
+
pageUrl: url,
|
|
34
|
+
pageTitle: '',
|
|
35
|
+
screenshots: [],
|
|
36
|
+
consoleErrors: [],
|
|
37
|
+
consoleWarnings: [],
|
|
38
|
+
networkErrors: [],
|
|
39
|
+
networkRequests: [],
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const context = await browser.newContext();
|
|
45
|
+
const page = await context.newPage();
|
|
46
|
+
|
|
47
|
+
// Collect console messages
|
|
48
|
+
page.on('console', (msg) => {
|
|
49
|
+
const type = msg.type();
|
|
50
|
+
const text = msg.text();
|
|
51
|
+
|
|
52
|
+
if (type === 'error') {
|
|
53
|
+
captureData.consoleErrors.push(text);
|
|
54
|
+
} else if (type === 'warning') {
|
|
55
|
+
captureData.consoleWarnings.push(text);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Collect network errors
|
|
60
|
+
page.on('requestfailed', (request) => {
|
|
61
|
+
captureData.networkErrors.push({
|
|
62
|
+
url: request.url(),
|
|
63
|
+
method: request.method(),
|
|
64
|
+
failure: request.failure()?.errorText || 'Unknown error',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Collect all network requests for analysis
|
|
69
|
+
page.on('response', (response) => {
|
|
70
|
+
const status = response.status();
|
|
71
|
+
if (status >= 400) {
|
|
72
|
+
captureData.networkErrors.push({
|
|
73
|
+
url: response.url(),
|
|
74
|
+
status,
|
|
75
|
+
statusText: response.statusText(),
|
|
76
|
+
method: response.request().method(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Navigate to the page
|
|
82
|
+
console.log(`Navigating to ${url}...`);
|
|
83
|
+
await page.goto(url, {
|
|
84
|
+
waitUntil: 'networkidle',
|
|
85
|
+
timeout,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Get page title
|
|
89
|
+
captureData.pageTitle = await page.title();
|
|
90
|
+
console.log(`Page title: ${captureData.pageTitle}`);
|
|
91
|
+
|
|
92
|
+
// Wait a bit for any lazy-loaded content
|
|
93
|
+
await page.waitForTimeout(1000);
|
|
94
|
+
|
|
95
|
+
// Capture screenshots at each viewport
|
|
96
|
+
for (const viewportName of viewports) {
|
|
97
|
+
const viewport = VIEWPORTS[viewportName];
|
|
98
|
+
if (!viewport) {
|
|
99
|
+
console.warn(`Unknown viewport: ${viewportName}, skipping`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(`Capturing ${viewportName} (${viewport.width}x${viewport.height})...`);
|
|
104
|
+
|
|
105
|
+
await page.setViewportSize(viewport);
|
|
106
|
+
await page.waitForTimeout(500); // Let layout settle
|
|
107
|
+
|
|
108
|
+
const screenshotPath = path.join(screenshotDir, `${viewportName}.png`);
|
|
109
|
+
const buffer = await page.screenshot({
|
|
110
|
+
path: screenshotPath,
|
|
111
|
+
fullPage: false,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
captureData.screenshots.push({
|
|
115
|
+
viewport: viewportName,
|
|
116
|
+
width: viewport.width,
|
|
117
|
+
height: viewport.height,
|
|
118
|
+
path: screenshotPath,
|
|
119
|
+
buffer,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(`Captured ${captureData.screenshots.length} screenshots`);
|
|
124
|
+
console.log(`Console errors: ${captureData.consoleErrors.length}`);
|
|
125
|
+
console.log(`Network errors: ${captureData.networkErrors.length}`);
|
|
126
|
+
} finally {
|
|
127
|
+
await browser.close();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return captureData;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { capturePage, VIEWPORTS };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const { capturePage } = require('./capture');
|
|
5
|
+
const { getProvider } = require('./providers');
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
const startTime = Date.now();
|
|
9
|
+
|
|
10
|
+
// Get configuration from environment (standard format first, then INPUT_ format for GitHub Actions)
|
|
11
|
+
const url = process.env.URL || process.env.INPUT_URL;
|
|
12
|
+
const viewportsRaw = process.env.VIEWPORTS || process.env.INPUT_VIEWPORTS || 'desktop,mobile';
|
|
13
|
+
const focus = process.env.FOCUS || process.env.INPUT_FOCUS || 'all';
|
|
14
|
+
const timeout = parseInt(process.env.TIMEOUT || process.env.INPUT_TIMEOUT || '300', 10) * 1000;
|
|
15
|
+
const outputFormat = process.env.OUTPUT_FORMAT || process.env.INPUT_OUTPUT_FORMAT || 'markdown';
|
|
16
|
+
|
|
17
|
+
if (!url) {
|
|
18
|
+
console.error('Error: URL is required (set URL or INPUT_URL env var)');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const viewports = viewportsRaw.split(',').map((v) => v.trim().toLowerCase());
|
|
23
|
+
|
|
24
|
+
console.log('='.repeat(60));
|
|
25
|
+
console.log('qai');
|
|
26
|
+
console.log('='.repeat(60));
|
|
27
|
+
console.log(`URL: ${url}`);
|
|
28
|
+
console.log(`Viewports: ${viewports.join(', ')}`);
|
|
29
|
+
console.log(`Focus: ${focus}`);
|
|
30
|
+
console.log('='.repeat(60));
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// Get the provider (auto-detected from env vars)
|
|
34
|
+
const provider = getProvider();
|
|
35
|
+
|
|
36
|
+
// Step 1: Capture page data
|
|
37
|
+
console.log('\n[1/3] Capturing page data...');
|
|
38
|
+
const captureData = await capturePage(url, {
|
|
39
|
+
viewports,
|
|
40
|
+
timeout,
|
|
41
|
+
screenshotDir: './screenshots',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Step 2: Analyze with LLM
|
|
45
|
+
console.log('\n[2/3] Analyzing with AI...');
|
|
46
|
+
const report = await provider.analyze(captureData, { focus });
|
|
47
|
+
|
|
48
|
+
// Step 3: Generate report
|
|
49
|
+
console.log('\n[3/3] Generating report...');
|
|
50
|
+
|
|
51
|
+
// Add metadata to report
|
|
52
|
+
report.metadata = {
|
|
53
|
+
url: captureData.pageUrl,
|
|
54
|
+
title: captureData.pageTitle,
|
|
55
|
+
timestamp: captureData.timestamp,
|
|
56
|
+
viewports,
|
|
57
|
+
focus,
|
|
58
|
+
consoleErrorCount: captureData.consoleErrors.length,
|
|
59
|
+
networkErrorCount: captureData.networkErrors.length,
|
|
60
|
+
duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Include raw errors in report
|
|
64
|
+
report.consoleErrors = captureData.consoleErrors;
|
|
65
|
+
report.networkErrors = captureData.networkErrors;
|
|
66
|
+
|
|
67
|
+
// Save report
|
|
68
|
+
if (outputFormat === 'json' || outputFormat === 'all') {
|
|
69
|
+
await fs.writeFile('qa-report.json', JSON.stringify(report, null, 2));
|
|
70
|
+
console.log('Saved: qa-report.json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (outputFormat === 'markdown' || outputFormat === 'all') {
|
|
74
|
+
const markdown = generateMarkdownReport(report);
|
|
75
|
+
await fs.writeFile('qa-report.md', markdown);
|
|
76
|
+
console.log('Saved: qa-report.md');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Print summary
|
|
80
|
+
console.log('\n' + '='.repeat(60));
|
|
81
|
+
console.log('QA Report Summary');
|
|
82
|
+
console.log('='.repeat(60));
|
|
83
|
+
console.log(`Score: ${report.score !== null ? report.score + '/100' : 'N/A'}`);
|
|
84
|
+
console.log(`Bugs found: ${report.bugs?.length || 0}`);
|
|
85
|
+
|
|
86
|
+
if (report.bugs?.length > 0) {
|
|
87
|
+
const critical = report.bugs.filter((b) => b.severity === 'critical').length;
|
|
88
|
+
const high = report.bugs.filter((b) => b.severity === 'high').length;
|
|
89
|
+
const medium = report.bugs.filter((b) => b.severity === 'medium').length;
|
|
90
|
+
const low = report.bugs.filter((b) => b.severity === 'low').length;
|
|
91
|
+
|
|
92
|
+
console.log(` - Critical: ${critical}`);
|
|
93
|
+
console.log(` - High: ${high}`);
|
|
94
|
+
console.log(` - Medium: ${medium}`);
|
|
95
|
+
console.log(` - Low: ${low}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`Duration: ${report.metadata.duration}`);
|
|
99
|
+
console.log('='.repeat(60));
|
|
100
|
+
|
|
101
|
+
// Set outputs for GitHub Actions
|
|
102
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
103
|
+
const outputs = [
|
|
104
|
+
`report=qa-report.${outputFormat === 'json' ? 'json' : 'md'}`,
|
|
105
|
+
'screenshots=./screenshots',
|
|
106
|
+
`bugs_found=${report.bugs?.length || 0}`,
|
|
107
|
+
`critical_bugs=${report.bugs?.filter((b) => ['critical', 'high'].includes(b.severity)).length || 0}`,
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, outputs.join('\n') + '\n');
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error('\nError:', error.message);
|
|
114
|
+
console.error(error.stack);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function generateMarkdownReport(report) {
|
|
120
|
+
const lines = [];
|
|
121
|
+
|
|
122
|
+
lines.push('# QA Report');
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push(`**URL:** ${report.metadata.url}`);
|
|
125
|
+
lines.push(`**Title:** ${report.metadata.title}`);
|
|
126
|
+
lines.push(`**Date:** ${report.metadata.timestamp}`);
|
|
127
|
+
lines.push(`**Duration:** ${report.metadata.duration}`);
|
|
128
|
+
lines.push(`**Score:** ${report.score !== null ? report.score + '/100' : 'N/A'}`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
lines.push('## Summary');
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push(report.summary || 'No summary provided.');
|
|
134
|
+
lines.push('');
|
|
135
|
+
|
|
136
|
+
if (report.bugs?.length > 0) {
|
|
137
|
+
lines.push('## Bugs Found');
|
|
138
|
+
lines.push('');
|
|
139
|
+
|
|
140
|
+
for (const bug of report.bugs) {
|
|
141
|
+
const severityEmoji =
|
|
142
|
+
{
|
|
143
|
+
critical: '🔴',
|
|
144
|
+
high: '🟠',
|
|
145
|
+
medium: '🟡',
|
|
146
|
+
low: '🟢',
|
|
147
|
+
}[bug.severity] || '⚪';
|
|
148
|
+
|
|
149
|
+
lines.push(`### ${severityEmoji} ${bug.title}`);
|
|
150
|
+
lines.push('');
|
|
151
|
+
lines.push(`**Severity:** ${bug.severity}`);
|
|
152
|
+
lines.push(`**Category:** ${bug.category}`);
|
|
153
|
+
if (bug.viewport) {
|
|
154
|
+
lines.push(`**Viewport:** ${bug.viewport}`);
|
|
155
|
+
}
|
|
156
|
+
lines.push('');
|
|
157
|
+
lines.push(bug.description);
|
|
158
|
+
lines.push('');
|
|
159
|
+
if (bug.recommendation) {
|
|
160
|
+
lines.push(`**Recommendation:** ${bug.recommendation}`);
|
|
161
|
+
lines.push('');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
lines.push('## Bugs Found');
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push('No bugs found.');
|
|
168
|
+
lines.push('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (report.consoleErrors?.length > 0) {
|
|
172
|
+
lines.push('## Console Errors');
|
|
173
|
+
lines.push('');
|
|
174
|
+
for (const error of report.consoleErrors) {
|
|
175
|
+
lines.push(`- ${error}`);
|
|
176
|
+
}
|
|
177
|
+
lines.push('');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (report.networkErrors?.length > 0) {
|
|
181
|
+
lines.push('## Network Errors');
|
|
182
|
+
lines.push('');
|
|
183
|
+
for (const error of report.networkErrors) {
|
|
184
|
+
lines.push(`- \`${error.method || 'GET'} ${error.url}\`: ${error.status || error.failure}`);
|
|
185
|
+
}
|
|
186
|
+
lines.push('');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (report.recommendations?.length > 0) {
|
|
190
|
+
lines.push('## Recommendations');
|
|
191
|
+
lines.push('');
|
|
192
|
+
for (const rec of report.recommendations) {
|
|
193
|
+
lines.push(`- ${rec}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push('');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lines.push('---');
|
|
199
|
+
lines.push('*Generated by [qai](https://github.com/tyler-james-bridges/qaie)*');
|
|
200
|
+
|
|
201
|
+
return lines.join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
2
|
+
const BaseProvider = require('./base');
|
|
3
|
+
|
|
4
|
+
class AnthropicProvider extends BaseProvider {
|
|
5
|
+
constructor(apiKey, options = {}) {
|
|
6
|
+
super(apiKey, options);
|
|
7
|
+
this.client = new Anthropic({ apiKey });
|
|
8
|
+
this.model = options.model || 'claude-sonnet-4-20250514';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async analyze(captureData, options = {}) {
|
|
12
|
+
const prompt = this.buildPrompt(captureData, options);
|
|
13
|
+
|
|
14
|
+
// Build content array with images
|
|
15
|
+
const content = [];
|
|
16
|
+
|
|
17
|
+
// Add screenshots as images
|
|
18
|
+
for (const screenshot of captureData.screenshots) {
|
|
19
|
+
content.push({
|
|
20
|
+
type: 'image',
|
|
21
|
+
source: {
|
|
22
|
+
type: 'base64',
|
|
23
|
+
media_type: 'image/png',
|
|
24
|
+
data: screenshot.buffer.toString('base64'),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
content.push({
|
|
28
|
+
type: 'text',
|
|
29
|
+
text: `[Screenshot: ${screenshot.viewport} - ${screenshot.width}x${screenshot.height}]`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Add the analysis prompt
|
|
34
|
+
content.push({
|
|
35
|
+
type: 'text',
|
|
36
|
+
text: prompt,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const response = await this.client.messages.create({
|
|
40
|
+
model: this.model,
|
|
41
|
+
max_tokens: 4096,
|
|
42
|
+
messages: [
|
|
43
|
+
{
|
|
44
|
+
role: 'user',
|
|
45
|
+
content,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const responseText = response.content
|
|
51
|
+
.filter((block) => block.type === 'text')
|
|
52
|
+
.map((block) => block.text)
|
|
53
|
+
.join('\n');
|
|
54
|
+
|
|
55
|
+
return this.parseResponse(responseText);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = AnthropicProvider;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base provider class - defines the interface for all LLM providers
|
|
3
|
+
*/
|
|
4
|
+
class BaseProvider {
|
|
5
|
+
constructor(apiKey, options = {}) {
|
|
6
|
+
this.apiKey = apiKey;
|
|
7
|
+
this.options = options;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Analyze page capture data and return QA findings
|
|
12
|
+
* @param {Object} captureData - Page capture data
|
|
13
|
+
* @param {Object} options - Analysis options
|
|
14
|
+
* @returns {Promise<Object>} QA report
|
|
15
|
+
*/
|
|
16
|
+
// eslint-disable-next-line no-unused-vars
|
|
17
|
+
async analyze(captureData, options = {}) {
|
|
18
|
+
throw new Error('analyze() must be implemented by subclass');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the analysis prompt with focus-specific guidance
|
|
23
|
+
*/
|
|
24
|
+
buildPrompt(captureData, options) {
|
|
25
|
+
const { focus = 'all' } = options;
|
|
26
|
+
|
|
27
|
+
const focusGuidance = FOCUS_PROMPTS[focus] || FOCUS_PROMPTS.all;
|
|
28
|
+
|
|
29
|
+
const ariaSection = captureData.ariaSnapshot
|
|
30
|
+
? `\n## ARIA / Accessibility Tree\n\`\`\`\n${captureData.ariaSnapshot}\n\`\`\``
|
|
31
|
+
: '';
|
|
32
|
+
|
|
33
|
+
const domSection = captureData.domSummary ? `\n## DOM Summary\n${captureData.domSummary}` : '';
|
|
34
|
+
|
|
35
|
+
return `You are an expert QA engineer analyzing a webpage. Be concise and actionable. Report real issues only — do not invent problems.
|
|
36
|
+
|
|
37
|
+
## Page Information
|
|
38
|
+
- URL: ${captureData.pageUrl}
|
|
39
|
+
- Title: ${captureData.pageTitle}
|
|
40
|
+
|
|
41
|
+
## Console Errors (${captureData.consoleErrors.length})
|
|
42
|
+
${
|
|
43
|
+
captureData.consoleErrors.length > 0
|
|
44
|
+
? captureData.consoleErrors.map((e) => `- ${e}`).join('\n')
|
|
45
|
+
: 'None detected'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
## Network Errors (${captureData.networkErrors.length})
|
|
49
|
+
${
|
|
50
|
+
captureData.networkErrors.length > 0
|
|
51
|
+
? captureData.networkErrors.map((e) => `- ${e.url}: ${e.status} ${e.statusText}`).join('\n')
|
|
52
|
+
: 'None detected'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
## Screenshots Provided
|
|
56
|
+
${captureData.screenshots.map((s) => `- ${s.viewport}: ${s.width}x${s.height}`).join('\n')}
|
|
57
|
+
|
|
58
|
+
## Focus Area: ${focus}
|
|
59
|
+
${focusGuidance}
|
|
60
|
+
|
|
61
|
+
Report issues in this JSON format:
|
|
62
|
+
{
|
|
63
|
+
"summary": "Brief overall assessment",
|
|
64
|
+
"bugs": [
|
|
65
|
+
{
|
|
66
|
+
"severity": "critical|high|medium|low",
|
|
67
|
+
"category": "visual|functional|accessibility|performance|console|network|responsive",
|
|
68
|
+
"title": "Short description",
|
|
69
|
+
"description": "Detailed explanation with specific element references",
|
|
70
|
+
"viewport": "which viewport (if applicable)",
|
|
71
|
+
"recommendation": "How to fix"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"score": 0-100,
|
|
75
|
+
"recommendations": ["List of general improvements"]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
Only report actual issues. If the page looks good, say so with an empty bugs array and a high score.
|
|
79
|
+
Respond with ONLY the JSON, no markdown code blocks.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse LLM response into structured report
|
|
84
|
+
*/
|
|
85
|
+
parseResponse(response) {
|
|
86
|
+
try {
|
|
87
|
+
let jsonStr = response.trim();
|
|
88
|
+
|
|
89
|
+
// Remove markdown code blocks if present
|
|
90
|
+
if (jsonStr.startsWith('```')) {
|
|
91
|
+
jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```\n?$/g, '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return JSON.parse(jsonStr);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return {
|
|
97
|
+
summary: 'Failed to parse LLM response',
|
|
98
|
+
bugs: [],
|
|
99
|
+
score: null,
|
|
100
|
+
recommendations: [],
|
|
101
|
+
raw_response: response,
|
|
102
|
+
parse_error: error.message,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Focus-specific prompt guidance
|
|
110
|
+
*/
|
|
111
|
+
const FOCUS_PROMPTS = {
|
|
112
|
+
all:
|
|
113
|
+
'Check everything: visual consistency, responsiveness across viewports, ' +
|
|
114
|
+
'accessibility, console/network errors, interactive element states, ' +
|
|
115
|
+
'text readability, contrast, layout issues, and broken functionality.',
|
|
116
|
+
|
|
117
|
+
accessibility: `Focus on accessibility issues:
|
|
118
|
+
- Missing or incorrect ARIA labels/roles
|
|
119
|
+
- Color contrast failures (WCAG AA minimum 4.5:1 for text)
|
|
120
|
+
- Missing alt text on images
|
|
121
|
+
- Keyboard navigation issues (tab order, focus indicators)
|
|
122
|
+
- Screen reader compatibility (heading hierarchy, landmark regions)
|
|
123
|
+
- Form labels and error messages
|
|
124
|
+
- Touch target sizes (minimum 44x44px)`,
|
|
125
|
+
|
|
126
|
+
visual: `Focus on visual and design issues:
|
|
127
|
+
- Layout breaks or misalignment across viewports
|
|
128
|
+
- Overlapping elements
|
|
129
|
+
- Inconsistent spacing, padding, margins
|
|
130
|
+
- Text truncation or overflow
|
|
131
|
+
- Dark mode / light mode rendering problems
|
|
132
|
+
- Font rendering issues
|
|
133
|
+
- Image quality and sizing
|
|
134
|
+
- Z-index stacking issues`,
|
|
135
|
+
|
|
136
|
+
responsive: `Focus on responsive design:
|
|
137
|
+
- Layout differences between mobile, tablet, and desktop
|
|
138
|
+
- Elements that overflow or get cut off on smaller screens
|
|
139
|
+
- Touch targets too small on mobile
|
|
140
|
+
- Text that's too small to read on mobile
|
|
141
|
+
- Navigation usability on mobile (hamburger menu, etc.)
|
|
142
|
+
- Horizontal scrolling on mobile (usually a bug)
|
|
143
|
+
- Images not scaling properly`,
|
|
144
|
+
|
|
145
|
+
forms: `Focus on form usability:
|
|
146
|
+
- Input field labels and placeholders
|
|
147
|
+
- Validation feedback (inline errors, success states)
|
|
148
|
+
- Required field indicators
|
|
149
|
+
- Tab order between fields
|
|
150
|
+
- Submit button states (disabled, loading, success, error)
|
|
151
|
+
- Auto-fill compatibility
|
|
152
|
+
- Mobile keyboard types (email, number, tel)`,
|
|
153
|
+
|
|
154
|
+
performance: `Focus on performance indicators visible in the page:
|
|
155
|
+
- Lazy loading implementation
|
|
156
|
+
- Image optimization (large uncompressed images)
|
|
157
|
+
- Render-blocking resources
|
|
158
|
+
- Layout shifts (elements moving after load)
|
|
159
|
+
- Loading states and skeletons
|
|
160
|
+
- Excessive DOM elements
|
|
161
|
+
- Console warnings about performance`,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
module.exports = BaseProvider;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
|
2
|
+
const BaseProvider = require('./base');
|
|
3
|
+
|
|
4
|
+
class GeminiProvider extends BaseProvider {
|
|
5
|
+
constructor(apiKey, options = {}) {
|
|
6
|
+
super(apiKey, options);
|
|
7
|
+
this.genAI = new GoogleGenerativeAI(apiKey);
|
|
8
|
+
this.model = options.model || 'gemini-1.5-flash';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async analyze(captureData, options = {}) {
|
|
12
|
+
const prompt = this.buildPrompt(captureData, options);
|
|
13
|
+
const model = this.genAI.getGenerativeModel({ model: this.model });
|
|
14
|
+
|
|
15
|
+
// Build content array with images
|
|
16
|
+
const parts = [];
|
|
17
|
+
|
|
18
|
+
// Add screenshots as images
|
|
19
|
+
for (const screenshot of captureData.screenshots) {
|
|
20
|
+
parts.push({
|
|
21
|
+
inlineData: {
|
|
22
|
+
mimeType: 'image/png',
|
|
23
|
+
data: screenshot.buffer.toString('base64'),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
parts.push({
|
|
27
|
+
text: `[Screenshot: ${screenshot.viewport} - ${screenshot.width}x${screenshot.height}]`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Add the analysis prompt
|
|
32
|
+
parts.push({ text: prompt });
|
|
33
|
+
|
|
34
|
+
const result = await model.generateContent(parts);
|
|
35
|
+
const response = await result.response;
|
|
36
|
+
const responseText = response.text();
|
|
37
|
+
|
|
38
|
+
return this.parseResponse(responseText);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = GeminiProvider;
|