/** * Pure visual-diff verdict logic, split out of crawler.ts so it can be unit-tested * without pulling in crawler.ts's full import graph (Playwright, Stagehand, and * transitively @detiq/database, which instantiates a PrismaClient at module load and * so requires a live DATABASE_URL just to import the module). */ export interface VisualDiffVerdict { changeType: 'NONE' | 'MINOR' | 'SIGNIFICANT' | 'CRITICAL'; recommendation: 'APPROVE' | 'REVIEW' | 'FLAG' | 'BLOCK'; changes: Array<{ location: string; severity: string; description: string }>; bugReportDraft?: { title: string; severity: string; steps: string[]; expected: string; actual: string }; aiSummary?: string; } /** Cheap, always-on first-pass verdict from perceptual hash distance + layout/element diffs. */ export function pHashVerdict( pHashDistance: number, changeLevel: 'IDENTICAL' | 'MINOR' | 'MODERATE' | 'SIGNIFICANT', changes: Array<{ location: string; severity: string; description: string }>, ): VisualDiffVerdict { const changeType: VisualDiffVerdict['changeType'] = changeLevel === 'IDENTICAL' ? 'NONE' : changeLevel === 'MINOR' ? 'MINOR' : changeLevel === 'MODERATE' ? 'SIGNIFICANT' : 'CRITICAL'; const approveThreshold = parseInt(process.env.CRAWLER_VISUAL_DIFF_APPROVE_THRESHOLD ?? '') || 5; const reviewThreshold = parseInt(process.env.CRAWLER_VISUAL_DIFF_REVIEW_THRESHOLD ?? '') || 20; const recommendation: VisualDiffVerdict['recommendation'] = pHashDistance === 0 ? 'APPROVE' : pHashDistance < approveThreshold ? 'APPROVE' : pHashDistance < reviewThreshold ? 'REVIEW' : 'FLAG'; return { changeType, recommendation, changes }; } /** Replaces a pHash-only verdict with the AI's semantic judgment once it's available, * and drafts a bug report for anything still CRITICAL/SIGNIFICANT after AI review * (pHash flags plenty of anti-aliasing/font-rendering noise the AI correctly discards * as NONE/MINOR — only what survives AI review is worth a draft). */ export function mergeAiVisualVerdict( pHashResult: VisualDiffVerdict, aiResult: { changeType: string; changes: any[]; recommendation: string; diffScore: number; summary?: string }, screenName: string, ): VisualDiffVerdict { if (!aiResult.changeType) return pHashResult; const changeType = aiResult.changeType as VisualDiffVerdict['changeType']; const recommendation = aiResult.recommendation as VisualDiffVerdict['recommendation']; const changes = Array.isArray(aiResult.changes) && aiResult.changes.length > 0 ? aiResult.changes : pHashResult.changes; const aiSummary = aiResult.summary ?? undefined; const bugReportDraft = (changeType === 'CRITICAL' || changeType === 'SIGNIFICANT') ? { title: `Visual regression on "${screenName}"`, severity: changeType === 'CRITICAL' ? 'High' : 'Medium', steps: ['Navigate to the screen', 'Compare current view to baseline screenshot'], expected: 'Screen appearance should match the stored baseline.', actual: aiSummary || `Screen has changed (diff score: ${aiResult.diffScore.toFixed(1)}%)`, } : undefined; return { changeType, recommendation, changes, bugReportDraft, aiSummary }; }