nxtgauge-admin-solid/tests/e2e/admin-visual.spec.ts

213 lines
7 KiB
TypeScript

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);
});
}
});