Newer
Older
vmk-ui-kit / tests / figma-match.spec.js
/**
 * 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);
    });
  }
});