Традиционный визуальный регрессионный тест (Visual Regression Testing) в CI/CD базируется на попиксельном вычитании растровых изображений (инструменты pixelmatch, looks-same, reg-cli). Этот подход страдает от фундаментальной проблемы детерминизма: малейшие расхождения в механизмах сглаживания шрифтов (FreeType в Linux-контейнерах CI vs CoreText в macOS рабочих станций vs DirectWrite в Windows), различия в субпиксельном рендеринге графических процессоров или сдвиг контейнера на 1 пиксель вызывают лавинообразное превышение порога threshold и приводят к ложноположительным падениям тестов (flaky builds).
В производственных пайплайнах 2026 года визуальный контроль переходит на уровень когнитивного семантического аудита с использованием мультимодальных моделей (Vision LLM: GPT-4o Vision, Claude 3.7 Vision). Модель оценивает интерфейс не как матрицу несвязанных цветовых каналов RGBA, а как иерархическую систему компонентов, дизайн-токенов и визуальных весов.
Архитектура системы объединяет три источника истины:
itemSpacing, типографика) и отрендеренные растровые фреймы через эндпоинт /v1/images.Вектор проверки трансформируется из бинарного сравнения битовых карт в многоуровневое сопоставление визуального намерения дизайнера и фактической реализации в браузере.
[ Figma REST API ] [ Playwright Headless ]
|-- /v1/files/:key/nodes |-- page.setViewportSize()
| (Extract Design Tokens) |-- page.addStyleTag(freezeCSS)
`-- /v1/images (Render Frame) `-- page.screenshot()
\ /
\ /
v v
[ Figma Reference PNG ] [ Browser Render PNG ]
\ /
+-------------+----------------+
|
v
[ Vision LLM (Claude 3.7 / GPT-4o) ]
|
<System Prompt + Design Tokens Manifest>
|
v
[ Structured JSON Defect Report ]
|-- Severity: CRITICAL | WARNING
|-- Category: SPACING | TYPOGRAPHY | COLOR
|-- BoundingBox: [ymin, xmin, ymax, xmax]
`-- Remediation: Tailwind Class PatchНиже представлена production-ready реализация системы семантического визуального аудита на TypeScript, интегрирующая Playwright, Figma REST API и Vercel AI SDK.
import { chromium, type Browser, type Page } from 'playwright';
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import * as fs from 'fs/promises';
// 1. Схема строгого структурированного отчета о дефектах
export const VisualDefectSchema = z.object({
id: z.string().describe('Уникальный идентификатор дефекта'),
category: z.enum(['LAYOUT_SPACING', 'TYPOGRAPHY', 'COLOR_MISMATCH', 'ELEMENT_MISSING', 'CONTAINER_OVERFLOW']),
severity: z.enum(['CRITICAL', 'WARNING', 'INFO']),
description: z.string().describe('Инженерное описание несоответствия макету'),
expectedToken: z.string().describe('Ожидаемое значение токена или параметра из Figma'),
actualValue: z.string().describe('Фактическое значение, обнаруженное в рендере'),
remediationClass: z.string().describe('Рекомендуемый класс Tailwind CSS для устранения дефекта'),
boundingBox: z.object({
ymin: z.number().describe('Верхняя граница в процентах [0-100]'),
xmin: z.number().describe('Левая граница в процентах [0-100]'),
ymax: z.number().describe('Нижняя граница в процентах [0-100]'),
xmax: z.number().describe('Правая граница в процентах [0-100]'),
}),
});
export const AuditReportSchema = z.object({
componentName: z.string(),
isPixelPerfect: z.boolean(),
matchScore: z.number().min(0).max(100).describe('Оценка визуального соответствия от 0 до 100%'),
defects: z.array(VisualDefectSchema),
architecturalSummary: z.string().describe('Краткое резюме для Pull Request'),
});
export type VisualAuditReport = z.infer<typeof AuditReportSchema>;
interface FigmaNodeTokens {
layoutMode?: string;
itemSpacing?: number;
paddingLeft?: number;
paddingRight?: number;
paddingTop?: number;
paddingBottom?: number;
fontFamily?: string;
fontSize?: number;
lineHeightPx?: number;
backgroundColorHex?: string;
}
/**
* Клиент взаимодействия с Figma REST API
*/
export class FigmaClient {
private readonly baseUrl = 'https://api.figma.com/v1';
constructor(private readonly apiToken: string) {}
/**
* Экспорт растрового эталона конкретного фрейма
*/
async exportFrameImage(fileKey: string, nodeId: string): Promise<Buffer> {
const url = `${this.baseUrl}/images/${fileKey}?ids=${encodeURIComponent(nodeId)}&format=png&scale=2`;
const response = await fetch(url, {
headers: { 'X-Figma-Token': this.apiToken },
});
if (!response.ok) {
throw new Error(`Figma Image Export API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const imageUrl = data.images[nodeId];
if (!imageUrl) {
throw new Error(`Node ${nodeId} was not found in Figma export response`);
}
const imageResponse = await fetch(imageUrl);
const arrayBuffer = await imageResponse.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* Извлечение проектных токенов из дерева узла Figma
*/
async extractNodeTokens(fileKey: string, nodeId: string): Promise<FigmaNodeTokens> {
const url = `${this.baseUrl}/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`;
const response = await fetch(url, {
headers: { 'X-Figma-Token': this.apiToken },
});
if (!response.ok) {
throw new Error(`Figma Nodes API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const node = data.nodes[nodeId]?.document;
if (!node) {
throw new Error(`Node ${nodeId} document structure not found`);
}
return {
layoutMode: node.layoutMode,
itemSpacing: node.itemSpacing,
paddingLeft: node.paddingLeft,
paddingRight: node.paddingRight,
paddingTop: node.paddingTop,
paddingBottom: node.paddingBottom,
fontFamily: node.style?.fontFamily,
fontSize: node.style?.fontSize,
lineHeightPx: node.style?.lineHeightPx,
backgroundColorHex: node.backgroundColor
? this.rgbaToHex(node.backgroundColor)
: undefined,
};
}
private rgbaToHex(c: { r: number; g: number; b: number; a: number }): string {
const toHex = (n: number) => Math.round(n * 255).toString(16).padStart(2, '0');
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
}
}
/**
* Захват детерминированного скриншота в браузере через Playwright
*/
export async function captureBrowserComponent(params: {
url: string;
selector: string;
viewportWidth: number;
viewportHeight: number;
}): Promise<Buffer> {
const browser: Browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: params.viewportWidth, height: params.viewportHeight },
deviceScaleFactor: 2, // Эквивалент scale=2 в экспорте Figma
});
const page: Page = await context.newPage();
try {
await page.goto(params.url, { waitUntil: 'networkidle' });
// Отключение анимаций и переходов для обеспечения стабильности рендера
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
caret-color: transparent !important;
}
`,
});
// Ожидание полной загрузки всех веб-шрифтов через Document API
await page.evaluate(async () => {
await document.fonts.ready;
});
const targetLocator = page.locator(params.selector);
await targetLocator.waitFor({ state: 'visible', timeout: 5000 });
const screenshotBuffer = await targetLocator.screenshot({
type: 'png',
omitBackground: false,
});
return screenshotBuffer;
} finally {
await context.close();
await browser.close();
}
}
/**
* Главный контур когнитивного аудита через Vision LLM
*/
export async function runSemanticVisualAudit(params: {
componentName: string;
figmaImage: Buffer;
browserImage: Buffer;
designTokens: FigmaNodeTokens;
}): Promise<VisualAuditReport> {
const figmaBase64 = params.figmaImage.toString('base64');
const browserBase64 = params.browserImage.toString('base64');
const systemInstruction = `
Вы — ведущий инженер по визуальному тестированию дизайн-систем.
Ваша задача — сопоставить эталонный макет из Figma и фактический рендер компонента в браузере.
Вам также предоставлены официальные дизайн-токены узла Figma.
Правила анализа:
1. Игнорируйте шум субпиксельного антиалиасинга шрифтов и естественные артефакты растрирования.
2. Фокусируйтесь на семантических отклонениях:
- Несоответствие отступов (padding, gap, margins).
- Ошибки типографики (font-size, font-weight, line-height).
- Несоответствие цветов (фона, границ, текста) токенам палитры.
- Смещение элементов сетки и поломка выравнивания.
3. Для каждого дефекта укажите нормализованный Bounding Box [0-100] на фактическом скриншоте браузера и предложите точный класс Tailwind CSS v4.
4. Вычислите matchScore: 100 - идеальное соответствие, <85 - блокирующий дефект.
`.trim();
const userPrompt = `
Компонент: ${params.componentName}
Эталонные дизайн-токены Figma:
${JSON.stringify(params.designTokens, null, 2)}
Изображение 1: Эталонный макет из Figma (Reference).
Изображение 2: Фактический рендер из Playwright (Actual).
Проведите детальный аудит и верните строго структурированный JSON.
`.trim();
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: AuditReportSchema,
messages: [
{ role: 'system', content: systemInstruction },
{
role: 'user',
content: [
{ type: 'text', text: userPrompt },
{ type: 'image', image: `data:image/png;base64,${figmaBase64}` },
{ type: 'image', image: `data:image/png;base64,${browserBase64}` },
],
},
],
});
return object;
}Если Playwright выполняет скриншот со стандартным deviceScaleFactor: 1, а экспорт из Figma REST API запрашивается с параметром scale=2 (для Retina-экранов), Vision-модель будет анализировать изображения с двукратной разницей в разрешении. Это приводит к ложным замечаниям относительно размытия границ или толщины обводок (border-width). Коэффициенты deviceScaleFactor в браузере и scale в API Figma должны быть строго согласованы.
Захват скриншота сразу после наступления события DOMContentLoaded или load до того, как загрузчик шрифтов завершил операцию (document.fonts.ready). Браузер отображает резервный системный шрифт (Times New Roman / Arial), что провоцирует ошибочный отчет Vision LLM о тотальном сломе типографики. Обязательно вызывайте await page.evaluate(() => document.fonts.ready) перед каждым снимком.
Попытка скормить модели полностраничный скриншот лендинга размером 1920x8000 пикселей. Модель автоматически выполнит даунскейлинг изображения до ограничений тайлов (например, 2048x2048 в OpenAI Vision), что приведет к потере мелких деталей (отступы в 4px, субпиксельные бордеры) и избыточному расходу токенов. Аудит необходимо проводить атомарно: изолированный компонент против изолированного фрейма Figma.
Мерцающий текстовый курсор (caret-color), активные анимации загрузки (скелетоны, спиннеры) или динамические даты в интерфейсе генерируют шум между прогонами тестов. Внедряйте глобальный CSS с отключением всех переходов transition: none !important; animation: none !important; caret-color: transparent !important; в контекст Playwright.
Запрос к Figma REST API для выгрузки эталонного растра узла и извлечения дизайн-токенов в формате JSON
Playwright инициализирует headless-сессию, мокирует системное время, блокирует анимации и делает скриншот компонента
Vision LLM сопоставляет растры с учетом дизайн-токенов и отсекает артефакты сглаживания шрифтов
Генерация валидированного Zod-отчета с координатами расхождений и готовыми классами Tailwind CSS
Автоматическое комментирование PR на GitHub с подсветкой дефектов или блокировка пайплайна при matchScore менее 85