Menguji antarmuka visual (UI) bergaya retro seperti tampilan monokrom 1-bit ala Macintosh Classic (resolusi 512×342) atau game 8-bit memiliki tantangan unik. Berbeda dengan web modern yang memanfaatkan subpixel rendering dan vector smoothing, UI retro bergantung pada penempatan piksel biner yang presisi. Pergeseran subpixel sebesar 0.5px atau variasi anti-aliasing GPU akan menyebabkan snapshot diff gagal total.

Artikel ini membahas arsitektur visual regression testing deterministik untuk UI retro menggunakan harness mock framebuffer berbasis node-canvas, eliminasi flaky test, masking area dinamis, serta integrasi pada CI pipeline.

Penyebab Flaky Test pada Render UI Retro

Ketidakcocokan snapshot pada pipeline CI umumnya dipicu oleh tiga faktor non-deterministik:

  • Font Smoothing dan Anti-Aliasing: FreeType di Linux (CI) dan CoreText di macOS merender glyph secara berbeda pada tingkat subpixel jika font smoothing aktif.
  • Asynchronous Font Loading: Render buffer dieksekusi sebelum custom bitmap font (misal format .woff atau .bdf) selesai di-parsing oleh runtime, menyebabkan fallback ke system font.
  • Interpolasi Skala: Browser atau renderer secara default menggunakan interpolasi bilinear saat mengubah ukuran framebuffer retro ke layar resolusi tinggi, mengaburkan batas tegas antar piksel.

Strategi Determinisme Render

Sebelum membandingkan byte buffer, lingkungan render harus dipaksa berada dalam kondisi deterministik.

1. Menonaktifkan Font Smoothing dan Image Smoothing

Pada CSS atau Canvas context, matikan seluruh algoritma interpolasi piksel:

/* Global CSS untuk web-based retro render */
canvas, .retro-ui {
  image-rendering: pixelated;
  font-smooth: never;
  -webkit-font-smoothing: none;
  text-rendering: geometricPrecision;
}

2. Memastikan Ketersediaan Web Font

Gunakan API document.fonts.ready sebelum snapshot diambil untuk memastikan font bitmap lokal telah terpasang ke dalam memory map engine.

Implementasi Test Harness: Mock Framebuffer & Diffing

Daripada menjalankan browser penuh via Playwright yang memakan resource dan rentan terhadap perbedaan driver GPU di CI, pendekatan paling deterministik untuk UI retro resolusi rendah adalah merender langsung ke in-memory pixel buffer menggunakan canvas di Node.js.

Berikut adalah implementasi harness pengujian yang memvalidasi output render terhadap snapshot baseline:

import { createCanvas, registerFont } from 'canvas';
import fs from 'node:fs';
import path from 'node:path';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';

// Daftarkan bitmap font tetap
registerFont('./fonts/Chicago-12.ttf', { family: 'RetroMac' });

const WIDTH = 512;
const HEIGHT = 342;

export function renderRetroWindow(state) {
  const canvas = createCanvas(WIDTH, HEIGHT);
  const ctx = canvas.getContext('2d');

  // Matikan smoothing pada software context
  ctx.imageSmoothingEnabled = false;

  // Clear background (White 1-bit)
  ctx.fillStyle = '#FFFFFF';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  // Title Bar dengan garis horizontal khas Mac OS
  ctx.fillStyle = '#000000';
  for (let y = 4; y < 18; y += 2) {
    ctx.fillRect(4, y, WIDTH - 8, 1);
  }

  // Window Title box
  ctx.fillStyle = '#FFFFFF';
  ctx.fillRect(40, 2, 120, 16);
  ctx.fillStyle = '#000000';
  ctx.font = '12px RetroMac';
  ctx.fillText(state.title, 45, 14);

  // Border utama
  ctx.strokeRect(1, 1, WIDTH - 2, HEIGHT - 2);

  // Area Dinamis: Timestamp / Status bar
  ctx.fillText(`Clock: ${state.clock}`, 10, HEIGHT - 10);

  return ctx.getImageData(0, 0, WIDTH, HEIGHT);
}

Masking Elemen Dinamis pada Framebuffer

Komponen dinamis seperti jam sistem atau ID transaksi harus di-masking sebelum fungsi perbandingan dijalankan agar tidak merusak validasi snapshot.

export function maskDynamicArea(imageData, x, y, width, height) {
  const data = imageData.data;
  for (let row = y; row < y + height; row++) {
    for (let col = x; col < x + width; col++) {
      const idx = (row * imageData.width + col) * 4;
      // Ubah area mask menjadi hitam absolut (0,0,0,255) pada kedua buffer
      data[idx] = 0;
      data[idx + 1] = 0;
      data[idx + 2] = 0;
      data[idx + 3] = 255;
    }
  }
}

Assertion Test dengan Pixelmatch

Tes membandingkan buffer hasil render dengan file baseline PNG yang telah disimpan di repositori.

import assert from 'node:assert';
import { renderRetroWindow, maskDynamicArea } from './renderer.js';

function runSnapshotTest() {
  const current = renderRetroWindow({
    title: 'System Preferences',
    clock: '12:45:00'
  });

  // Mask area jam sistem di (10, 320) dengan ukuran 120x20
  maskDynamicArea(current, 10, 320, 120, 20);

  const baselinePath = './snapshots/window_baseline.png';
  const baselineData = PNG.sync.read(fs.readFileSync(baselinePath));

  const diff = new PNG({ width: WIDTH, height: HEIGHT });
  const mismatchedPixels = pixelmatch(
    current.data,
    baselineData.data,
    diff.data,
    WIDTH,
    HEIGHT,
    { threshold: 0 } // Strict: 0 toleransi untuk 1-bit palette
  );

  if (mismatchedPixels > 0) {
    fs.writeFileSync('./snapshots/diff.png', PNG.sync.write(diff));
    throw new Error(`Visual regression failed: ${mismatchedPixels} pixels mismatched.`);
  }
}

runSnapshotTest();
console.log('Visual regression test passed.');

Integrasi pada CI Pipeline

Untuk memastikan rendering font pada software canvas sepenuhnya konsisten antara mesin lokal dan runner Linux, jalankan tes di dalam Docker container atau konfigurasikan GitHub Actions dengan font dependencies eksplisit:

name: Visual Regression
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Install System Font Engine Dependencies
        run: sudo apt-get update && sudo apt-get install -y libcairo2-dev libpango1.0-dev libgif-dev librsvg2-dev
      - run: npm ci
      - name: Run Snapshot Tests
        run: node test/visual-regression.test.js
      - name: Upload Diff Artifact
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diff
          path: snapshots/diff.png
Catatan: Parameter threshold: 0 pada pixelmatch hanya direkomendasikan ketika antarmuka membatasi palet warna secara ketat (misalnya monokrom murni). Jika antarmuka retro menggunakan dithering kompleks (seperti Floyd-Steinberg), naikkan threshold ke 0.05 guna mengakomodasi variasi rounding pada floating point math lintas arsitektur CPU (x86_64 vs ARM64).