|
| 1 | +import fs from "node:fs"; |
| 2 | +import path from "node:path"; |
| 3 | +import pixelmatch from "pixelmatch"; |
| 4 | +import { PNG } from "pngjs"; |
| 5 | +import { ensureDir } from "../../shared/utils.js"; |
| 6 | +import type { QaIssue } from "../../shared/types.js"; |
| 7 | + |
| 8 | +/** |
| 9 | + * Detects visual differences between current screenshot and baseline screenshot. |
| 10 | + * If baseline doesn't exist, it creates one. |
| 11 | + */ |
| 12 | +export async function detectVisualRegression( |
| 13 | + currentScreenshotPath: string, |
| 14 | + url: string, |
| 15 | + moduleName = "default" |
| 16 | +): Promise<QaIssue[]> { |
| 17 | + const issues: QaIssue[] = []; |
| 18 | + if (!currentScreenshotPath || !fs.existsSync(currentScreenshotPath)) { |
| 19 | + return issues; |
| 20 | + } |
| 21 | + |
| 22 | + // Create baseline directory |
| 23 | + const baselineDir = path.join(process.cwd(), "agent", "artifacts", "baselines"); |
| 24 | + ensureDir(baselineDir); |
| 25 | + |
| 26 | + // Create diff directory |
| 27 | + const diffDir = path.join(process.cwd(), "agent", "artifacts", "visual-diffs"); |
| 28 | + ensureDir(diffDir); |
| 29 | + |
| 30 | + // Normalize URL to a safe filename |
| 31 | + const safeUrl = url.replace(/[^a-z0-9]+/gi, "-").toLowerCase().slice(0, 100); |
| 32 | + const baselineFilename = `${safeUrl}-${moduleName}.png`; |
| 33 | + const baselinePath = path.join(baselineDir, baselineFilename); |
| 34 | + |
| 35 | + // If baseline doesn't exist, save current screenshot as baseline and return empty issues |
| 36 | + if (!fs.existsSync(baselinePath)) { |
| 37 | + try { |
| 38 | + fs.copyFileSync(currentScreenshotPath, baselinePath); |
| 39 | + } catch { |
| 40 | + // Ignore copy error |
| 41 | + } |
| 42 | + return issues; |
| 43 | + } |
| 44 | + |
| 45 | + try { |
| 46 | + const img1 = PNG.sync.read(fs.readFileSync(baselinePath)); |
| 47 | + const img2 = PNG.sync.read(fs.readFileSync(currentScreenshotPath)); |
| 48 | + |
| 49 | + const { width, height } = img1; |
| 50 | + // Handle size mismatches |
| 51 | + if (img2.width !== width || img2.height !== height) { |
| 52 | + issues.push({ |
| 53 | + title: "Visual baseline size mismatch", |
| 54 | + severity: "Low", |
| 55 | + area: "Visual Regression", |
| 56 | + description: `Current screenshot size (${img2.width}x${img2.height}) does not match baseline screenshot size (${width}x${height}).`, |
| 57 | + suggestedFix: "Run tests under identical viewport dimensions, or update the visual baseline if the page layout has changed." |
| 58 | + }); |
| 59 | + return issues; |
| 60 | + } |
| 61 | + |
| 62 | + const diff = new PNG({ width, height }); |
| 63 | + const diffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, { threshold: 0.1 }); |
| 64 | + |
| 65 | + // If there is a meaningful difference (e.g. > 500 pixels) |
| 66 | + if (diffPixels > 500) { |
| 67 | + const diffPath = path.join(diffDir, `diff-${baselineFilename}`); |
| 68 | + fs.writeFileSync(diffPath, PNG.sync.write(diff)); |
| 69 | + |
| 70 | + issues.push({ |
| 71 | + title: "Visual regression detected", |
| 72 | + severity: "Medium", |
| 73 | + area: "Visual Regression", |
| 74 | + description: `Visual difference of ${diffPixels} pixels detected compared to baseline screenshot. Diff saved to visual-diffs/diff-${baselineFilename}`, |
| 75 | + evidence: diffPath, |
| 76 | + suggestedFix: "Compare the screenshot with the diff file to see layout/style regressions, and update baseline if correct." |
| 77 | + }); |
| 78 | + } |
| 79 | + } catch (err: any) { |
| 80 | + // Fail silently on image parse issues |
| 81 | + } |
| 82 | + |
| 83 | + return issues; |
| 84 | +} |
0 commit comments