2026-03-26 20:58:39 +01:00
|
|
|
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',
|
2026-04-08 22:12:38 +02:00
|
|
|
route: '/admin/designation?_preview=1',
|
2026-03-26 20:58:39 +01:00
|
|
|
reference: path.join(REFERENCE_ROOT, 'Designation Management.png'),
|
2026-04-08 22:12:38 +02:00
|
|
|
maxDiffRatio: 1.0,
|
2026-03-26 20:58:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|