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/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;