uiux-pixelmatch

star 0

Visual regression testing with pixel-level comparison against Penpot design exports

kobogithub By kobogithub schedule Updated 2/20/2026

name: uiux-pixelmatch description: Visual regression testing with pixel-level comparison against Penpot design exports version: 1.0.0 tags: - uiux - visual-testing - pixelmatch - regression - penpot

uiux-pixelmatch

Comparacion visual pixel-a-pixel entre screenshots del sitio web y exports de Penpot usando Pixelmatch para detectar regresiones visuales y desviaciones del diseno.

Overview

Pixelmatch es una libreria minimalista de comparacion de imagenes que:

  • Compara dos imagenes PNG pixel a pixel
  • Genera una imagen diff resaltando las diferencias
  • Retorna el numero de pixeles diferentes
  • Soporta tolerancia anti-aliasing y thresholds configurables
  • Es extremadamente rapida (~150ms para imagenes 1920x1080)

El workflow principal es: Penpot Export → Screenshot Web → Pixelmatch Diff → Reporte.

Instalacion

# Node.js (para scripts de comparacion)
npm install pixelmatch pngjs sharp

# Dependencias opcionales para manipulacion de imagenes
npm install jimp  # alternativa a sharp

Script de Comparacion Visual

Script basico: scripts/visual-compare.js

const fs = require('fs');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');

/**
 * Compara dos imagenes PNG y genera un diff.
 * @param {string} referencePath - Ruta a la imagen de referencia (Penpot export)
 * @param {string} actualPath - Ruta a la imagen actual (screenshot web)
 * @param {string} diffPath - Ruta donde guardar la imagen diff
 * @param {object} options - Opciones de pixelmatch
 * @returns {object} Resultado de la comparacion
 */
function compareImages(referencePath, actualPath, diffPath, options = {}) {
  const defaultOptions = {
    threshold: 0.1,          // Tolerancia de color (0 = exacto, 1 = muy tolerante)
    includeAA: false,        // Ignorar diferencias de anti-aliasing
    alpha: 0.1,              // Opacidad del fondo en la imagen diff
    aaColor: [255, 255, 0],  // Color para diferencias de anti-aliasing (amarillo)
    diffColor: [255, 0, 0],  // Color para diferencias reales (rojo)
    diffColorAlt: null,      // Color alternativo para diff
    diffMask: false,         // Solo mostrar diff sin fondo
  };

  const opts = { ...defaultOptions, ...options };

  const reference = PNG.sync.read(fs.readFileSync(referencePath));
  const actual = PNG.sync.read(fs.readFileSync(actualPath));

  // Las imagenes deben tener el mismo tamano
  if (reference.width !== actual.width || reference.height !== actual.height) {
    console.error(`Size mismatch: reference=${reference.width}x${reference.height}, actual=${actual.width}x${actual.height}`);
    console.error('Resize images to match before comparing.');
    process.exit(1);
  }

  const { width, height } = reference;
  const diff = new PNG({ width, height });

  const numDiffPixels = pixelmatch(
    reference.data,
    actual.data,
    diff.data,
    width,
    height,
    opts
  );

  // Guardar imagen diff
  fs.writeFileSync(diffPath, PNG.sync.write(diff));

  const totalPixels = width * height;
  const diffPercentage = ((numDiffPixels / totalPixels) * 100).toFixed(2);

  return {
    totalPixels,
    diffPixels: numDiffPixels,
    diffPercentage: parseFloat(diffPercentage),
    width,
    height,
    passed: parseFloat(diffPercentage) <= (opts.passThreshold || 2.0),
  };
}

// CLI usage
if (require.main === module) {
  const [,, reference, actual, diff, threshold] = process.argv;

  if (!reference || !actual || !diff) {
    console.log('Usage: node visual-compare.js <reference.png> <actual.png> <diff.png> [threshold]');
    process.exit(1);
  }

  const result = compareImages(reference, actual, diff, {
    passThreshold: parseFloat(threshold || '2.0'),
  });

  console.log(JSON.stringify(result, null, 2));
  process.exit(result.passed ? 0 : 1);
}

module.exports = { compareImages };

Script avanzado: scripts/visual-compare-batch.js

const fs = require('fs');
const path = require('path');
const { compareImages } = require('./visual-compare');

/**
 * Compara todos los screenshots de un directorio contra sus referencias.
 * Estructura esperada:
 *   tests/references/  -> Imagenes de Penpot (referencia)
 *   tests/screenshots/ -> Screenshots actuales (Playwright)
 *   tests/diffs/       -> Imagenes diff (generadas)
 */
