jpglens
Version:
๐ Universal AI-Powered UI Testing - See your interfaces through the lens of intelligence
3,048 lines โข 111 kB
JavaScript
import fs, { writeFileSync, readFileSync } from 'fs';
import path, { join } from 'path';
import 'url';
/**
* ๐ jpglens - Master Prompt System
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Master prompt template that provides comprehensive context to AI models
* This is the core intelligence that makes jpglens understand user experience
*/
function createMasterPrompt(context, analysisTypes) {
const { userContext, businessContext, technicalContext, stage, userIntent, criticalElements } = context;
return `You are a world-class UX expert, accessibility specialist, and design systems consultant analyzing a user interface through the lens of REAL USER EXPERIENCE.
๐ฏ **ANALYSIS CONTEXT**
User Stage: ${stage}
User Intent: ${userIntent}
Critical Elements: ${criticalElements?.join(', ') || 'All visible elements'}
๐ค **USER CONTEXT**
${formatUserContext(userContext)}
๐ข **BUSINESS CONTEXT**
${formatBusinessContext(businessContext)}
โ๏ธ **TECHNICAL CONTEXT**
${formatTechnicalContext(technicalContext)}
๐ **ANALYSIS REQUIREMENTS**
You must analyze this interface for: ${analysisTypes.join(', ')}
${generateAnalysisInstructions(analysisTypes)}
๐ **RESPONSE FORMAT**
Provide your analysis in this EXACT format:
**๐ฏ OVERALL UX SCORE: X/10**
**โ
STRENGTHS:**
- [What works exceptionally well for this specific user context]
**๐จ CRITICAL ISSUES:** (Blocks user success)
- [Issues that prevent task completion or cause significant user frustration]
**โ ๏ธ MAJOR ISSUES:** (Impacts user experience)
- [Problems that make the interface difficult or unpleasant to use]
**๐ก MINOR ISSUES:** (Polish opportunities)
- [Small improvements that would enhance the experience]
**๐ ๏ธ SPECIFIC RECOMMENDATIONS:**
For each issue, provide:
1. **Problem**: Clear description
2. **Impact**: How it affects the user in this context
3. **Solution**: Specific, actionable fix
4. **Priority**: Critical/Major/Minor
**๐จ CONTEXTUAL INSIGHTS:**
- How well does this interface serve the user's specific intent: "${userIntent}"?
- What would make this experience more successful for this user context?
- Are there any missed opportunities for this business context?
**๐ฑ DEVICE-SPECIFIC NOTES:**
- Any issues specific to the ${userContext.deviceContext} experience?
- Touch targets, readability, interaction patterns?
Remember: This user is ${getUserPersonaDescription(userContext.persona)} in the context of ${stage}. Every recommendation should consider their specific needs, constraints, and goals.
Be specific, actionable, and focused on REAL USER SUCCESS.`;
}
/**
* Format user context for the prompt
*/
function formatUserContext(userContext) {
const persona = typeof userContext.persona === 'string'
? `Persona: ${userContext.persona}`
: formatPersonaDetails(userContext.persona);
return `${persona}
Device Context: ${userContext.deviceContext}
Expertise Level: ${userContext.expertise || 'Not specified'}
Time Constraint: ${userContext.timeConstraint || 'Normal'}
Trust Level: ${userContext.trustLevel || 'Medium'}
Business Goals: ${userContext.businessGoals?.join(', ') || 'Not specified'}`;
}
/**
* Format business context for the prompt
*/
function formatBusinessContext(businessContext) {
if (!businessContext)
return 'Not specified';
return `Industry: ${businessContext.industry}
Conversion Goal: ${businessContext.conversionGoal}
Competitive Advantage: ${businessContext.competitiveAdvantage || 'Not specified'}
Brand Personality: ${businessContext.brandPersonality || 'Not specified'}
Target Audience: ${businessContext.targetAudience || 'Not specified'}`;
}
/**
* Format technical context for the prompt
*/
function formatTechnicalContext(technicalContext) {
if (!technicalContext)
return 'Not specified';
return `Framework: ${technicalContext.framework || 'Not specified'}
Design System: ${technicalContext.designSystem || 'Not specified'}
Device Support: ${technicalContext.deviceSupport}
Performance Target: ${technicalContext.performanceTarget || 'Not specified'}
Accessibility Target: ${technicalContext.accessibilityTarget || 'WCAG AA'}`;
}
/**
* Format persona details for the prompt
*/
function formatPersonaDetails(persona) {
return `Persona: ${persona.name}
Expertise: ${persona.expertise}
Primary Device: ${persona.device}
Urgency: ${persona.urgency}
Goals: ${persona.goals.join(', ')}
Pain Points: ${persona.painPoints?.join(', ') || 'Not specified'}
Context: ${persona.context || 'Not specified'}`;
}
/**
* Get user persona description for contextual understanding
*/
function getUserPersonaDescription(persona) {
if (!persona)
return 'a general user';
if (typeof persona === 'string')
return persona;
return `${persona.name} (${persona.expertise} level, ${persona.device} user, ${persona.urgency} urgency)`;
}
/**
* Generate specific analysis instructions based on requested types
*/
function generateAnalysisInstructions(analysisTypes) {
const instructions = [];
if (analysisTypes.includes('usability')) {
instructions.push(`
**๐งญ USABILITY ANALYSIS:**
- Is the interface intuitive for this user's expertise level?
- Can the user complete their intended task efficiently?
- Are there any confusing or misleading elements?
- Does the information architecture make sense?
- Are interactive elements clearly identifiable?`);
}
if (analysisTypes.includes('accessibility')) {
instructions.push(`
**โฟ ACCESSIBILITY ANALYSIS:**
- WCAG 2.1 compliance (AA minimum, AAA preferred)
- Color contrast ratios (4.5:1 for normal text, 3:1 for large text)
- Keyboard navigation support
- Screen reader compatibility (semantic HTML, ARIA labels)
- Focus management and visual focus indicators
- Alternative text for images
- Form labels and error handling`);
}
if (analysisTypes.includes('visual-design')) {
instructions.push(`
**๐จ VISUAL DESIGN ANALYSIS:**
- Typography hierarchy and readability
- Color usage and brand consistency
- Visual balance and composition
- Spacing and layout effectiveness
- Responsive design quality
- Visual feedback for interactions
- Overall aesthetic appeal and professionalism`);
}
if (analysisTypes.includes('performance')) {
instructions.push(`
**โก PERFORMANCE ANALYSIS:**
- Perceived performance and loading states
- Image optimization and lazy loading
- Critical rendering path
- User perception of speed
- Progressive enhancement
- Mobile performance considerations`);
}
if (analysisTypes.includes('mobile-optimization')) {
instructions.push(`
**๐ฑ MOBILE OPTIMIZATION ANALYSIS:**
- Touch target sizes (minimum 44px)
- Thumb-friendly navigation
- Mobile-first responsive design
- Portrait/landscape orientation support
- Mobile-specific interaction patterns
- One-handed usage considerations`);
}
if (analysisTypes.includes('conversion-optimization')) {
instructions.push(`
**๐ฐ CONVERSION OPTIMIZATION ANALYSIS:**
- Clear value proposition presentation
- Friction points in the conversion funnel
- Trust signals and credibility indicators
- Call-to-action effectiveness
- Form optimization
- User motivation and persuasion elements`);
}
if (analysisTypes.includes('brand-consistency')) {
instructions.push(`
**๐ฏ BRAND CONSISTENCY ANALYSIS:**
- Design system adherence
- Brand voice and tone in copy
- Visual identity consistency
- Component usage patterns
- Brand personality reflection
- Cross-platform consistency`);
}
if (analysisTypes.includes('error-handling')) {
instructions.push(`
**๐จ ERROR HANDLING ANALYSIS:**
- Error prevention strategies
- Clear error messaging
- Recovery path availability
- User guidance during errors
- Validation feedback timing
- Graceful degradation`);
}
return instructions.join('\n');
}
/**
* Create specialized prompts for specific scenarios
*/
const SpecializedPrompts = {
/**
* E-commerce focused analysis
*/
ecommerce: (context) => `
${createMasterPrompt(context, ['usability', 'conversion-optimization', 'mobile-optimization'])}
**๐ E-COMMERCE SPECIFIC FOCUS:**
- Product discoverability and presentation
- Shopping cart and checkout flow optimization
- Trust signals and security indicators
- Mobile shopping experience
- Price presentation and value communication
- Return policy and customer service accessibility`,
/**
* SaaS application analysis
*/
saas: (context) => `
${createMasterPrompt(context, ['usability', 'accessibility', 'performance'])}
**๐ผ SAAS SPECIFIC FOCUS:**
- Dashboard clarity and information hierarchy
- Feature discoverability and onboarding
- Data visualization effectiveness
- Workflow efficiency
- User role and permission clarity
- Integration and API documentation accessibility`,
/**
* Design system component analysis
*/
designSystem: (context) => `
${createMasterPrompt(context, ['visual-design', 'accessibility', 'brand-consistency'])}
**๐จ DESIGN SYSTEM SPECIFIC FOCUS:**
- Component consistency and reusability
- Documentation clarity and completeness
- Implementation flexibility
- Accessibility built-in by default
- Responsive behavior patterns
- Cross-browser compatibility`,
/**
* Mobile app analysis
*/
mobileApp: (context) => `
${createMasterPrompt(context, ['mobile-optimization', 'usability', 'performance'])}
**๐ฑ MOBILE APP SPECIFIC FOCUS:**
- Native platform conventions adherence
- Gesture support and touch interactions
- Offline functionality and sync
- App store guidelines compliance
- Battery and performance optimization
- Push notification integration`,
};
/**
* ๐ jpglens - OpenRouter AI Provider
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* OpenRouter AI provider for jpglens
* Supports multiple AI models through OpenRouter's unified API
*/
class OpenRouterProvider {
config;
name = 'OpenRouter';
baseUrl;
apiKey;
model;
constructor(config) {
this.config = config;
this.baseUrl = config.ai.baseUrl || 'https://openrouter.ai/api/v1';
this.apiKey = config.ai.apiKey;
this.model = config.ai.model;
if (!this.apiKey) {
throw new Error('OpenRouter API key is required. Set JPGLENS_API_KEY environment variable.');
}
}
/**
* Check if OpenRouter is available
*/
async isAvailable() {
try {
const response = await fetch(`${this.baseUrl}/models`, {
headers: {
Authorization: `Bearer ${this.apiKey}`,
'HTTP-Referer': 'https://jpglens.dev',
'X-Title': 'jpglens - Universal AI UI Testing',
},
});
return response.ok;
}
catch (error) {
console.warn('OpenRouter availability check failed:', error);
return false;
}
}
/**
* Get model information
*/
getModelInfo() {
return {
name: this.model,
capabilities: ['vision', 'text-analysis', 'code-generation', 'accessibility-analysis'],
};
}
/**
* Analyze screenshot with OpenRouter
*/
async analyze(screenshot, context, prompt) {
const startTime = Date.now();
try {
// Validate inputs
if (!screenshot.buffer || screenshot.buffer.length === 0) {
throw new Error('Invalid screenshot data');
}
// Convert screenshot to base64
const base64Image = screenshot.buffer.toString('base64');
// Prepare request body
const requestBody = {
model: this.model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt,
},
{
type: 'image_url',
image_url: {
url: `data:image/png;base64,${base64Image}`,
detail: this.config.analysis.depth === 'comprehensive' ? 'high' : 'auto',
},
},
],
},
],
max_tokens: this.config.ai.maxTokens || 4000,
temperature: this.config.ai.temperature || 0.1,
stream: false,
};
// Make API request
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://jpglens.dev',
'X-Title': 'jpglens - Universal AI UI Testing',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText} - ${errorText}`);
}
const result = await response.json();
if (!result.choices || result.choices.length === 0) {
throw new Error('No analysis result returned from OpenRouter');
}
const analysisText = result.choices[0].message.content;
const tokensUsed = result.usage?.total_tokens || 0;
// Parse the analysis text into structured result
const structuredResult = this.parseAnalysisResult(analysisText, context, tokensUsed, startTime);
return structuredResult;
}
catch (error) {
console.error('OpenRouter analysis failed:', error);
throw new Error(`OpenRouter analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Parse AI analysis text into structured result
*/
parseAnalysisResult(analysisText, context, tokensUsed, startTime) {
const analysisTime = Date.now() - startTime;
// Extract overall score
const scoreMatch = analysisText.match(/(?:OVERALL UX SCORE|QUALITY SCORE):\s*(\d+)\/10/i);
const overallScore = scoreMatch ? parseInt(scoreMatch[1]) : 5;
// Extract sections using regex patterns
const strengths = this.extractSection(analysisText, 'STRENGTHS');
const criticalIssues = this.extractIssues(analysisText, 'CRITICAL ISSUES', 'critical');
const majorIssues = this.extractIssues(analysisText, 'MAJOR ISSUES', 'major');
const minorIssues = this.extractIssues(analysisText, 'MINOR ISSUES', 'minor');
const recommendations = this.extractRecommendations(analysisText);
// Extract specific scores if available
const scores = {
usability: this.extractSpecificScore(analysisText, 'usability') || overallScore,
accessibility: this.extractSpecificScore(analysisText, 'accessibility') || overallScore,
visualDesign: this.extractSpecificScore(analysisText, 'visual') || overallScore,
performance: this.extractSpecificScore(analysisText, 'performance') || overallScore,
};
return {
id: `jpglens-openrouter-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
page: context.pageInfo?.url || 'unknown',
context,
overallScore,
scores,
strengths,
criticalIssues,
majorIssues,
minorIssues,
recommendations,
model: this.model,
tokensUsed,
analysisTime,
provider: 'OpenRouter',
rawAnalysis: analysisText, // Keep raw text for debugging
};
}
/**
* Extract a section from the analysis text
*/
extractSection(text, sectionName) {
const regex = new RegExp(`\\*\\*${sectionName}[:\\s]*\\*\\*([\\s\\S]*?)(?=\\*\\*[A-Z]|$)`, 'i');
const match = text.match(regex);
if (!match)
return [];
return match[1]
.split(/[-โข]\s+/)
.filter(item => item.trim().length > 0)
.map(item => item.trim().replace(/\n/g, ' '));
}
/**
* Extract issues from a section
*/
extractIssues(text, sectionName, severity) {
const items = this.extractSection(text, sectionName);
return items.map(item => ({
severity,
category: this.categorizeIssue(item),
title: this.extractTitle(item),
description: item,
impact: this.getImpactBySeverity(severity),
selector: this.extractSelector(item),
fix: this.extractFix(item),
}));
}
/**
* Extract recommendations from text
*/
extractRecommendations(text) {
const items = this.extractSection(text, 'RECOMMENDATIONS');
return items.map(item => ({
type: this.categorizeRecommendation(item),
title: this.extractTitle(item),
description: item,
implementation: this.extractCodeBlock(item) || item,
impact: this.assessImpact(item),
effort: this.assessEffort(item),
}));
}
/**
* Categorize issue by analyzing content
*/
categorizeIssue(issueText) {
const text = issueText.toLowerCase();
if (text.includes('contrast') ||
text.includes('accessibility') ||
text.includes('wcag') ||
text.includes('screen reader')) {
return 'accessibility';
}
if (text.includes('mobile') || text.includes('touch') || text.includes('responsive') || text.includes('44px')) {
return 'mobile-optimization';
}
if (text.includes('performance') || text.includes('loading') || text.includes('speed') || text.includes('slow')) {
return 'performance';
}
if (text.includes('visual') || text.includes('design') || text.includes('color') || text.includes('typography')) {
return 'visual-design';
}
if (text.includes('conversion') || text.includes('cta') || text.includes('purchase') || text.includes('signup')) {
return 'conversion-optimization';
}
return 'usability';
}
/**
* Extract title from text (first sentence or 50 chars)
*/
extractTitle(text) {
const firstSentence = text.split('.')[0];
return firstSentence.length > 50 ? firstSentence.substring(0, 47) + '...' : firstSentence;
}
/**
* Get impact description by severity
*/
getImpactBySeverity(severity) {
const impacts = {
critical: 'Prevents users from completing their tasks or causes significant frustration',
major: 'Makes the interface difficult or unpleasant to use, reducing user satisfaction',
minor: 'Small improvement that would enhance the overall user experience',
};
return impacts[severity];
}
/**
* Extract CSS selector from issue text
*/
extractSelector(text) {
const selectorPatterns = [
/\.[\w-]+/g, // CSS classes
/#[\w-]+/g, // CSS IDs
/\[[\w-]+=[\w-]+\]/g, // Attribute selectors
/button|input|form|div|span|a/gi, // HTML elements
];
for (const pattern of selectorPatterns) {
const matches = text.match(pattern);
if (matches) {
return matches[0];
}
}
return undefined;
}
/**
* Extract fix suggestion from text
*/
extractFix(text) {
const fixPatterns = [/(?:fix|solution|recommend)[:\s]+([^.]+)/i, /should[:\s]+([^.]+)/i, /change[:\s]+([^.]+)/i];
for (const pattern of fixPatterns) {
const match = text.match(pattern);
if (match) {
return match[1].trim();
}
}
return undefined;
}
/**
* Categorize recommendation type
*/
categorizeRecommendation(text) {
const lower = text.toLowerCase();
if (lower.includes('css') || lower.includes('html') || lower.includes('javascript') || lower.includes('```')) {
return 'code';
}
if (lower.includes('content') || lower.includes('copy') || lower.includes('text') || lower.includes('wording')) {
return 'content';
}
if (lower.includes('process') ||
lower.includes('workflow') ||
lower.includes('team') ||
lower.includes('testing')) {
return 'process';
}
return 'design';
}
/**
* Extract code block from text
*/
extractCodeBlock(text) {
const codeMatch = text.match(/```[\s\S]*?```/);
return codeMatch ? codeMatch[0] : undefined;
}
/**
* Assess recommendation impact
*/
assessImpact(text) {
const lower = text.toLowerCase();
if (lower.includes('critical') ||
lower.includes('conversion') ||
lower.includes('accessibility') ||
lower.includes('revenue')) {
return 'high';
}
if (lower.includes('major') || lower.includes('usability') || lower.includes('satisfaction')) {
return 'medium';
}
return 'low';
}
/**
* Assess implementation effort
*/
assessEffort(text) {
const lower = text.toLowerCase();
if (lower.includes('simple') ||
lower.includes('quick') ||
lower.includes('css change') ||
lower.includes('one line')) {
return 'low';
}
if (lower.includes('redesign') ||
lower.includes('refactor') ||
lower.includes('complex') ||
lower.includes('major change')) {
return 'high';
}
return 'medium';
}
/**
* Extract specific score from text
*/
extractSpecificScore(text, category) {
const regex = new RegExp(`${category}[:\\s]*([\\d]+)(?:\/10)?`, 'i');
const match = text.match(regex);
return match ? parseInt(match[1]) : undefined;
}
}
/**
* ๐ jpglens - OpenAI Provider
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* OpenAI provider for jpglens
* Direct integration with OpenAI's GPT-4 Vision and other models
*/
class OpenAIProvider {
config;
name = 'OpenAI';
baseUrl;
apiKey;
model;
constructor(config) {
this.config = config;
this.apiKey = config.ai.apiKey;
this.model = config.ai.model?.includes('/') ? config.ai.model.split('/')[1] : config.ai.model;
this.baseUrl = config.ai.baseUrl || 'https://api.openai.com/v1';
if (!this.apiKey) {
throw new Error('OpenAI API key is required');
}
}
async isAvailable() {
try {
const response = await fetch(`${this.baseUrl}/models`, {
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
});
return response.ok;
}
catch {
return false;
}
}
getModelInfo() {
return {
name: this.model,
capabilities: ['vision', 'text-analysis', 'code-generation'],
};
}
async analyze(screenshot, context, prompt) {
const startTime = Date.now();
try {
const base64Image = screenshot.buffer.toString('base64');
const requestBody = {
model: this.model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt,
},
{
type: 'image_url',
image_url: {
url: `data:image/png;base64,${base64Image}`,
detail: this.config.analysis.depth === 'comprehensive' ? 'high' : 'auto',
},
},
],
},
],
max_tokens: this.config.ai.maxTokens || 4000,
temperature: this.config.ai.temperature || 0.1,
};
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenAI API error: ${response.status} - ${errorText}`);
}
const result = await response.json();
const analysisText = result.choices[0].message.content;
const tokensUsed = result.usage?.total_tokens || 0;
return this.parseAnalysisResult(analysisText, context, tokensUsed, startTime);
}
catch (error) {
throw new Error(`OpenAI analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
parseAnalysisResult(analysisText, context, tokensUsed, startTime) {
// Similar parsing logic as OpenRouter provider
// This is a simplified version - in production you'd want shared parsing utilities
const scoreMatch = analysisText.match(/(?:OVERALL UX SCORE|QUALITY SCORE):\s*(\d+)\/10/i);
const overallScore = scoreMatch ? parseInt(scoreMatch[1]) : 5;
return {
id: `jpglens-openai-${Date.now()}`,
timestamp: new Date().toISOString(),
page: context.pageInfo?.url || 'unknown',
context,
overallScore,
scores: {
usability: overallScore,
accessibility: overallScore,
visualDesign: overallScore,
performance: overallScore,
},
strengths: [],
criticalIssues: [],
majorIssues: [],
minorIssues: [],
recommendations: [],
model: this.model,
tokensUsed,
analysisTime: Date.now() - startTime,
provider: 'OpenAI',
rawAnalysis: analysisText,
};
}
}
/**
* ๐ jpglens - Anthropic Provider
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Anthropic Claude provider for jpglens
* Direct integration with Claude's vision capabilities
*/
class AnthropicProvider {
config;
name = 'Anthropic';
baseUrl = 'https://api.anthropic.com/v1';
apiKey;
model;
constructor(config) {
this.config = config;
this.apiKey = config.ai.apiKey;
this.model = config.ai.model?.includes('/') ? config.ai.model.split('/')[1] : config.ai.model;
if (!this.apiKey) {
throw new Error('Anthropic API key is required');
}
}
async isAvailable() {
try {
const response = await fetch(`${this.baseUrl}/messages`, {
method: 'POST',
headers: {
'x-api-key': this.apiKey,
'content-type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: this.model,
max_tokens: 10,
messages: [{ role: 'user', content: 'test' }],
}),
});
return response.status !== 401; // Not unauthorized
}
catch {
return false;
}
}
getModelInfo() {
return {
name: this.model,
capabilities: ['vision', 'text-analysis', 'detailed-reasoning'],
};
}
async analyze(screenshot, context, prompt) {
const startTime = Date.now();
try {
const base64Image = screenshot.buffer.toString('base64');
const requestBody = {
model: this.model,
max_tokens: this.config.ai.maxTokens || 4000,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt,
},
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: base64Image,
},
},
],
},
],
};
const response = await fetch(`${this.baseUrl}/messages`, {
method: 'POST',
headers: {
'x-api-key': this.apiKey,
'content-type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Anthropic API error: ${response.status} - ${errorText}`);
}
const result = await response.json();
const analysisText = result.content[0].text;
const tokensUsed = result.usage?.input_tokens + result.usage?.output_tokens || 0;
return this.parseAnalysisResult(analysisText, context, tokensUsed, startTime);
}
catch (error) {
throw new Error(`Anthropic analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
parseAnalysisResult(analysisText, context, tokensUsed, startTime) {
const scoreMatch = analysisText.match(/(?:OVERALL UX SCORE|QUALITY SCORE):\s*(\d+)\/10/i);
const overallScore = scoreMatch ? parseInt(scoreMatch[1]) : 5;
return {
id: `jpglens-anthropic-${Date.now()}`,
timestamp: new Date().toISOString(),
page: context.pageInfo?.url || 'unknown',
context,
overallScore,
scores: {
usability: overallScore,
accessibility: overallScore,
visualDesign: overallScore,
performance: overallScore,
},
strengths: [],
criticalIssues: [],
majorIssues: [],
minorIssues: [],
recommendations: [],
model: this.model,
tokensUsed,
analysisTime: Date.now() - startTime,
provider: 'Anthropic',
rawAnalysis: analysisText,
};
}
}
/**
* ๐ jpglens - Report Generator
* Configurable AI Analysis Report Generation System
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Default report templates for different output formats
*/
const DEFAULT_REPORT_TEMPLATES = {
detailed: {
name: 'Detailed Analysis Report',
format: 'markdown',
sections: [
'executive_summary',
'overall_score',
'visual_hierarchy',
'accessibility',
'usability',
'critical_issues',
'recommendations',
'technical_details',
],
prompts: {
executive_summary: 'Provide a comprehensive executive summary of the UI analysis',
overall_score: 'Rate the overall UI quality from 1-10 with detailed reasoning',
visual_hierarchy: 'Analyze the visual hierarchy and information architecture',
accessibility: 'Evaluate accessibility compliance and provide specific recommendations',
usability: 'Assess usability patterns and user experience quality',
critical_issues: 'Identify critical issues that must be addressed immediately',
recommendations: 'Provide actionable recommendations for improvement',
technical_details: 'Include technical implementation details and metrics',
},
},
summary: {
name: 'Quick Summary Report',
format: 'json',
sections: ['overall_score', 'top_issues', 'quick_wins'],
prompts: {
overall_score: 'Provide an overall quality score from 1-10',
top_issues: 'List the top 3 most critical issues',
quick_wins: 'Suggest 3 quick improvements that can be implemented immediately',
},
},
executive: {
name: 'Executive Dashboard Report',
format: 'json',
sections: ['executive_summary', 'key_metrics', 'business_impact', 'next_actions'],
prompts: {
executive_summary: 'Provide a business-focused summary suitable for executives',
key_metrics: 'Present key performance indicators and quality metrics',
business_impact: 'Explain the business impact of identified issues and improvements',
next_actions: 'Recommend prioritized actions with timeline and resource estimates',
},
},
};
/**
* Default report configuration
*/
const DEFAULT_REPORT_CONFIG = {
enabled: true,
outputDir: './jpglens-reports',
template: 'detailed',
format: 'markdown',
includeScreenshots: true,
includeRawAnalysis: false,
timestampFormat: 'ISO',
fileNaming: '{timestamp}-{component}-{page}',
customPrompts: {},
apiCompatibility: 'auto', // auto-detect based on provider
};
/**
* Report Generator Class
*/
class ReportGenerator {
config;
templates;
constructor(config = {}) {
this.config = { ...DEFAULT_REPORT_CONFIG, ...config };
this.templates = { ...DEFAULT_REPORT_TEMPLATES };
// Ensure output directory exists
this.ensureOutputDirectory();
}
/**
* Generate a report from analysis results
*/
async generateReport(analysisResult, customConfig) {
if (!this.config.enabled) {
return '';
}
const reportConfig = { ...this.config, ...customConfig };
const template = this.getTemplate(reportConfig.template);
// Generate report content based on format
const reportContent = await this.generateReportContent(analysisResult, template, reportConfig);
// Save report to file
const filePath = await this.saveReport(reportContent, analysisResult, reportConfig);
return filePath;
}
/**
* Generate report content based on template and format
*/
async generateReportContent(result, template, config) {
switch (template.format) {
case 'markdown':
return this.generateMarkdownReport(result, template, config);
case 'json':
return this.generateJsonReport(result, template, config);
case 'html':
return this.generateHtmlReport(result, template, config);
default:
throw new Error(`Unsupported report format: ${template.format}`);
}
}
/**
* Generate markdown report
*/
generateMarkdownReport(result, template, config) {
let markdown = `# ${template.name}\n\n`;
markdown += `**Generated:** ${this.formatTimestamp(result.timestamp, config.timestampFormat)}\n`;
markdown += `**Component:** ${result.component || 'N/A'}\n`;
markdown += `**Page:** ${result.page}\n`;
markdown += `**Model:** ${result.model}\n`;
markdown += `**Analysis Time:** ${result.analysisTime}ms\n\n`;
// Add sections based on template
for (const section of template.sections) {
markdown += this.generateMarkdownSection(section, result, template, config);
}
// Add technical details if requested
if (config.includeRawAnalysis && result.rawAnalysis) {
markdown += `## Raw Analysis\n\n\`\`\`\n${result.rawAnalysis}\n\`\`\`\n\n`;
}
return markdown;
}
/**
* Generate JSON report
*/
generateJsonReport(result, template, config) {
const jsonReport = {
metadata: {
generated: this.formatTimestamp(result.timestamp, config.timestampFormat),
template: template.name,
component: result.component,
page: result.page,
model: result.model,
analysisTime: result.analysisTime,
tokensUsed: result.tokensUsed,
},
analysis: {},
};
// Add sections based on template
for (const section of template.sections) {
jsonReport.analysis[section] = this.extractSectionData(section, result);
}
if (config.includeRawAnalysis) {
jsonReport.rawAnalysis = result.rawAnalysis;
}
return JSON.stringify(jsonReport, null, 2);
}
/**
* Generate HTML report
*/
generateHtmlReport(result, template, config) {
let html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${template.name}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; }
.header { border-bottom: 2px solid #eee; padding-bottom: 1rem; margin-bottom: 2rem; }
.section { margin: 2rem 0; }
.score { font-size: 2rem; font-weight: bold; color: #007acc; }
.issue { background: #fee; padding: 1rem; border-left: 4px solid #e74c3c; margin: 1rem 0; }
.recommendation { background: #efe; padding: 1rem; border-left: 4px solid #27ae60; margin: 1rem 0; }
</style>
</head>
<body>
<div class="header">
<h1>${template.name}</h1>
<p><strong>Generated:</strong> ${this.formatTimestamp(result.timestamp, config.timestampFormat)}</p>
<p><strong>Component:</strong> ${result.component || 'N/A'}</p>
<p><strong>Page:</strong> ${result.page}</p>
</div>`;
// Add sections
for (const section of template.sections) {
html += this.generateHtmlSection(section, result, template, config);
}
html += `</body></html>`;
return html;
}
/**
* Generate markdown section
*/
generateMarkdownSection(section, result, template, config) {
let content = `## ${this.formatSectionTitle(section)}\n\n`;
switch (section) {
case 'executive_summary':
content += `${this.extractExecutiveSummary(result)}\n\n`;
break;
case 'overall_score':
content += `**Score:** ${result.overallScore}/10\n\n`;
break;
case 'visual_hierarchy':
content += `**Visual Design Score:** ${result.scores.visualDesign || 'N/A'}/10\n\n`;
break;
case 'accessibility':
content += `**Accessibility Score:** ${result.scores.accessibility || 'N/A'}/10\n\n`;
break;
case 'usability':
content += `**Usability Score:** ${result.scores.usability || 'N/A'}/10\n\n`;
break;
case 'critical_issues':
content += this.formatIssues(result.criticalIssues, 'Critical');
break;
case 'recommendations':
content += this.formatRecommendations(result.recommendations);
break;
case 'technical_details':
content += this.formatTechnicalDetails(result);
break;
default:
content += `Data for ${section} not available.\n\n`;
}
return content;
}
/**
* Generate HTML section
*/
generateHtmlSection(section, result, template, config) {
let content = `<div class="section"><h2>${this.formatSectionTitle(section)}</h2>`;
switch (section) {
case 'overall_score':
content += `<div class="score">${result.overallScore}/10</div>`;
break;
case 'critical_issues':
result.criticalIssues.forEach(issue => {
content += `<div class="issue"><strong>${issue.title}</strong><br>${issue.description}</div>`;
});
break;
case 'recommendations':
result.recommendations.forEach(rec => {
content += `<div class="recommendation"><strong>${rec.title}</strong><br>${rec.description}</div>`;
});
break;
default:
content += `<p>Data for ${section} not available.</p>`;
}
content += `</div>`;
return content;
}
/**
* Extract section data for JSON format
*/
extractSectionData(section, result) {
switch (section) {
case 'overall_score':
return result.overallScore;
case 'top_issues':
return result.criticalIssues.slice(0, 3).map(issue => ({
title: issue.title,
severity: issue.severity,
impact: issue.impact,
}));
case 'quick_wins':
return result.recommendations.slice(0, 3).map(rec => ({
title: rec.title,
effort: rec.effort,
impact: rec.impact,
}));
default:
return null;
}
}
/**
* Save report to file
*/
async saveReport(content, result, config) {
const fileName = this.generateFileName(result, config);
const filePath = path.join(config.outputDir, fileName);
await fs.promises.writeFile(filePath, content, 'utf-8');
return filePath;
}
/**
* Generate file name based on configuration
*/
generateFileName(result, config) {
const template = config.fileNaming;
const timestamp = this.formatTimestamp(result.timestamp, 'filename');
const extension = this.getFileExtension(config.format);
return (template
.replace('{timestamp}', timestamp)
.replace('{component}', result.component || 'unknown')
.replace('{page}', result.page || 'unknown')
.replace('{id}', result.id) + extension);
}
/**
* Get file extension for format
*/
getFileExtension(format) {
switch (format) {
case 'markdown':
return '.md';
case 'json':
return '.json';
case 'html':
return '.html';
default:
return '.txt';
}
}
/**
* Format timestamp based on configuration
*/
formatTimestamp(timestamp, format) {
const date = new Date(timestamp);
switch (format) {
case 'ISO':
return date.toISOString();
case 'filename':
return date.toISOString().replace(/[:.]/g, '-').slice(0, 19);
case 'readable':
return date.toLocaleString();
default:
return timestamp;
}
}
/**
* Format section title
*/
formatSectionTitle(section) {
return section
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Extract executive summary from result
*/
extractExecutiveSummary(result) {
// Try to extract from raw analysis or generate from available data
if (result.rawAnalysis && result.rawAnalysis.includes('EXECUTIVE SUMMARY')) {
const match = result.rawAnalysis.match(/EXECUTIVE SUMMARY[:\n]+(.*?)(?=\n\n|\n[A-Z]|$)/s);
if (match)
return match[1].trim();
}
// Generate summary from available data
return (`UI analysis completed with an overall score of ${result.overallScore}/10. ` +
`${result.criticalIssues.length} critical issues identified. ` +
`${result.recommendations.length} recommendations provided for improvement.`);
}
/**
* Format issues for display
*/
formatIssues(issues, severity) {
if (!issues.length)
return `No ${severity.toLowerCase()} issues found.\n\n`;
let content = '';
issues.forEach((issue, index) => {
content += `### ${index + 1}. ${issue.title}\n`;
content += `**Severity:** ${issue.severity}\n`;
content += `**Description:** ${issue.description}\n`;
if (issue.fix)
content += `**Fix:** ${issue.fix}\n`;
content += '\n';
});
return content;
}
/**
* Format recommendations for display
*/
formatRecommendations(recommendations) {
if (!recommendations.length)
return `No recommendations available.\n\n`;
let content = '';
recommendations.forEach((rec, index) => {
content += `### ${index + 1}. ${rec.title}\n`;
content += `**Impact:** ${rec.impact}\n`;
content += `**Effort:** ${rec.effort}\n`;
content += `**Description:** ${rec.description}\n\n`;
});
return content;
}
/**
* Format technical details
*/
formatTechnicalDetails(result) {
return (`**Analysis ID:** ${result.id}\n` +
`**Model Used:** ${result.model}\n` +
`**Tokens Used:** ${result.tokensUsed}\n` +
`**Analysis Time:** ${result.analysisTime}ms\n` +
`**Provider:** ${result.provider || 'Unknown'}\n\n`);
}
/**
* Get template by name
*/
getTemplate(templateName) {
const template = this.templates[templateName];
if (!template) {
throw new Error(`Template '${templateName}' not found`);
}
return template;
}
/**
* Ensure output directory exists
*/
ensureOutputDirectory() {
try {
if (!fs.existsSync(this.config.outputDir)) {
fs.mkdirSync(this.config.outputDir, { recursive: true });
}
}
catch (error) {
console.warn(`Failed to create report output directory: ${error}`);
}
}
/**
* Add custom template
*/
addTemplate(name, template) {
this.templates[name] = template;
}
/**
* Update configuration
*/
updateConfig(config) {
this.config = { ...this.config, ...config };
this.ensureOutputDirectory();
}
/**
* Get current configuration
*/
getConfig() {
return { ...this.config };
}
}
/**
* ๐ jpglens - API Compatibility Layer
* Handles differences between OpenAI and Anthropic API formats
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* API Compatibility Handler
*/
class APICompatibilityHandler {
config;
constructor(config) {
this.config = config;
}
/**
* Detect API format based on provider and model
*/
detectAPIFormat() {
if (this.config.messageFormat && this.config.messageFormat !== 'auto') {
return this.config.messageFormat;
}
// Auto-detect based on provider and model
if (this.config.provider === 'anthropic') {
return 'anthropic';
}
if (this.config.provider === 'openai') {
return 'openai';
}
// For OpenRouter, detect based on model name
if (this.config.provider === 'openrouter') {
if (this.config.model?.includes('anthropic/') || this.config.model?.includes('claude')) {
return 'anthropic';
}
if (this.config.model?.includes('openai/') || this.config.model?.includes('gpt')) {
return 'openai';
}
}
// Default to OpenAI format for compatibility
return 'openai';
}
/**
* Convert prompt and image to appropriate API format
*/
formatRequest(prompt, imageBase64, systemPrompt) {
const apiFormat = this.detectAPIFormat();
if (apiFormat === 'anthropic') {
return this.formatAnthropicRequest(prompt, imageBase64, systemPrompt);
}
else {
return this.formatOpenAIRequest(prompt, imageBase64, systemPrompt);
}
}
/**
* Format request for OpenAI API
*/
formatOpenAIRequest(prompt, imageBase64, systemPrompt) {
const messages = [];
// Add system message if provided
if (systemPrompt) {
messages.push({
role: 'system',
content: systemPrompt,
});
}
// Format user message with text and optional image
const userContent = [{ type: 'text', text: prompt }];
if (imageBase64) {
userContent.push({
type: 'image_url',
image_url: {
url: `data:image/png;base64,${imageBase64}`,
},
});
}
messages.push({
role: 'user',
content: userContent,
});
return {
model: this.config.model,
messages,
max_tokens: this.config.maxTokens || 2000,
temperature: this.config.temperature || 0.1,
};
}
/**
* Format request for Anthropic API
*/
formatAnthropicRequest(prompt, imageBase64, systemPrompt) {
const messages = [];
// Format user message with text and optional image
const userContent = [{ type: 'text', text: prompt }];
if (imageBase64) {
userContent.push({
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: imageBase64,
},
});
}
messages.push({
role: 'user',
content: userContent,
});
const request = {
model: this.config.model,
max_tokens: this.config.maxTokens || 2000,
messages,
temperature: this.config.temperature || 0.1,
};
// Add system prompt if provided
if (systemPrompt) {
request.system = systemPrompt;
}
return request;
}
/**
* Parse response from either API format
*/
parseResponse(response) {
const apiFormat = this.detectAPIFormat();
if (apiFormat === 'anthropic') {
return this.parseAnthropicResponse(response);
}
else {
return this.parseOpenAIResponse(response);
}
}
/**
* Parse OpenAI API response
*/
parseOpenAIResponse(response) {
return {
content: response.choices[0]?.message?.content || '',
tokensUsed: response.usage?.total_tokens || 0,
model: response.model,
};
}
/**
* Parse Anthropic API response
*/
parseAnthropicResponse(response) {
const content = response.content
?.filter(item => item.type === 'text')
?.map(item => item.text)
?.join('') || '';
const tokensUsed = (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0);
return {
content,
tokensUsed,
model: response.model,
};
}
/**
* Get appropriate headers for the API
*/
getHeaders() {
const apiFormat = this.detectAPIFormat();
const baseHeaders = {
'Content-Type': 'application/json',
};
if (apiFormat === 'anthropic') {
return {
...baseHeaders,
Authorization: `Bearer ${this.config.apiKey}`,
'anthropic-version': '2023-06-01',
};
}
else {
return {
...baseHeaders,
Authorization: `Bearer ${this.config.apiKey}`,
};
}
}
/**
* Get appropriate API endpoint
*/
getEndpoint() {
if (this.config.baseUrl) {
const apiFormat = this.detectAPIFormat();
if (apiFormat === 'anthropic') {
return `${this.config.baseUrl}/v1/messages`;
}
else {
return `${this.config.baseUrl}/v1/chat/completions`;
}
}
// Default endpoints
if (this.config.provider === 'anthropic') {
return 'https://api.anthropic.com/v1/messages';
}
else if (this.config.provider === 'openai') {
return 'https://api.openai.com/v1/chat/completions';
}
else if (this.config.provider === 'openrouter') {
return 'https://openrouter.ai/api/v1/chat/completions';
}
throw new Error(`Unknown provider: ${this.config.provider}`);
}
/**
* Validate configuration for the detected API format
*/
validateConfig() {
const apiFormat = this.detectAPIFormat();
if (!this.config.apiKey) {
throw new Error('API key is required');
}
if (!this.config.model) {
throw new Error('Model is required');
}
if (apiFormat === 'anthropic') {
if (!this.config.maxTokens) {
throw new Error('max_tokens is required for Anthropic API');
}
}
}
/**
* Get configuration summary
*/
getConfigSummary() {
return {
provider: this.config.provider,
model: this.config.model,
apiFormat: this.detectAPIFormat(),
endpoint: this.getEndpoint(),
};
}
}
/**
* ๐ jpglens - Console Output Formatter
* Beautiful console display for AI analysis results when reports are disabled
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Console formatting utilities
*/
class ConsoleFormatter {
static COLORS = {
// Text colors
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
// Foreground colors
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
// Background colors
bgRed: '\x1b[41m',
bgGreen: '\x1b[42m',
bgYellow: '\x1b[43m',
bgBlue: '\x1b[44m',
bgMagenta: '\x1b[45m',
bgCyan: '\x1b[46m',
};
static ICONS = {
success: 'โ
',
warning: 'โ ๏ธ',
error: 'โ',
info: 'โน๏ธ',
score: '๐',
issue: '๐',
recommendation: '๐ก',
strength: '๐ฏ',
time: 'โฑ๏ธ',
model: '๐ค',
tokens: '๐ข',
component: '๐งฉ',
page: '๐',
user: '๐ค',
device: '๐ฑ',
critical: '๐จ',
major: 'โก',
minor: '๐',
fix: '๐ง',
impact: '๐',
effort: '๐ช',
accessibility: 'โฟ',
usability: '๐จ',
performance: 'โก',
visual: '๐๏ธ',
mobile: '๐ฑ',
desktop: '๐ป',
star: 'โญ',
arrow: 'โค',
bullet: 'โข',
separator: 'โ',
};
/**
* Format complete analysis result for console display
*/
static formatAnalysisResult(result, options = {}) {
const { showRawAnalysis = false, showTechnicalDetails = true, compact = false } = options;
// Clear console and show header
this.showHeader(result);
if (!compact) {
this.showMetadata(result);
this.showScores(result);
}
this.showStrengths(result);
this.showIssues(result);
this.showRecommendations(result);
if (showTechnicalDetails && !compact) {
this.showTechnicalDetails(result);
}
if (showRawAnalysis && result.rawAnalysis) {
this.showRawAnalysis(result.rawAnalysis);
}
this.showFooter(result);
}
/**
* Show analysis header
*/
static showHeader(result) {
const title = `${this.ICONS.success} jpglens AI Analysis Complete`;
const line = 'โ'.repeat(60);
console.log(`\n${this.color(line, 'cyan')}`);
console.log(`${this.color(title, 'bright')}${this.color(' ' + this.ICONS.component, 'cyan')}`);
console.log(`${this.color(line, 'cyan')}\n`);
}
/**
* Show metadata information
*/
static showMetadata(result) {
const metadata = [
`${this.ICONS.component} Component: ${this.color(result.component || 'Unknown', 'bright')}`,
`${this.ICONS.page} Page: ${this.color(result.page || 'Unknown', 'bright')}`,
`${this.ICONS.model} Model: ${this.color(result.model, 'cyan')}`,
`${this.ICONS.time} Analysis Time: ${this.color(result.analysisTime + 'ms', 'yellow')}`,
];
if (result.context?.userContext) {
const ctx = result.context.userContext;
if (ctx.deviceContext) {
const deviceIcon = ctx.deviceContext === 'mobile' ? this.ICONS.mobile : this.ICONS.desktop;
metadata.push(`${deviceIcon} Device: ${this.color(ctx.deviceContext, 'magenta')}`);
}
if (ctx.persona) {
const personaName = typeof ctx.persona === 'string' ? ctx.persona : ctx.persona.name || 'Unknown';
metadata.push(`${this.ICONS.user} Persona: ${this.color(personaName, 'magenta')}`);
}
}
metadata.forEach(item => console.log(` ${item}`));
console.log();
}
/**
* Show scores section
*/
static showScores(result) {
console.log(`${this.color('๐ ANALYSIS SCORES', 'bright')}`);
console.log(`${this.ICONS.separator.repeat(30)}`);
// Overall score with visual bar
const overallScore = result.overallScore || 0;
const scoreBar = this.createScoreBar(overallScore);
const scoreColor = this.getScoreColor(overallScore);
console.log(`${this.ICONS.star} Overall Score: ${this.color(overallScore.toFixed(1) + '/10', scoreColor)} ${scoreBar}`);
// Individual scores
if (result.scores && Object.keys(result.scores).length > 0) {
console.log();
Object.entries(result.scores).forEach(([category, score]) => {
const icon = this.getCategoryIcon(category);
const bar = this.createScoreBar(score);
const color = this.getScoreColor(score);
const categoryName = this.formatCategoryName(category);
console.log(` ${icon} ${categoryName}: ${this.color(score.toFixed(1) + '/10', color)} ${bar}`);
});
}
console.log();
}
/**
* Show strengths section
*/
static showStrengths(result) {
if (!result.strengths || result.strengths.length === 0)
return;
console.log(`${this.color('๐ฏ STRENGTHS', 'green')}`);
console.log(`${this.ICONS.separator.repeat(30)}`);
result.strengths.forEach((strength, index) => {
console.log(` ${this.color(index + 1 + '.', 'dim')} ${this.ICONS.success} ${strength}`);
});
console.log();
}
/**
* Show issues section
*/
static showIssues(result) {
const allIssues = [
...(result.criticalIssues || []).map(issue => ({ ...issue, type: 'critical' })),
...(result.majorIssues || []).map(issue => ({ ...issue, type: 'major' })),
...(result.minorIssues || []).map(issue => ({ ...issue, type: 'minor' })),
];
if (allIssues.length === 0) {
console.log(`${this.color('๐ NO ISSUES FOUND', 'green')}`);
console.log(`${this.ICONS.separator.repeat(30)}`);
console.log(` ${this.ICONS.success} Great job! No critical issues detected.\n`);
return;
}
console.log(`${this.color('๐ ISSUES FOUND', 'yellow')}`);
console.log(`${this.ICONS.separator.repeat(30)}`);
// Group by severity
const critical = allIssues.filter(i => i.type === 'critical');
const major = allIssues.filter(i => i.type === 'major');
const minor = allIssues.filter(i => i.type === 'minor');
if (critical.length > 0) {
console.log(`\n ${this.color('๐จ CRITICAL ISSUES', 'red')}`);
critical.forEach((issue, index) => {
this.formatIssue(issue, index + 1, 'critical');
});
}
if (major.length > 0) {
console.log(`\n ${this.color('โก MAJOR ISSUES', 'yellow')}`);
major.forEach((issue, index) => {
this.formatIssue(issue, index + 1, 'major');
});
}
if (minor.length > 0) {
console.log(`\n ${this.color('๐ MINOR ISSUES', 'cyan')}`);
minor.forEach((issue, index) => {
this.formatIssue(issue, index + 1, 'minor');
});
}
console.log();
}
/**
* Format individual issue
*/
static formatIssue(issue, index, type) {
const icons = {
critical: this.ICONS.critical,
major: this.ICONS.major,
minor: this.ICONS.minor,
};
const colors = {
critical: 'red',
major: 'yellow',
minor: 'cyan',
};
console.log(` ${this.color(index + '.', 'dim')} ${icons[type]} ${this.color(issue.title, colors[type])}`);
if (issue.description) {
console.log(` ${this.color('Description:', 'dim')} ${issue.description}`);
}
if (issue.impact) {
console.log(` ${this.ICONS.impact} ${this.color('Impact:', 'dim')} ${issue.impact}`);
}
if (issue.fix) {
console.log(` ${this.ICONS.fix} ${this.color('Fix:', 'green')} ${issue.fix}`);
}
}
/**
* Show recommendations section
*/
static showRecommendations(result) {
if (!result.recommendations || result.recommendations.length === 0)
return;
console.log(`${this.color('๐ก RECOMMENDATIONS', 'blue')}`);
console.log(`${this.ICONS.separator.repeat(30)}`);
result.recommendations.forEach((rec, index) => {
console.log(` ${this.color(index + 1 + '.', 'dim')} ${this.ICONS.recommendation} ${this.color(rec.title, 'bright')}`);
if (rec.description) {
console.log(` ${rec.description}`);
}
const details = [];
if (rec.impact)
details.push(`${this.ICONS.impact} Impact: ${this.color(rec.impact, 'green')}`);
if (rec.effort)
details.push(`${this.ICONS.effort} Effort: ${this.color(rec.effort, 'yellow')}`);
if (details.length > 0) {
console.log(` ${details.join(' | ')}`);
}
if (index < result.recommendations.length - 1)
console.log();
});
console.log();
}
/**
* Show technical details
*/
static showTechnicalDetails(result) {
console.log(`${this.color('๐ง TECHNICAL DETAILS', 'dim')}`);
console.log(`${this.ICONS.separator.repeat(30)}`);
const details = [
`${this.ICONS.info} Analysis ID: ${result.id}`,
`${this.ICONS.tokens} Tokens Used: ${result.tokensUsed || 0}`,
`${this.ICONS.time} Processing Time: ${result.analysisTime}ms`,
];
if (result.provider) {
details.push(`${this.ICONS.model} Provider: ${result.provider}`);
}
details.forEach(detail => console.log(` ${this.color(detail, 'dim')}`));
console.log();
}
/**
* Show raw analysis if requested
*/
static showRawAnalysis(rawAnalysis) {
console.log(`${this.color('๐ RAW AI ANALYSIS', 'dim')}`);
console.log(`${this.ICONS.separator.repeat(30)}`);
console.log(`${this.color(rawAnalysis, 'dim')}\n`);
}
/**
* Show footer
*/
static showFooter(result) {
const line = 'โ'.repeat(60);
const timestamp = new Date(result.timestamp).toLocaleString();
console.log(`${this.color(line, 'cyan')}`);
console.log(`${this.color('Analysis completed at ' + timestamp, 'dim')}`);
console.log(`${this.color('Powered by jpglens AI ๐', 'cyan')}`);
console.log(`${this.color(line, 'cyan')}\n`);
}
/**
* Create visual score bar
*/
static createScoreBar(score, width = 10) {
const filled = Math.round((score / 10) * width);
const empty = width - filled;
const filledBar = 'โ'.repeat(filled);
const emptyBar = 'โ'.repeat(empty);
return `[${this.color(filledBar, this.getScoreColor(score))}${this.color(emptyBar, 'dim')}]`;
}
/**
* Get color for score
*/
static getScoreColor(score) {
if (score >= 8)
return 'green';
if (score >= 6)
return 'yellow';
if (score >= 4)
return 'yellow';
return 'red';
}
/**
* Get icon for category
*/
static getCategoryIcon(category) {
const iconMap = {
accessibility: this.ICONS.accessibility,
usability: this.ICONS.usability,
performance: this.ICONS.performance,
visualDesign: this.ICONS.visual,
'visual-design': this.ICONS.visual,
mobile: this.ICONS.mobile,
desktop: this.ICONS.desktop,
};
return iconMap[category] || this.ICONS.info;
}
/**
* Format category name
*/
static formatCategoryName(category) {
return category
.split(/[-_]/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Apply color to text
*/
static color(text, color) {
return `${this.COLORS[color]}${text}${this.COLORS.reset}`;
}
/**
* Show compact summary (for quick results)
*/
static showCompactSummary(result) {
const score = result.overallScore || 0;
const scoreColor = this.getScoreColor(score);
const scoreBar = this.createScoreBar(score, 5);
const criticalCount = result.criticalIssues?.length || 0;
const majorCount = result.majorIssues?.length || 0;
const recCount = result.recommendations?.length || 0;
console.log(`\n${this.ICONS.success} ${this.color('jpglens Analysis:', 'bright')} ${this.color(score.toFixed(1) + '/10', scoreColor)} ${scoreBar}`);
console.log(`${this.ICONS.issue} Issues: ${this.color(criticalCount + ' critical', criticalCount > 0 ? 'red' : 'green')}, ${this.color(majorCount + ' major', majorCount > 0 ? 'yellow' : 'green')}`);
console.log(`${this.ICONS.recommendation} Recommendations: ${this.color(recCount.toString(), 'blue')}\n`);
}
/**
* Show error message with formatting
*/
static showError(error, details) {
console.log(`\n${this.color('โ jpglens Analysis Failed', 'red')}`);
console.log(`${this.ICONS.separator.repeat(40)}`);
console.log(`${this.ICONS.error} ${error}`);
if (details) {
console.log(`${this.ICONS.info} ${this.color('Details:', 'dim')} ${details}`);
}
console.log();
}
/**
* Show loading/progress indicator
*/
static showProgress(message) {
console.log(`${this.ICONS.info} ${this.color(message, 'cyan')}`);
}
}
/**
* ๐ jpglens - AI Analyzer Core
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Core AI analysis engine for jpglens
* Orchestrates AI providers and manages analysis workflow
*/
class AIAnalyzer {
config;
providers = new Map();
primaryProvider;
fallbackProvider;
reportGenerator;
apiHandler;
constructor(config) {
this.config = config;
this.initializeProviders();
this.primaryProvider = this.getProvider(config.ai.provider);
if (config.ai.fallbackModel) {
this.fallbackProvider = this.getProvider(this.extractProviderFromModel(config.ai.fallbackModel));
}
// Initialize reporting system
const reportConfig = { ...DEFAULT_REPORT_CONFIG, ...(config.reporting || {}) };
this.reportGenerator = new ReportGenerator(reportConfig);
// Initialize API compatibility handler
this.apiHandler = new APICompatibilityHandler({
provider: config.ai.provider,
model: config.ai.model,
apiKey: config.ai.apiKey,
baseUrl: config.ai.baseUrl,
maxTokens: config.ai.maxTokens,
temperature: config.ai.temperature,
messageFormat: config.ai.messageFormat || 'auto',
});
}
/**
* Initialize available AI providers
*/
initializeProviders() {
this.providers.set('openrouter', new OpenRouterProvider(this.config));
this.providers.set('openai', new OpenAIProvider(this.config));
this.providers.set('anthropic', new AnthropicProvider(this.config));
}
/**
* Get provider by name
*/
getProvider(providerName) {
const provider = this.providers.get(providerName);
if (!provider) {
throw new Error(`AI provider '${providerName}' not found. Available: ${Array.from(this.providers.keys()).join(', ')}`);
}
return provider;
}
/**
* Extract provider name from model string (e.g., "openai/gpt-4" -> "openai")
*/
extractProviderFromModel(model) {
if (model.includes('/')) {
const [provider] = model.split('/');
return provider;
}
return this.config.ai.provider; // Default to primary provider
}
/**
* Main analysis method - the heart of jpglens
*/
async analyze(screenshot, context) {
const startTime = Date.now();
try {
// Validate inputs
this.validateInputs(screenshot, context);
// Generate appropriate prompt based on context
const prompt = this.generatePrompt(context);
// Log analysis start (if debugging)
if (process.env.JPGLENS_DEBUG) {
console.log(`๐ Starting jpglens analysis:`, {
stage: context.stage,
userIntent: context.userIntent,
provider: this.config.ai.provider,
model: this.config.ai.model,
});
}
// Attempt analysis with primary provider
let result;
try {
result = await this.primaryProvider.analyze(screenshot, context, prompt);
}
catch (primaryError) {
console.warn(`Primary AI provider failed, trying fallback:`, primaryError);
if (this.fallbackProvider) {
result = await this.fallbackProvider.analyze(screenshot, context, prompt);
}
else {
throw primaryError;
}
}
// Post-process and enhance the result
const enhancedResult = await this.enhanceResult(result, context, startTime);
// Log successful analysis
if (process.env.JPGLENS_DEBUG) {
console.log(`โ
jpglens analysis completed:`, {
score: enhancedResult.overallScore,
issues: enhancedResult.criticalIssues.length + enhancedResult.majorIssues.length,
analysisTime: enhancedResult.analysisTime,
});
}
return enhancedResult;
}
catch (error) {
const analysisTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
ConsoleFormatter.showError('jpglens analysis failed: ' + errorMessage, 'Check your configuration and try again');
// Return error result instead of throwing
return this.createErrorResult(error, context, analysisTime);
}
}
/**
* Analyze multiple screenshots in parallel (for journey analysis)
*/
async analyzeMultiple(screenshots, contexts) {
if (screenshots.length !== contexts.length) {
throw new Error('Screenshots and contexts arrays must have the same length');
}
// Process in parallel with concurrency limit
const concurrencyLimit = 3; // Avoid overwhelming AI providers
const results = [];
for (let i = 0; i < screenshots.length; i += concurrencyLimit) {
const batch = screenshots.slice(i, i + concurrencyLimit);
const batchContexts = contexts.slice(i, i + concurrencyLimit);
const batchPromises = batch.map((screenshot, index) => this.analyze(screenshot, batchContexts[index]));
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
}
return results;
}
/**
* Generate appropriate prompt based on context
*/
generatePrompt(context) {
const analysisTypes = this.config.analysis.types;
// Use specialized prompts for specific scenarios
if (context.businessContext?.industry === 'e-commerce') {
return SpecializedPrompts.ecommerce(context);
}
if (context.businessContext?.industry === 'saas') {
return SpecializedPrompts.saas(context);
}
if (context.technicalContext?.designSystem) {
return SpecializedPrompts.designSystem(context);
}
if (context.userContext?.deviceContext?.includes('mobile')) {
return SpecializedPrompts.mobileApp(context);
}
// Default to master prompt
return createMasterPrompt(context, analysisTypes);
}
/**
* Validate analysis inputs
*/
validateInputs(screenshot, context) {
if (!screenshot?.buffer || screenshot.buffer.length === 0) {
throw new Error('Invalid screenshot data provided');
}
if (!context.stage || !context.userIntent) {
throw new Error('Analysis context must include stage and userIntent');
}
if (!context.userContext) {
throw new Error('User context is required for meaningful analysis');
}
}
/**
* Enhance AI result with additional processing
*/
async enhanceResult(result, context, startTime) {
const analysisTime = Date.now() - startTime;
// Parse and structure the AI response if needed
const structuredResult = this.parseAIResponse(result, context);
// Add metadata
structuredResult.analysisTime = analysisTime;
structuredResult.config = {
provider: this.config.ai.provider,
model: this.config.ai.model,
analysisTypes: this.config.analysis.types,
depth: this.config.analysis.depth,
};
// Validate and score the result
this.validateResult(structuredResult);
// Generate report if enabled, otherwise show console output
const reportingConfig = this.reportGenerator.getConfig();
if (reportingConfig.enabled) {
try {
const reportPath = await this.reportGenerator.generateReport(structuredResult);
if (reportPath) {
structuredResult.reportPath = reportPath;
ConsoleFormatter.showProgress(`๐ Report saved: ${reportPath}`);
}
}
catch (error) {
console.warn('Failed to generate report:', error);
// Fallback to console output
ConsoleFormatter.formatAnalysisResult(structuredResult, {
showTechnicalDetails: true,
compact: false,
});
}
}
else {
// Show beautiful console output when reports are disabled
ConsoleFormatter.formatAnalysisResult(structuredResult, {
showTechnicalDetails: true,
showRawAnalysis: false,
compact: false,
});
}
return structuredResult;
}
/**
* Parse AI response and structure it properly
*/
parseAIResponse(result, context) {
// If the result is already structured, return as-is
if (result.overallScore !== undefined && result.criticalIssues) {
return result;
}
// Parse raw AI text response (fallback for simpler providers)
if (typeof result === 'string' || result.rawResponse) {
const rawText = typeof result === 'string' ? result : result.rawResponse;
return this.parseTextResponse(rawText, context);
}
return result;
}
/**
* Parse raw text response from AI
*/
parseTextResponse(rawText, context) {
// Extract overall score
const scoreMatch = rawText.match(/(?:OVERALL UX SCORE|QUALITY SCORE):\s*(\d+)\/10/i);
const overallScore = scoreMatch ? parseInt(scoreMatch[1]) : 5;
// Extract issues by section
const criticalIssues = this.extractIssues(rawText, 'CRITICAL ISSUES', 'critical');
const majorIssues = this.extractIssues(rawText, 'MAJOR ISSUES', 'major');
const minorIssues = this.extractIssues(rawText, 'MINOR ISSUES', 'minor');
// Extract strengths
const strengths = this.extractListItems(rawText, 'STRENGTHS');
// Extract recommendations
const recommendations = this.extractRecommendations(rawText);
return {
id: `jpglens-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
page: context.pageInfo?.url || context.stage || 'unknown',
context,
overallScore,
scores: {
usability: this.extractSpecificScore(rawText, 'usability') || overallScore,
accessibility: this.extractSpecificScore(rawText, 'accessibility') || overallScore,
visualDesign: this.extractSpecificScore(rawText, 'visual') || overallScore,
performance: this.extractSpecificScore(rawText, 'performance') || overallScore,
},
strengths,
criticalIssues,
majorIssues,
minorIssues,
recommendations,
model: this.config.ai.model,
tokensUsed: 0, // Will be filled by provider
analysisTime: 0, // Will be filled by enhanceResult
};
}
/**
* Extract issues from text by section
*/
extractIssues(text, sectionName, severity) {
const issues = [];
const items = this.extractListItems(text, sectionName);
items.forEach(item => {
issues.push({
severity,
category: this.categorizeIssue(item),
title: this.extractIssueTitle(item),
description: item,
impact: this.assessImpact(item, severity),
fix: this.extractFix(item),
});
});
return issues;
}
/**
* Extract list items from a section
*/
extractListItems(text, sectionName) {
const regex = new RegExp(`${sectionName}[:\\s]*([\\s\\S]*?)(?=\\*\\*[A-Z]|$)`, 'i');
const match = text.match(regex);
if (!match)
return [];
return match[1]
.split(/[-โข]\s+/)
.filter(item => item.trim().length > 0)
.map(item => item.trim());
}
/**
* Categorize issue by content
*/
categorizeIssue(issueText) {
const text = issueText.toLowerCase();
if (text.includes('contrast') || text.includes('accessibility') || text.includes('wcag')) {
return 'accessibility';
}
if (text.includes('mobile') || text.includes('touch') || text.includes('responsive')) {
return 'mobile-optimization';
}
if (text.includes('performance') || text.includes('loading') || text.includes('speed')) {
return 'performance';
}
if (text.includes('visual') || text.includes('design') || text.includes('color')) {
return 'visual-design';
}
return 'usability';
}
/**
* Extract issue title from description
*/
extractIssueTitle(issueText) {
// Take first sentence or first 50 characters
const firstSentence = issueText.split('.')[0];
return firstSentence.length > 50 ? firstSentence.substring(0, 47) + '...' : firstSentence;
}
/**
* Assess impact based on issue content and severity
*/
assessImpact(issueText, severity) {
const impactMap = {
critical: 'Blocks user task completion or causes significant frustration',
major: 'Makes the interface difficult or unpleasant to use',
minor: 'Small improvement opportunity that would enhance the experience',
};
return impactMap[severity];
}
/**
* Extract fix suggestion from issue text
*/
extractFix(issueText) {
// Look for common fix patterns
const fixPatterns = [
/fix[:\s]+([^.]+)/i,
/solution[:\s]+([^.]+)/i,
/recommend[:\s]+([^.]+)/i,
/should[:\s]+([^.]+)/i,
];
for (const pattern of fixPatterns) {
const match = issueText.match(pattern);
if (match) {
return match[1].trim();
}
}
return undefined;
}
/**
* Extract recommendations from text
*/
extractRecommendations(text) {
const recommendations = [];
const items = this.extractListItems(text, 'RECOMMENDATIONS');
items.forEach(item => {
recommendations.push({
type: this.categorizeRecommendation(item),
title: this.extractIssueTitle(item),
description: item,
implementation: this.extractImplementation(item),
impact: this.assessRecommendationImpact(item),
effort: this.assessRecommendationEffort(item),
});
});
return recommendations;
}
/**
* Categorize recommendation by type
*/
categorizeRecommendation(text) {
const lower = text.toLowerCase();
if (lower.includes('css') || lower.includes('html') || lower.includes('javascript') || lower.includes('code')) {
return 'code';
}
if (lower.includes('content') || lower.includes('copy') || lower.includes('text')) {
return 'content';
}
if (lower.includes('process') || lower.includes('workflow') || lower.includes('team')) {
return 'process';
}
return 'design';
}
/**
* Extract implementation details
*/
extractImplementation(text) {
// Look for code blocks or specific instructions
const codeMatch = text.match(/```[\s\S]*?```/);
if (codeMatch) {
return codeMatch[0];
}
// Look for implementation keywords
const implMatch = text.match(/implement[:\s]+([^.]+)/i);
if (implMatch) {
return implMatch[1].trim();
}
return text;
}
/**
* Assess recommendation impact
*/
assessRecommendationImpact(text) {
const lower = text.toLowerCase();
if (lower.includes('critical') || lower.includes('conversion') || lower.includes('accessibility')) {
return 'high';
}
if (lower.includes('major') || lower.includes('usability')) {
return 'medium';
}
return 'low';
}
/**
* Assess recommendation effort
*/
assessRecommendationEffort(text) {
const lower = text.toLowerCase();
if (lower.includes('simple') || lower.includes('quick') || lower.includes('css')) {
return 'low';
}
if (lower.includes('redesign') || lower.includes('refactor') || lower.includes('complex')) {
return 'high';
}
return 'medium';
}
/**
* Extract specific score from text
*/
extractSpecificScore(text, category) {
const regex = new RegExp(`${category}[:\\s]*([\\d]+)\/10`, 'i');
const match = text.match(regex);
return match ? parseInt(match[1]) : undefined;
}
/**
* Validate analysis result
*/
validateResult(result) {
if (result.overallScore < 0 || result.overallScore > 10) {
console.warn('Invalid overall score, clamping to 0-10 range');
result.overallScore = Math.max(0, Math.min(10, result.overallScore));
}
if (!result.criticalIssues)
result.criticalIssues = [];
if (!result.majorIssues)
result.majorIssues = [];
if (!result.minorIssues)
result.minorIssues = [];
if (!result.strengths)
result.strengths = [];
if (!result.recommendations)
result.recommendations = [];
}
/**
* Create error result for failed analyses
*/
createErrorResult(error, context, analysisTime) {
// Safely extract page information with proper null checks
const pageUrl = context?.pageInfo?.url || context?.stage || 'unknown';
return {
id: `jpglens-error-${Date.now()}`,
timestamp: new Date().toISOString(),
page: pageUrl,
context,
overallScore: 0,
scores: {},
strengths: [],
criticalIssues: [
{
severity: 'critical',
category: 'error-handling',
title: 'Analysis Failed',
description: `jpglens analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
impact: 'Could not analyze user experience',
fix: 'Check configuration and try again',
},
],
majorIssues: [],
minorIssues: [],
recommendations: [],
model: this.config.ai.model,
tokensUsed: 0,
analysisTime,
error: true,
};
}
/**
* Configure reporting system
*/
configureReporting(config) {
this.reportGenerator.updateConfig(config);
}
/**
* Get current reporting configuration
*/
getReportingConfig() {
return this.reportGenerator.getConfig();
}
/**
* Add custom report template
*/
addReportTemplate(name, template) {
this.reportGenerator.addTemplate(name, template);
}
/**
* Get API compatibility information
*/
getAPICompatibilityInfo() {
return this.apiHandler.getConfigSummary();
}
/**
* Manually generate report for existing result
*/
async generateReport(result, config) {
return this.reportGenerator.generateReport(result, config);
}
}
/**
* ๐ jpglens - Screenshot Capture System
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Universal screenshot capture and management system
* Handles screenshots from multiple testing frameworks
*/
class ScreenshotCapture {
outputDir;
constructor(outputDir = './jpglens-screenshots') {
this.outputDir = outputDir;
// Initialize directory creation asynchronously
this.ensureOutputDir().catch(error => console.warn('Failed to initialize screenshot directory:', error));
}
/**
* Capture screenshot from Playwright page
*/
async capturePlaywrightPage(page, options = {}) {
const timestamp = new Date().toISOString();
const filename = `playwright-${Date.now()}.png`;
const filepath = join(this.outputDir, filename);
// Capture screenshot with Playwright
const buffer = await page.screenshot({
path: filepath,
fullPage: options.fullPage ?? true,
animations: options.animations ?? 'disabled',
mask: options.mask ?? [],
type: 'png',
});
// Get viewport information
const viewport = page.viewportSize() || { width: 1280, height: 720 };
return {
buffer,
path: filepath,
metadata: {
width: viewport.width,
height: viewport.height,
devicePixelRatio: (await page.evaluate(() => window.devicePixelRatio)) || 1,
timestamp,
},
};
}
/**
* Capture screenshot from Selenium WebDriver
*/
async captureSeleniumDriver(driver) {
const timestamp = new Date().toISOString();
const filename = `selenium-${Date.now()}.png`;
const filepath = join(this.outputDir, filename);
// Capture screenshot with Selenium
const base64Data = await driver.takeScreenshot();
const buffer = Buffer.from(base64Data, 'base64');
// Save to file
writeFileSync(filepath, buffer);
// Get window size
const windowSize = await driver.manage().window().getSize();
return {
buffer,
path: filepath,
metadata: {
width: windowSize.width,
height: windowSize.height,
devicePixelRatio: 1, // Selenium doesn't provide this easily
timestamp,
},
};
}
/**
* Load screenshot from Cypress (which saves to filesystem)
*/
async loadFromPath(filepath, metadata) {
const buffer = readFileSync(filepath);
return {
buffer,
path: filepath,
metadata,
};
}
/**
* Create screenshot data from buffer
*/
createFromBuffer(buffer, metadata) {
const timestamp = metadata.timestamp || new Date().toISOString();
const filename = `buffer-${Date.now()}.png`;
const filepath = join(this.outputDir, filename);
// Save buffer to file
writeFileSync(filepath, buffer);
return {
buffer,
path: filepath,
metadata: {
width: metadata.width,
height: metadata.height,
devicePixelRatio: metadata.devicePixelRatio || 1,
timestamp,
},
};
}
/**
* Capture multiple screenshots for user journey
* Returns them in a zip-like structure for batch AI analysis
*/
async captureJourneyScreenshots(captureFunction, stages) {
const screenshots = [];
for (const stage of stages) {
try {
const screenshot = await captureFunction();
// Add stage metadata
screenshot.stageInfo = {
stageName: stage,
stageIndex: stages.indexOf(stage),
totalStages: stages.length,
};
screenshots.push(screenshot);
}
catch (error) {
console.error(`Failed to capture screenshot for stage ${stage}:`, error);
}
}
// For user journey analysis, we can create a zip file
// This allows sending all screenshots to AI in one request for better context
const zipPath = await this.createScreenshotZip(screenshots);
return { screenshots, zipPath };
}
/**
* Create a zip file containing all screenshots for batch analysis
* This enables better contextual analysis across user journey stages
*/
async createScreenshotZip(screenshots) {
try {
// This is a placeholder for zip creation
// In a real implementation, you'd use a library like 'adm-zip'
const zipPath = join(this.outputDir, `journey-${Date.now()}.zip`);
// For now, we'll create a JSON manifest of the screenshots
// Real implementation would create actual zip file
const manifest = {
screenshots: screenshots.map(s => ({
path: s.path,
metadata: s.metadata,
stageInfo: s.stageInfo,
})),
createdAt: new Date().toISOString(),
totalScreenshots: screenshots.length,
};
writeFileSync(zipPath.replace('.zip', '.json'), JSON.stringify(manifest, null, 2));
return zipPath;
}
catch (error) {
console.warn('Failed to create screenshot zip:', error);
return undefined;
}
}
/**
* Optimize screenshot for AI analysis
* Reduces file size while maintaining quality for AI vision models
*/
async optimizeForAI(screenshot) {
// For now, return as-is
// Real implementation could:
// - Resize if too large (AI models have input limits)
// - Compress to reduce API costs
// - Convert format if needed
// - Add visual annotations for focus areas
return screenshot;
}
/**
* Add visual annotations to screenshot for better AI analysis
*/
async annotateScreenshot(screenshot, annotations) {
// This would use a library like 'sharp' or 'jimp' to add annotations
// For now, we'll store the annotation data in metadata
const annotatedScreenshot = { ...screenshot };
annotatedScreenshot.annotations = annotations;
return annotatedScreenshot;
}
/**
* Convert screenshot to base64 for API transmission
*/
toBase64(screenshot) {
return screenshot.buffer.toString('base64');
}
/**
* Get screenshot file size in bytes
*/
getFileSize(screenshot) {
return screenshot.buffer.length;
}
/**
* Validate screenshot data
*/
validateScreenshot(screenshot) {
const errors = [];
if (!screenshot.buffer || screenshot.buffer.length === 0) {
errors.push('Screenshot buffer is empty');
}
if (!screenshot.path) {
errors.push('Screenshot path is missing');
}
if (!screenshot.metadata) {
errors.push('Screenshot metadata is missing');
}
else {
if (!screenshot.metadata.width || !screenshot.metadata.height) {
errors.push('Screenshot dimensions are missing');
}
if (screenshot.metadata.width < 100 || screenshot.metadata.height < 100) {
errors.push('Screenshot dimensions are too small');
}
if (screenshot.metadata.width > 5000 || screenshot.metadata.height > 5000) {
errors.push('Screenshot dimensions are too large');
}
}
// Check file size limits (most AI APIs have limits)
const maxSize = 20 * 1024 * 1024; // 20MB
if (screenshot.buffer.length > maxSize) {
errors.push(`Screenshot file size (${Math.round(screenshot.buffer.length / 1024 / 1024)}MB) exceeds maximum (20MB)`);
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Ensure output directory exists
*/
async ensureOutputDir() {
try {
const fs = await import('fs');
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
}
catch (error) {
console.warn(`Failed to create screenshot output directory: ${error}`);
}
}
/**
* Clean up old screenshots to save disk space
*/
async cleanup(olderThanHours = 24) {
try {
const fs = await import('fs');
const path = await import('path');
if (!fs.existsSync(this.outputDir))
return;
const files = fs.readdirSync(this.outputDir);
const cutoffTime = Date.now() - olderThanHours * 60 * 60 * 1000;
let deletedCount = 0;
for (const file of files) {
const filepath = path.join(this.outputDir, file);
const stats = fs.statSync(filepath);
if (stats.mtime.getTime() < cutoffTime) {
fs.unlinkSync(filepath);
deletedCount++;
}
}
if (deletedCount > 0) {
console.log(`๐งน jpglens cleaned up ${deletedCount} old screenshots`);
}
}
catch (error) {
console.warn('Failed to cleanup old screenshots:', error);
}
}
}
/**
* ๐ jpglens - Configuration Management
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Default configuration for jpglens
*/
const DEFAULT_CONFIG = {
ai: {
provider: 'openrouter',
apiKey: process.env.JPGLENS_API_KEY || '',
model: process.env.JPGLENS_MODEL || 'openai/gpt-4-vision-preview',
fallbackModel: 'anthropic/claude-3-5-sonnet',
maxTokens: 4000,
temperature: 0.1,
baseUrl: 'https://openrouter.ai/api/v1',
},
analysis: {
types: ['usability', 'accessibility', 'visual-design'],
depth: 'standard',
includeScreenshots: true,
generateReports: true,
outputDir: './jpglens-reports',
},
// Reporting configuration
reporting: {
enabled: true,
outputDir: './jpglens-reports',
template: 'detailed',
format: 'markdown',
includeScreenshots: true,
includeRawAnalysis: false,
timestampFormat: 'ISO',
fileNaming: '{timestamp}-{component}-{page}',
customPrompts: {},
apiCompatibility: 'auto',
},
// Pre-built user personas for common use cases
userPersonas: {
'business-user': {
name: 'Business Professional',
expertise: 'intermediate',
device: 'desktop-primary',
urgency: 'medium',
goals: ['efficiency', 'accuracy', 'professional-appearance'],
painPoints: ['complex interfaces', 'slow loading', 'unclear navigation'],
context: 'Professional work environment, needs reliable tools',
},
'mobile-consumer': {
name: 'Mobile Consumer',
expertise: 'novice',
device: 'mobile-primary',
urgency: 'high',
goals: ['speed', 'simplicity', 'trust'],
painPoints: ['small touch targets', 'slow loading', 'complex forms'],
context: 'On-the-go usage, limited attention, thumb navigation',
},
'power-user': {
name: 'Power User',
expertise: 'expert',
device: 'mixed',
urgency: 'low',
goals: ['customization', 'advanced-features', 'keyboard-shortcuts'],
painPoints: ['lack of shortcuts', 'limited customization', 'dumbed-down interfaces'],
context: 'Daily heavy usage, values efficiency over simplicity',
},
'accessibility-user': {
name: 'Accessibility User',
expertise: 'intermediate',
device: 'desktop-primary',
urgency: 'medium',
goals: ['screen-reader-compatibility', 'keyboard-navigation', 'high-contrast'],
painPoints: ['poor alt text', 'keyboard traps', 'low contrast'],
context: 'Uses assistive technologies, relies on semantic HTML',
},
'first-time-visitor': {
name: 'First-Time Visitor',
expertise: 'novice',
device: 'mixed',
urgency: 'high',
goals: ['understand-value', 'quick-trial', 'low-commitment'],
painPoints: ['unclear value prop', 'complex signup', 'information overload'],
context: 'Evaluating product, high bounce risk, needs immediate value',
},
},
// Pre-built journey templates for common scenarios
journeyTemplates: {
'e-commerce': ['discovery', 'product-selection', 'add-to-cart', 'checkout', 'confirmation'],
'saas-onboarding': ['landing', 'signup', 'email-verification', 'setup', 'first-use', 'activation'],
'content-consumption': ['discovery', 'article-reading', 'engagement', 'sharing', 'related-content'],
'form-completion': ['form-discovery', 'field-entry', 'validation', 'review', 'submission', 'confirmation'],
'dashboard-analysis': ['login', 'overview', 'drill-down', 'filter-data', 'export-results'],
'mobile-app': ['app-launch', 'onboarding', 'core-feature', 'settings', 'sharing'],
},
};
/**
* ๐ jpglens - Storybook Integration
* Universal AI-Powered UI Testing
*
* @author Taha Bahrami (Kaito)
* @license MIT
*/
/**
* Storybook integration for jpglens
* Analyze component states and interactions within Storybook stories
*/
class StorybookJPGLens {
aiAnalyzer;
screenshotCapture;
config;
constructor(config) {
this.config = config || DEFAULT_CONFIG;
this.aiAnalyzer = new AIAnalyzer(this.config);
this.screenshotCapture = new ScreenshotCapture();
}
/**
* Analyze component states within Storybook
*/
async analyzeComponentStates(canvas, context) {
try {
// Get canvas element and convert to screenshot
const canvasElement = canvas.container || canvas;
const screenshot = await this.captureCanvasScreenshot(canvasElement);
// Enhance context for component analysis
const enhancedContext = {
...context,
stage: `component-${context.component.toLowerCase()}`,
userIntent: `evaluate ${context.component} component usability and design`,
technicalContext: {
...context.technicalContext,
framework: 'Storybook',
designSystem: context.designSystem || 'unknown',
deviceSupport: 'responsive',
},
componentInfo: {
name: context.component,
states: context.states,
framework: 'Storybook',
},
};
// Perform AI analysis
const result = await this.aiAnalyzer.analyze(screenshot, enhancedContext);
// Add Storybook-specific metadata
result.storybookInfo = {
component: context.component,
states: context.states,
designSystem: context.designSystem,
};
return result;
}
catch (error) {
throw new Error(`Storybook jpglens analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Analyze complete component library
*/
async analyzeComponentLibrary(components) {
const results = [];
for (const component of components) {
try {
const result = await this.analyzeComponentStates(component.canvas, {
userContext: {
persona: 'design-system-user',
deviceContext: 'storybook-browser',
expertise: 'intermediate',
},
stage: 'component-evaluation',
userIntent: `evaluate ${component.name} for design system consistency`,
component: component.name,
states: component.states,
businessContext: {
industry: 'design-systems',
conversionGoal: 'component-adoption',
brandPersonality: 'consistent-professional',
},
});
result.componentCategory = component.category;
results.push(result);
}
catch (error) {
console.error(`Failed to analyze component ${component.name}:`, error);
}
}
return results;
}
/**
* Analyze accessibility across component states
*/
async analyzeComponentAccessibility(canvas, component, states) {
const context = {
userContext: {
persona: 'accessibility-user',
deviceContext: 'screen-reader-desktop',
expertise: 'intermediate',
},
stage: 'accessibility-evaluation',
userIntent: 'ensure component is accessible across all states',
businessContext: {
industry: 'accessibility-compliance',
conversionGoal: 'wcag-compliance',
targetAudience: 'users-with-disabilities',
},
technicalContext: {
framework: 'Storybook',
accessibilityTarget: 'WCAG-AA',
deviceSupport: 'responsive',
},
};
return this.analyzeComponentStates(canvas, {
...context,
component,
states,
});
}
/**
* Capture screenshot from Storybook canvas
*/
async captureCanvasScreenshot(canvasElement) {
// This would typically use html2canvas or similar library
// For now, we'll simulate the screenshot capture
const rect = canvasElement.getBoundingClientRect();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not create canvas context for screenshot');
}
canvas.width = rect.width;
canvas.height = rect.height;
// In a real implementation, you'd use html2canvas:
// const canvas = await html2canvas(canvasElement);
// const buffer = Buffer.from(canvas.toDataURL('image/png').split(',')[1], 'base64');
// For now, create a placeholder screenshot
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000000';
ctx.font = '16px Arial';
ctx.fillText('Storybook Component Screenshot', 10, 30);
const dataUrl = canvas.toDataURL('image/png');
const buffer = Buffer.from(dataUrl.split(',')[1], 'base64');
return this.screenshotCapture.createFromBuffer(buffer, {
width: canvas.width,
height: canvas.height,
devicePixelRatio: window.devicePixelRatio || 1,
timestamp: new Date().toISOString(),
});
}
}
/**
* Global functions for easy Storybook integration
*/
let storybookJPGLens;
function getStorybookJPGLens() {
if (!storybookJPGLens) {
storybookJPGLens = new StorybookJPGLens();
}
return storybookJPGLens;
}
/**
* Analyze component states (main function for stories)
*/
async function analyzeComponentStates(canvas, context) {
const jpglens = getStorybookJPGLens();
return jpglens.analyzeComponentStates(canvas, context);
}
/**
* Quick component analysis with minimal setup
*/
async function analyzeComponent(canvas, componentName, options = {}) {
const jpglens = getStorybookJPGLens();
return jpglens.analyzeComponentStates(canvas, {
userContext: {
persona: 'component-user',
deviceContext: 'storybook-browser',
expertise: 'intermediate',
},
stage: 'component-review',
userIntent: options.focus || `evaluate ${componentName} component`,
component: componentName,
states: options.states || ['default'],
designSystem: options.designSystem,
});
}
/**
* Accessibility-focused component analysis
*/
async function analyzeComponentA11y(canvas, componentName, states = ['default', 'hover', 'focus', 'active', 'disabled']) {
const jpglens = getStorybookJPGLens();
return jpglens.analyzeComponentAccessibility(canvas, componentName, states);
}
/**
* Design system consistency analysis
*/
async function analyzeDesignSystemConsistency(canvas, componentName, designSystemName) {
const jpglens = getStorybookJPGLens();
return jpglens.analyzeComponentStates(canvas, {
userContext: {
persona: 'design-system-maintainer',
deviceContext: 'design-review',
expertise: 'expert',
},
stage: 'design-system-audit',
userIntent: `ensure ${componentName} follows ${designSystemName} guidelines`,
component: componentName,
states: ['default', 'hover', 'active'],
designSystem: designSystemName,
businessContext: {
industry: 'design-systems',
conversionGoal: 'consistency-compliance',
brandPersonality: 'systematic-professional',
},
});
}
// Export the class for advanced usage
// StorybookJPGLens already exported above
export { StorybookJPGLens, analyzeComponent, analyzeComponentA11y, analyzeComponentStates, analyzeDesignSystemConsistency };