/**
* Figma frame comparison tests.
*
* For each matched demo/figma page, take a screenshot of the component matrix
* and compare it to the exported Figma PNG using pixelmatch.
*/
const { test, expect } = require("@playwright/test");
const fs = require("fs");
const path = require("path");
const PNG = require("pngjs").PNG;
const pixelmatch = require("pixelmatch").default || require("pixelmatch");
const sharp = require("sharp");
const BASELINES_DIR = path.join(__dirname, "..", "tests", "figma-baselines");
const DIFFS_DIR = path.join(__dirname, "..", "tests", "figma-diffs");
const COMPARISONS = [
{ name: "buttons", baseline: "buttons-baseline.png", selector: "#figma-buttons", viewport: { width: 1200, height: 2372 }, threshold: 0.15 },
{ name: "alerts", baseline: "alerts-baseline.png", selector: "#figma-alerts", viewport: { width: 1200, height: 1363 }, threshold: 0.20 },
{ name: "badges", baseline: "badges-baseline.png", selector: "#figma-badges", viewport: { width: 1200, height: 743 }, threshold: 0.20 },
{ name: "forms", baseline: "forms-baseline.png", selector: "#figma-forms", viewport: { width: 1200, height: 2648 }, threshold: 0.25 },
{ name: "breadcrumbs", baseline: "breadcrumbs-baseline.png", selector: "#figma-breadcrumbs", viewport: { width: 1200, height: 567 }, threshold: 0.30, background: "#000000" },
{ name: "divider", baseline: "divider-baseline.png", selector: "#figma-divider", viewport: { width: 1200, height: 120 }, threshold: 0.05, background: "#ffffff" },
{ name: "attached-file", baseline: "attached-file-baseline.png", selector: "#figma-attached-file", viewport: { width: 560, height: 2000 }, threshold: 0.20, background: "#000000" }
];
/**
* Resize an image to the target dimensions using contain (preserve aspect
* ratio) and pad any leftover space with a solid background color. The source
* is composited on top of the pad color so transparent/semi-transparent pixels
* in exported Figma frames are flattened consistently with the demo page.
*/
async function normalizeToCanvas(imagePath, targetWidth, targetHeight, padColor = "#ffffff") {
const { data, info } = await sharp(imagePath)
.ensureAlpha()
.resize(targetWidth, targetHeight, { fit: "inside" })
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const canvas = Buffer.alloc(targetWidth * targetHeight * 4);
// Parse pad color
const hex = padColor.replace("#", "");
const pr = parseInt(hex.substring(0, 2), 16);
const pg = parseInt(hex.substring(2, 4), 16);
const pb = parseInt(hex.substring(4, 6), 16);
// Fill canvas with pad color
for (let i = 0; i < targetWidth * targetHeight; i++) {
canvas[i * 4] = pr;
canvas[i * 4 + 1] = pg;
canvas[i * 4 + 2] = pb;
canvas[i * 4 + 3] = 255;
}
// Center source image and composite it over the pad color
const offsetX = Math.round((targetWidth - width) / 2);
const offsetY = Math.round((targetHeight - height) / 2);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = (y * width + x) * 4;
const dstIdx = ((y + offsetY) * targetWidth + (x + offsetX)) * 4;
const srcA = data[srcIdx + 3] / 255;
const invA = 1 - srcA;
canvas[dstIdx] = Math.round(pr * invA + data[srcIdx] * srcA);
canvas[dstIdx + 1] = Math.round(pg * invA + data[srcIdx + 1] * srcA);
canvas[dstIdx + 2] = Math.round(pb * invA + data[srcIdx + 2] * srcA);
canvas[dstIdx + 3] = 255;
}
}
return { data: canvas, width: targetWidth, height: targetHeight };
}
async function compareWithBaseline(screenshotPath, baselinePath, diffPath, targetWidth, targetHeight, background) {
const screenshot = await normalizeToCanvas(screenshotPath, targetWidth, targetHeight, background);
const baseline = await normalizeToCanvas(baselinePath, targetWidth, targetHeight, background);
const diff = Buffer.alloc(targetWidth * targetHeight * 4);
const mismatched = pixelmatch(
screenshot.data,
baseline.data,
diff,
targetWidth,
targetHeight,
{ threshold: 0.1, includeAA: false }
);
await sharp(diff, {
raw: { width: targetWidth, height: targetHeight, channels: 4 }
}).png().toFile(diffPath);
const totalPixels = targetWidth * targetHeight;
return { mismatched, ratio: mismatched / totalPixels };
}
test.describe("@figma", () => {
for (const comparison of COMPARISONS) {
test(`${comparison.name} matches Figma frame`, async ({ page }) => {
if (comparison.viewport) {
await page.setViewportSize(comparison.viewport);
}
await page.goto(`/figma/${comparison.name}.html`);
await page.waitForSelector(comparison.selector);
const screenshotDir = path.join(__dirname, "figma-screenshots");
fs.mkdirSync(screenshotDir, { recursive: true });
fs.mkdirSync(DIFFS_DIR, { recursive: true });
const screenshotPath = path.join(screenshotDir, `${comparison.name}.png`);
await page.locator(comparison.selector).screenshot({ path: screenshotPath });
const baselinePath = path.join(BASELINES_DIR, comparison.baseline);
if (!fs.existsSync(baselinePath)) {
test.info().annotations.push({ type: "skip", description: `Baseline ${comparison.baseline} not found` });
return;
}
const diffPath = path.join(DIFFS_DIR, `${comparison.name}-diff.png`);
const targetWidth = comparison.viewport?.width || 1200;
const targetHeight = comparison.viewport?.height || 1200;
const { ratio } = await compareWithBaseline(screenshotPath, baselinePath, diffPath, targetWidth, targetHeight, comparison.background);
test.info().annotations.push({
type: "info",
description: `Figma diff ratio: ${(ratio * 100).toFixed(2)}%`
});
expect(ratio).toBeLessThan(comparison.threshold || 0.05);
});
}
});