function batchCompare(config = {}) {
  const {
    referencesDir = 'tests/references',
    screenshotsDir = 'tests/screenshots',
    diffsDir = 'tests/diffs',
    threshold = 2.0,        // Porcentaje maximo aceptable de diferencia
    pixelThreshold = 0.1,   // Tolerancia de color por pixel
  } = config;

  // Crear directorio de diffs si no existe
  if (!fs.existsSync(diffsDir)) {
    fs.mkdirSync(diffsDir, { recursive: true });
  }

  const references = fs.readdirSync(referencesDir)
    .filter(f => f.endsWith('.png'));

  const results = [];

  for (const filename of references) {
    const referencePath = path.join(referencesDir, filename);
    const actualPath = path.join(screenshotsDir, filename);
    const diffPath = path.join(diffsDir, `diff-${filename}`);

    if (!fs.existsSync(actualPath)) {
      results.push({
        filename,
        status: 'MISSING',
        message: `Screenshot not found: ${actualPath}`,
      });
      continue;
    }

    try {
      const result = compareImages(referencePath, actualPath, diffPath, {
        threshold: pixelThreshold,
        passThreshold: threshold,
      });

      results.push({
        filename,
        status: result.passed ? 'PASS' : 'FAIL',
        diffPercentage: result.diffPercentage,
        diffPixels: result.diffPixels,
        totalPixels: result.totalPixels,
        diffImage: diffPath,
      });
    } catch (error) {
      results.push({
        filename,
        status: 'ERROR',
        message: error.message,
      });
    }
  }

  return results;
}

// CLI usage
if (require.main === module) {
  const results = batchCompare();

  console.log('\n=== Visual Regression Report ===\n');

  let passed = 0;
  let failed = 0;
  let errors = 0;

  for (const r of results) {
    const icon = r.status === 'PASS' ? '✅' : r.status === 'FAIL' ? '❌' : '⚠️';
    console.log(`${icon} ${r.filename}: ${r.status}`);
    if (r.diffPercentage !== undefined) {
      console.log(`   Diff: ${r.diffPercentage}% (${r.diffPixels}/${r.totalPixels} pixels)`);
    }
    if (r.message) {
      console.log(`   ${r.message}`);
    }

    if (r.status === 'PASS') passed++;
    else if (r.status === 'FAIL') failed++;
    else errors++;
  }

  console.log(`\nTotal: ${results.length} | Passed: ${passed} | Failed: ${failed} | Errors: ${errors}`);
  process.exit(failed > 0 || errors > 0 ? 1 : 0);
}

module.exports = { batchCompare };

Thresholds Recomendados

Por tipo de componente

Componente Threshold Razon
Iconos/Logos 0.5% Deben ser pixel-perfect
Tipografia 1.0% Font rendering varia entre OS
Layout completo 2.0% Variaciones menores aceptables
Imagenes/Fotos 3.0% Compresion puede variar
Animaciones (frame) 5.0% Timing de captura variable

Por viewport

Viewport Threshold Razon
Desktop 1440px 1.5% Mas espacio, menos variacion
Tablet 768px 2.0% Layouts intermedios
Mobile 375px 2.5% Mas variacion por espacio reducido

Opciones de pixelmatch por caso

// Comparacion estricta (iconos, logos)
const strictOptions = {
  threshold: 0.05,
  includeAA: true,
};

// Comparacion normal (layouts, paginas)
const normalOptions = {
  threshold: 0.1,
  includeAA: false,
};

// Comparacion tolerante (contenido dinamico)
const tolerantOptions = {
  threshold: 0.3,
  includeAA: false,
};

Workflow con Penpot MCP

Paso 1: Exportar referencia desde Penpot

# Usando el MCP de Penpot:
# 1. Listar proyectos -> encontrar el proyecto correcto
# -> list_projects()

# 2. Listar archivos del proyecto
# -> list_files(project_id="...")

# 3. Obtener la pagina con los frames
# -> get_page(file_id="...", page_id="...")

# 4. Exportar el frame como PNG a 2x para retina
# -> export_frame(file_id="...", frame_id="...", format="png", scale=2)

# 5. Guardar en tests/references/<pagename>-<viewport>.png
# Ejemplo: tests/references/dashboard-desktop.png

Paso 2: Capturar screenshot con Playwright MCP

# Usando el MCP de Playwright:
# 1. Navegar a la pagina
# -> browser_navigate(url="http://localhost:4321/dashboard")

# 2. Configurar viewport
# -> browser_resize(width=1440, height=900)

# 3. Esperar a que cargue completamente
# -> browser_snapshot()  # Verificar que el contenido esta visible

# 4. Capturar screenshot
# -> browser_screenshot()
# -> Guardar como tests/screenshots/dashboard-desktop.png

Paso 3: Comparar con Pixelmatch

# Ejecutar comparacion
node scripts/visual-compare.js \
  tests/references/dashboard-desktop.png \
  tests/screenshots/dashboard-desktop.png \
  tests/diffs/dashboard-desktop-diff.png \
  2.0

# Output:
# {
#   "totalPixels": 1296000,
#   "diffPixels": 2592,
#   "diffPercentage": 0.2,
#   "passed": true
# }

Paso 4: Batch comparison (todas las paginas)

node scripts/visual-compare-batch.js

# Output:
# === Visual Regression Report ===
# ✅ dashboard-desktop.png: PASS (0.2%)
# ✅ login-desktop.png: PASS (0.1%)
# ❌ settings-mobile.png: FAIL (3.5%)
# ⚠️ checkout-tablet.png: MISSING

