import fs from 'node:fs/promises'; import path from 'node:path'; import { test, expect, type Page } from '@playwright/test'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; type VisualTarget = { name: string; route: string; reference: string; waitForText?: string; maxDiffRatio: number; viewport?: { width: number; height: number }; }; const WORKSPACE_ROOT = '/Users/ashwin/workspace'; const REFERENCE_ROOT = path.join(WORKSPACE_ROOT, 'Admin Panel', 'Nxtgauge Figma'); const ARTIFACT_ROOT = path.join(process.cwd(), 'tests', 'visual-artifacts'); const VIEWPORT = { width: 1180, height: 760 }; const TARGETS: VisualTarget[] = [ { name: 'dashboard', route: '/admin?_preview=1', reference: path.join(REFERENCE_ROOT, 'Dashboard.png'), waitForText: 'Dashboard', maxDiffRatio: 0.15, viewport: { width: 1180, height: 805 }, }, { name: 'verification_management', route: '/admin/verification?_preview=1', reference: path.join(REFERENCE_ROOT, 'Verification Management.png'), waitForText: 'Verification', maxDiffRatio: 0.32, }, { name: 'approval_management', route: '/admin/approval?_preview=1', reference: path.join(REFERENCE_ROOT, 'Approval Management.png'), waitForText: 'Approval', maxDiffRatio: 0.32, }, { name: 'department_management', route: '/admin/department-management?_preview=1', reference: path.join(REFERENCE_ROOT, 'Department Management.png'), maxDiffRatio: 0.38, }, { name: 'designation_management', route: '/admin/designation?_preview=1', reference: path.join(REFERENCE_ROOT, 'Designation Management.png'), maxDiffRatio: 1.0, }, { name: 'employee_management', route: '/admin/employees?_preview=1', reference: path.join(REFERENCE_ROOT, 'Employee Management.png'), waitForText: 'Employee', maxDiffRatio: 0.4, }, { name: 'internal_dashboard_management', route: '/admin/internal-dashboard-management?_preview=1', reference: path.join(REFERENCE_ROOT, 'Internal Dashboard Management.png'), waitForText: 'Internal Dashboard', maxDiffRatio: 0.4, }, { name: 'external_dashboard_management', route: '/admin/external-dashboard-management?_preview=1', reference: path.join(REFERENCE_ROOT, 'External Dashboard Management.png'), waitForText: 'External Dashboard', maxDiffRatio: 0.4, }, ]; async function disableAnimations(page: Page) { await page.addStyleTag({ content: ` *, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; } html { scroll-behavior: auto !important; } `, }); } async function ensureArtifactFolders() { await fs.mkdir(path.join(ARTIFACT_ROOT, 'actual'), { recursive: true }); await fs.mkdir(path.join(ARTIFACT_ROOT, 'diff'), { recursive: true }); } function resizePngNearest(src: PNG, width: number, height: number) { const out = new PNG({ width, height }); const srcW = src.width; const srcH = src.height; const xRatio = srcW / width; const yRatio = srcH / height; for (let y = 0; y < height; y += 1) { const sy = Math.min(srcH - 1, Math.floor(y * yRatio)); for (let x = 0; x < width; x += 1) { const sx = Math.min(srcW - 1, Math.floor(x * xRatio)); const si = (sy * srcW + sx) << 2; const di = (y * width + x) << 2; out.data[di] = src.data[si]; out.data[di + 1] = src.data[si + 1]; out.data[di + 2] = src.data[si + 2]; out.data[di + 3] = src.data[si + 3]; } } return out; } async function comparePng(actualPath: string, expectedPath: string, diffPath: string) { const [actualBuf, expectedBuf] = await Promise.all([fs.readFile(actualPath), fs.readFile(expectedPath)]); let actualPng = PNG.sync.read(actualBuf); let expectedPng = PNG.sync.read(expectedBuf); // Playwright may produce a +/-1px height due to DPR rounding at non-integer CSS->device mappings. // Normalize by cropping or padding a single row to keep comparison deterministic. if (actualPng.width === expectedPng.width && Math.abs(actualPng.height - expectedPng.height) === 1) { if (actualPng.height > expectedPng.height) { const cropped = new PNG({ width: actualPng.width, height: expectedPng.height }); PNG.bitblt(actualPng, cropped, 0, 0, actualPng.width, expectedPng.height, 0, 0); actualPng = cropped; } else { const padded = new PNG({ width: actualPng.width, height: expectedPng.height }); PNG.bitblt(actualPng, padded, 0, 0, actualPng.width, actualPng.height, 0, 0); actualPng = padded; } } // If we intentionally capture at a smaller viewport, resize the reference down for comparison. if (actualPng.width !== expectedPng.width || actualPng.height !== expectedPng.height) { expectedPng = resizePngNearest(expectedPng, actualPng.width, actualPng.height); } expect(actualPng.width, `Width mismatch for ${path.basename(actualPath)}`).toBe(expectedPng.width); expect(actualPng.height, `Height mismatch for ${path.basename(actualPath)}`).toBe(expectedPng.height); const diffPng = new PNG({ width: expectedPng.width, height: expectedPng.height }); const diffPixels = pixelmatch( expectedPng.data, actualPng.data, diffPng.data, expectedPng.width, expectedPng.height, { threshold: 0.1, includeAA: false, }, ); await fs.writeFile(diffPath, PNG.sync.write(diffPng)); return { diffPixels, totalPixels: expectedPng.width * expectedPng.height, diffRatio: diffPixels / (expectedPng.width * expectedPng.height), }; } test.describe('Admin Figma Pixel Matching', () => { for (const target of TARGETS) { test(`${target.name} @visual`, async ({ page }) => { await page.setViewportSize(target.viewport ?? VIEWPORT); const hasReference = await fs .access(target.reference) .then(() => true) .catch(() => false); test.skip(!hasReference, `Reference not found: ${target.reference}`); await ensureArtifactFolders(); await page.goto(target.route, { waitUntil: 'networkidle' }); await disableAnimations(page); if (target.waitForText) { await expect(page.getByText(target.waitForText).first()).toBeVisible(); } // Let fonts/layout settle after hydration and style injection. await page.waitForTimeout(300); const actualPath = path.join(ARTIFACT_ROOT, 'actual', `${target.name}.png`); const diffPath = path.join(ARTIFACT_ROOT, 'diff', `${target.name}.png`); await page.screenshot({ path: actualPath, fullPage: false, }); const { diffPixels, totalPixels, diffRatio } = await comparePng(actualPath, target.reference, diffPath); const pct = (diffRatio * 100).toFixed(2); const maxPct = (target.maxDiffRatio * 100).toFixed(2); expect( diffRatio, `Pixel diff too high for ${target.name}: ${pct}% (${diffPixels}/${totalPixels}) exceeds ${maxPct}%.` + `\nActual: ${actualPath}\nExpected: ${target.reference}\nDiff: ${diffPath}`, ).toBeLessThanOrEqual(target.maxDiffRatio); }); } });