import { z } from 'zod';
import * as fs from 'fs';
import * as path from 'path';
import OpenAI from 'openai';
import ExcelJS from 'exceljs';
// Default prompt template
const DEFAULT_PROMPT = `You are a Frontend QA engineer comparing UI screenshots. Your job is to verify the UI STRUCTURE and LAYOUT matches, NOT the data content.
## CRITICAL: This is UI/FE Testing, NOT Data Testing
Each dealer has DIFFERENT DATA (different cars, different features, different prices). This is EXPECTED and CORRECT.
## Feature: {{featureName}}
## User Story: {{userStory}}
## Dealer: {{dealerName}}
## WHAT TO CHECK (UI Structure Only):
1. Layout Structure: Same sections in same positions
2. UI Components Exist: Same types of components present
3. Widget/Section Types: Same widget types exist
4. Navigation Elements: Menu structure in expected positions
5. Visual Hierarchy: Similar visual organization
## WHAT TO COMPLETELY IGNORE (NOT errors):
- Different text content (car names, feature names, prices)
- Different number of items in lists
- Different feature names
- Different images/photos
- Different data values
- Different colors/themes/logos
## Response Format (JSON only):
{
"pass": true or false,
"score": 0 to 100,
"analysis": "Brief assessment",
"issues": ["Issue 1", "Issue 2"]
}
REMEMBER: Different DATA is NOT an error. Only MISSING or WRONG UI components are errors.`;
const CompareDealerScreenshotsSchema = z.object({
expectationImagePath: z.string().describe('Path to the expectation/baseline image (PNG/JPG)'),
dealersFolderPath: z.string().describe('Path to dealers folder containing subfolders for each dealer'),
featureName: z.string().describe('Name of the feature being compared (e.g., "Login Page")'),
userStory: z.string().describe('User story or acceptance criteria to verify against'),
customPrompt: z.string().optional().describe('Custom prompt for AI analysis. Use {{featureName}}, {{userStory}}, {{dealerName}} as placeholders. If not provided, uses default prompt.'),
openaiApiKey: z.string().optional().describe('OpenAI API key (or set OPENAI_API_KEY env var)'),
exportPath: z.string().optional().describe('Path to export Excel report (default: ./report.xlsx)')
});
type Params = z.infer<typeof CompareDealerScreenshotsSchema>;
interface DealerResult {
dealer: string;
image: string;
score: number;
status: 'PASS' | 'FAIL' | 'ERROR' | 'NO IMAGE';
analysis: string;
issues: string[];
}
function imageToBase64(imagePath: string): string {
const buffer = fs.readFileSync(imagePath);
return buffer.toString('base64');
}
function getImageMimeType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
if (ext === '.png') return 'image/png';
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.gif') return 'image/gif';
if (ext === '.webp') return 'image/webp';
return 'image/png';
}
function getImageFiles(dir: string): string[] {
try {
return fs.readdirSync(dir).filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f));
} catch { return []; }
}
function getDealerFolders(basePath: string): string[] {
try {
return fs.readdirSync(basePath, { withFileTypes: true })
.filter(item => item.isDirectory())
.map(item => item.name);
} catch { return []; }
}
function buildPrompt(template: string, featureName: string, userStory: string, dealerName: string): string {
return template
.replace(/\{\{featureName\}\}/g, featureName)
.replace(/\{\{userStory\}\}/g, userStory)
.replace(/\{\{dealerName\}\}/g, dealerName);
}
async function analyzeWithVision(
openai: OpenAI,
expectationBase64: string,
expectationMime: string,
dealerBase64: string,
dealerMime: string,
featureName: string,
userStory: string,
dealerName: string,
customPrompt?: string
): Promise<{ pass: boolean; score: number; analysis: string; issues: string[] }> {
const promptTemplate = customPrompt || DEFAULT_PROMPT;
const prompt = buildPrompt(promptTemplate, featureName, userStory, dealerName);
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'text', text: '\n\nIMAGE 1 - EXPECTATION (Baseline):' },
{ type: 'image_url', image_url: { url: `data:${expectationMime};base64,${expectationBase64}`, detail: 'high' } },
{ type: 'text', text: `\n\nIMAGE 2 - DEALER (${dealerName}):` },
{ type: 'image_url', image_url: { url: `data:${dealerMime};base64,${dealerBase64}`, detail: 'high' } }
]
}
],
max_tokens: 1000,
temperature: 0.1
});
const content = response.choices[0]?.message?.content || '';
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const result = JSON.parse(jsonMatch[0]);
return {
pass: result.pass ?? false,
score: result.score ?? 0,
analysis: result.analysis ?? 'No analysis',
issues: result.issues ?? []
};
}
return { pass: false, score: 0, analysis: content, issues: ['Could not parse AI response'] };
} catch (error: any) {
return { pass: false, score: 0, analysis: `API Error: ${error.message}`, issues: [error.message] };
}
}
async function exportToExcel(
results: DealerResult[],
featureName: string,
userStory: string,
exportPath: string
): Promise<string> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'MCP Dealer Screenshot Tool';
workbook.created = new Date();
// Summary Sheet
const summarySheet = workbook.addWorksheet('Summary');
summarySheet.columns = [
{ header: 'Feature', key: 'feature', width: 30 },
{ header: 'User Story', key: 'userStory', width: 50 },
{ header: 'Total Dealers', key: 'total', width: 15 },
{ header: 'Passed', key: 'passed', width: 10 },
{ header: 'Failed', key: 'failed', width: 10 },
{ header: 'Pass Rate', key: 'passRate', width: 12 },
{ header: 'Generated At', key: 'generatedAt', width: 20 }
];
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status !== 'PASS').length;
const passRate = results.length > 0 ? ((passed / results.length) * 100).toFixed(1) + '%' : '0%';
summarySheet.addRow({
feature: featureName,
userStory: userStory,
total: results.length,
passed: passed,
failed: failed,
passRate: passRate,
generatedAt: new Date().toLocaleString()
});
summarySheet.getRow(1).font = { bold: true };
summarySheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } };
summarySheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
// Results Sheet
const resultsSheet = workbook.addWorksheet('Results');
resultsSheet.columns = [
{ header: '#', key: 'index', width: 5 },
{ header: 'Dealer', key: 'dealer', width: 20 },
{ header: 'Image', key: 'image', width: 30 },
{ header: 'Score', key: 'score', width: 10 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Analysis', key: 'analysis', width: 50 },
{ header: 'Issues', key: 'issues', width: 60 }
];
results.forEach((r, i) => {
const row = resultsSheet.addRow({
index: i + 1,
dealer: r.dealer,
image: r.image,
score: r.score,
status: r.status,
analysis: r.analysis,
issues: r.issues.join('; ')
});
const statusCell = row.getCell('status');
if (r.status === 'PASS') {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF92D050' } };
statusCell.font = { bold: true, color: { argb: 'FF006600' } };
} else if (r.status === 'FAIL') {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B6B' } };
statusCell.font = { bold: true, color: { argb: 'FF990000' } };
row.eachCell((cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF0F0' } };
});
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B6B' } };
} else {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFEB9C' } };
statusCell.font = { bold: true };
}
});
resultsSheet.getRow(1).font = { bold: true };
resultsSheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } };
resultsSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
// Failed Only Sheet
const failedResults = results.filter(r => r.status !== 'PASS');
if (failedResults.length > 0) {
const failedSheet = workbook.addWorksheet('Failed Dealers');
failedSheet.columns = [
{ header: '#', key: 'index', width: 5 },
{ header: 'Dealer', key: 'dealer', width: 20 },
{ header: 'Image', key: 'image', width: 30 },
{ header: 'Score', key: 'score', width: 10 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Analysis', key: 'analysis', width: 50 },
{ header: 'Issues', key: 'issues', width: 60 }
];
failedResults.forEach((r, i) => {
const row = failedSheet.addRow({
index: i + 1,
dealer: r.dealer,
image: r.image,
score: r.score,
status: r.status,
analysis: r.analysis,
issues: r.issues.join('; ')
});
row.getCell('status').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B6B' } };
row.getCell('status').font = { bold: true, color: { argb: 'FF990000' } };
});
failedSheet.getRow(1).font = { bold: true };
failedSheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B6B' } };
failedSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
}
await workbook.xlsx.writeFile(exportPath);
return exportPath;
}
async function execute(args: Params): Promise<string> {
const { expectationImagePath, dealersFolderPath, featureName, userStory, customPrompt, openaiApiKey, exportPath } = args;
const apiKey = openaiApiKey || process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OpenAI API key required. Set OPENAI_API_KEY env var or pass openaiApiKey parameter.');
}
const openai = new OpenAI({ apiKey });
if (!fs.existsSync(expectationImagePath)) {
throw new Error(`Expectation image not found: ${expectationImagePath}`);
}
const expectationBase64 = imageToBase64(expectationImagePath);
const expectationMime = getImageMimeType(expectationImagePath);
const dealers = getDealerFolders(dealersFolderPath);
if (dealers.length === 0) {
throw new Error(`No dealer folders found in: ${dealersFolderPath}`);
}
const results: DealerResult[] = [];
let output = `# UI Structure Comparison Report\n\n`;
output += `## Feature: ${featureName}\n`;
output += `**User Story:** ${userStory}\n`;
output += `**Baseline:** ${path.basename(expectationImagePath)}\n`;
output += `**Prompt:** ${customPrompt ? 'Custom' : 'Default'}\n\n`;
output += `---\n\n## Results\n\n`;
output += `| Dealer | Image | Score | Status | Analysis |\n`;
output += `|--------|-------|-------|--------|----------|\n`;
for (const dealer of dealers) {
const dealerPath = path.join(dealersFolderPath, dealer);
const images = getImageFiles(dealerPath);
if (images.length === 0) {
results.push({ dealer, image: '-', score: 0, status: 'NO IMAGE', analysis: 'No screenshot found', issues: ['Missing screenshot'] });
output += `| ${dealer} | - | 0 | NO IMAGE | No screenshot found |\n`;
continue;
}
for (const img of images) {
const imgPath = path.join(dealerPath, img);
try {
const dealerBase64 = imageToBase64(imgPath);
const dealerMime = getImageMimeType(imgPath);
const result = await analyzeWithVision(openai, expectationBase64, expectationMime, dealerBase64, dealerMime, featureName, userStory, dealer, customPrompt);
const status: 'PASS' | 'FAIL' = result.pass ? 'PASS' : 'FAIL';
results.push({ dealer, image: img, score: result.score, status, analysis: result.analysis, issues: result.issues });
const shortAnalysis = result.analysis.length > 50 ? result.analysis.substring(0, 47) + '...' : result.analysis;
output += `| ${dealer} | ${img} | ${result.score}/100 | ${status} | ${shortAnalysis} |\n`;
} catch (error: any) {
results.push({ dealer, image: img, score: 0, status: 'ERROR', analysis: error.message, issues: [error.message] });
output += `| ${dealer} | ${img} | 0 | ERROR | ${error.message} |\n`;
}
}
}
const passCount = results.filter(r => r.status === 'PASS').length;
const failCount = results.filter(r => r.status !== 'PASS').length;
output += `\n---\n\n## Summary\n\n`;
output += `- **PASS:** ${passCount}\n`;
output += `- **FAIL:** ${failCount}\n`;
output += `- **Total:** ${results.length}\n\n`;
// Export to Excel
const excelPath = exportPath || path.join(dealersFolderPath, '../', `report-${featureName.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.xlsx`);
try {
const savedPath = await exportToExcel(results, featureName, userStory, excelPath);
output += `---\n\n## Excel Report\n\n`;
output += `**Exported to:** ${savedPath}\n\n`;
output += `The Excel file contains 3 sheets:\n`;
output += `1. **Summary** - Overview with pass rate\n`;
output += `2. **Results** - All dealers with color-coded status\n`;
output += `3. **Failed Dealers** - Only failed dealers for quick review\n`;
} catch (error: any) {
output += `\n**Excel export failed:** ${error.message}\n`;
}
return output;
}
export const compareDealerScreenshotsTool = {
name: 'compare-dealer-screenshots',
description: 'Compare dealer UI screenshots against expectation image using AI Vision (GPT-4o). Exports results to Excel with PASS/FAIL status for each dealer. Supports custom prompts with placeholders: {{featureName}}, {{userStory}}, {{dealerName}}',
parameters: CompareDealerScreenshotsSchema,
execute
};