Normalizacion de Imagenes

Redimensionar imagenes para que coincidan

const sharp = require('sharp');

/**
 * Normaliza dos imagenes al mismo tamano antes de comparar.
 * Usa las dimensiones de la imagen de referencia (Penpot).
 */
async function normalizeImages(referencePath, actualPath) {
  const refMeta = await sharp(referencePath).metadata();
  const { width, height } = refMeta;

  // Redimensionar actual al tamano de referencia
  const normalizedPath = actualPath.replace('.png', '-normalized.png');
  await sharp(actualPath)
    .resize(width, height, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } })
    .toFile(normalizedPath);

  return normalizedPath;
}

Recortar areas especificas

/**
 * Recortar un area especifica para comparacion focalizada.
 * Util cuando solo quieres comparar un componente, no la pagina completa.
 */
async function cropRegion(imagePath, region) {
  const { left, top, width, height } = region;
  const croppedPath = imagePath.replace('.png', `-crop-${left}-${top}.png`);

  await sharp(imagePath)
    .extract({ left, top, width, height })
    .toFile(croppedPath);

  return croppedPath;
}

// Ejemplo: comparar solo el header
// const refCropped = await cropRegion('tests/references/dashboard.png', { left: 0, top: 0, width: 1440, height: 80 });
// const actCropped = await cropRegion('tests/screenshots/dashboard.png', { left: 0, top: 0, width: 1440, height: 80 });
// const result = compareImages(refCropped, actCropped, 'tests/diffs/header-diff.png');

Reporte para Beads

Formato de reporte estandar

bd comments add task-id "[UI/UX Tester] Visual Fidelity Report:
Page: /dashboard
Penpot frame: Dashboard-v2.3
Viewport: 1440x900 (Desktop)

Overall match: 98.5% ✅ (threshold: 98%)

Region breakdown:
  - Header:  99.8% ✅
  - Sidebar: 96.5% ❌ (icon colors differ)
  - Content: 99.2% ✅
  - Footer:  100%  ✅

Diff images: tests/diffs/dashboard-desktop-diff.png
Action: Create bug for sidebar icon mismatch"

Formato de reporte para bugs

bd create "Visual: Sidebar icon color mismatch vs Penpot (#1a1a2e vs #1a1a3e)" \
  -t bug -p 1 -l uiux,visual,frontend \
  --assignee knowledge-4yh \
  -d "Pixelmatch: 3.5% diff en sidebar.
Reference (Penpot): tests/references/dashboard-desktop.png
Actual (Web): tests/screenshots/dashboard-desktop.png
Diff: tests/diffs/dashboard-desktop-diff.png
Region: sidebar area (x:0, y:80, w:250, h:820)
Expected: icons in #1a1a2e
Actual: icons in #1a1a3e"

Design Tokens Validation

Comparar tokens de Penpot con CSS

/**
 * Valida que los design tokens de Penpot coincidan con los CSS custom properties.
 * Usa Penpot MCP para obtener tokens y Playwright MCP para leer CSS.
 */
async function validateDesignTokens(penpotTokens, cssVariables) {
  const mismatches = [];

  // Comparar colores
  for (const [name, penpotValue] of Object.entries(penpotTokens.colors)) {
    const cssValue = cssVariables[`--color-${name}`];
    if (cssValue && cssValue.toLowerCase() !== penpotValue.toLowerCase()) {
      mismatches.push({
        type: 'color',
        token: name,
        penpot: penpotValue,
        css: cssValue,
      });
    }
  }

  // Comparar tipografia
  for (const [name, penpotValue] of Object.entries(penpotTokens.typography)) {
    const cssFontSize = cssVariables[`--font-size-${name}`];
    if (cssFontSize && cssFontSize !== penpotValue.fontSize) {
      mismatches.push({
        type: 'typography',
        token: name,
        penpot: penpotValue.fontSize,
        css: cssFontSize,
      });
    }
  }

  return mismatches;
}

Mejores Practicas

DO

  • Normalizar imagenes al mismo tamano antes de comparar
  • Usar includeAA: false para ignorar diferencias de anti-aliasing entre OS
  • Guardar referencias de Penpot con nombre descriptivo: {page}-{viewport}.png
  • Actualizar referencias cuando el diseno cambie intencionalmente
  • Comparar por regiones para localizar problemas especificos
  • Documentar thresholds por componente en el proyecto

DON'T

  • Comparar imagenes de tamanos diferentes (pixelmatch fallara)
  • Usar threshold muy bajo (< 0.05) para paginas completas — demasiados false positives
  • Comparar screenshots con contenido dinamico (fechas, avatares) sin mascaras
  • Olvidar actualizar las referencias cuando el diseno cambia
  • Ignorar diferencias de anti-aliasing sin investigar si es real

Recursos

Install via CLI
npx skills add https://github.com/kobogithub/knowledge --skill uiux-pixelmatch
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator