fase(20): visual regression refactor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
157
dist/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.js
vendored
Normal file
157
dist/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.js
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.VisualRegressionAdapter = exports.DEFAULT_VISUAL_CONFIG = void 0;
|
||||
/**
|
||||
* VisualRegressionAdapter — wraps screenshot comparison logic.
|
||||
* Uses IStorageProvider for persisting diff images instead of direct fs calls.
|
||||
*/
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const path = __importStar(require("path"));
|
||||
const VisualComparison_1 = require("../../domain/entities/VisualComparison");
|
||||
const ComparisonStatus_1 = require("../../domain/value-objects/ComparisonStatus");
|
||||
exports.DEFAULT_VISUAL_CONFIG = {
|
||||
enabled: true,
|
||||
threshold: 0.001,
|
||||
screenshotFullPage: false,
|
||||
ignoreSelectors: [],
|
||||
};
|
||||
async function compareScreenshots(baselinePath, currentPath, threshold) {
|
||||
const sharp = (await Promise.resolve().then(() => __importStar(require('sharp')))).default;
|
||||
const pixelmatch = (await Promise.resolve().then(() => __importStar(require('pixelmatch')))).default;
|
||||
const [baselineRaw, currentRaw] = await Promise.all([
|
||||
sharp(baselinePath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
|
||||
sharp(currentPath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
|
||||
]);
|
||||
const { width, height } = baselineRaw.info;
|
||||
const diffBuffer = Buffer.alloc(width * height * 4);
|
||||
const diffPixels = pixelmatch(baselineRaw.data, currentRaw.data, diffBuffer, width, height, { threshold });
|
||||
const totalPixels = width * height;
|
||||
const diffPercent = totalPixels > 0 ? diffPixels / totalPixels : 0;
|
||||
// Encode diff as PNG
|
||||
const pngBuffer = await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
return { diffPixels, diffPercent, diffBuffer: pngBuffer, width, height };
|
||||
}
|
||||
class VisualRegressionAdapter {
|
||||
constructor(storage, baselineRepo, comparisonRepo, eventBus, config = {}) {
|
||||
this.storage = storage;
|
||||
this.baselineRepo = baselineRepo;
|
||||
this.comparisonRepo = comparisonRepo;
|
||||
this.eventBus = eventBus;
|
||||
this.config = { ...exports.DEFAULT_VISUAL_CONFIG, ...config };
|
||||
}
|
||||
async processScreenshot(screenshotPath, state, sessionId, actionTrace) {
|
||||
if (!this.config.enabled)
|
||||
return null;
|
||||
const comparisonId = crypto.randomUUID();
|
||||
const baseline = await this.baselineRepo.findByStateId(state.id);
|
||||
if (!baseline) {
|
||||
const comparison = VisualComparison_1.VisualComparison.create({
|
||||
sessionId,
|
||||
stateId: state.id,
|
||||
baselineId: null,
|
||||
currentScreenshotPath: screenshotPath,
|
||||
diffScreenshotPath: null,
|
||||
diffPixels: null,
|
||||
diffPercent: null,
|
||||
status: ComparisonStatus_1.ComparisonStatus.newState(),
|
||||
});
|
||||
await this.comparisonRepo.save(comparison);
|
||||
return null;
|
||||
}
|
||||
let diffPixels = 0;
|
||||
let diffPercent = 0;
|
||||
let diffScreenshotPath = null;
|
||||
try {
|
||||
const result = await compareScreenshots(baseline.screenshotPath, screenshotPath, this.config.threshold);
|
||||
diffPixels = result.diffPixels;
|
||||
diffPercent = result.diffPercent;
|
||||
if (diffPixels > 0) {
|
||||
const diffRelativePath = path.join('visual', comparisonId, 'diff.png');
|
||||
diffScreenshotPath = await this.storage.save(diffRelativePath, result.diffBuffer);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
const hasFailed = diffPercent > this.config.threshold;
|
||||
const status = hasFailed ? ComparisonStatus_1.ComparisonStatus.failed() : ComparisonStatus_1.ComparisonStatus.passed();
|
||||
const comparison = VisualComparison_1.VisualComparison.create({
|
||||
sessionId,
|
||||
stateId: state.id,
|
||||
baselineId: baseline.id.toString(),
|
||||
currentScreenshotPath: screenshotPath,
|
||||
diffScreenshotPath,
|
||||
diffPixels,
|
||||
diffPercent,
|
||||
status,
|
||||
});
|
||||
await this.comparisonRepo.save(comparison);
|
||||
// Publish domain events
|
||||
for (const event of comparison.domainEvents) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
comparison.clearEvents();
|
||||
if (!hasFailed)
|
||||
return null;
|
||||
const pct = diffPercent * 100;
|
||||
let severity;
|
||||
if (pct > 15)
|
||||
severity = 'critical';
|
||||
else if (pct > 5)
|
||||
severity = 'high';
|
||||
else if (pct > 1)
|
||||
severity = 'medium';
|
||||
else
|
||||
severity = 'low';
|
||||
const anomaly = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'visual_regression',
|
||||
severity,
|
||||
observationId: state.id,
|
||||
actionTrace,
|
||||
description: `Visual regression detected: ${pct.toFixed(2)}% of pixels changed`,
|
||||
evidence: {
|
||||
screenshotPath: diffScreenshotPath ?? screenshotPath,
|
||||
rawErrors: [`Diff: ${diffPixels} pixels (${pct.toFixed(2)}%)`],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return anomaly;
|
||||
}
|
||||
}
|
||||
exports.VisualRegressionAdapter = VisualRegressionAdapter;
|
||||
82
dist/modules/visual-regression/infrastructure/http/VisualRegressionController.js
vendored
Normal file
82
dist/modules/visual-regression/infrastructure/http/VisualRegressionController.js
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createVisualRegressionRouter = createVisualRegressionRouter;
|
||||
const express_1 = require("express");
|
||||
const Result_1 = require("../../../../shared/domain/Result");
|
||||
function createVisualRegressionRouter(deps) {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/visual/comparisons
|
||||
router.get('/comparisons', async (req, res, next) => {
|
||||
try {
|
||||
const sessionId = req.query['sessionId'];
|
||||
const status = req.query['status'];
|
||||
const result = await deps.listComparisons.execute({ sessionId, status });
|
||||
if ((0, Result_1.isErr)(result)) {
|
||||
res.status(500).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
const comparisons = result.value.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
session_id: c.sessionId,
|
||||
state_id: c.stateId,
|
||||
baseline_id: c.baselineId,
|
||||
current_screenshot_path: c.currentScreenshotPath,
|
||||
diff_screenshot_path: c.diffScreenshotPath,
|
||||
diff_pixels: c.diffPixels,
|
||||
diff_percent: c.diffPercent,
|
||||
status: c.status.value,
|
||||
created_at: c.createdAt.getTime(),
|
||||
}));
|
||||
res.json(comparisons);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/visual/baselines/:comparisonId/approve
|
||||
router.post('/baselines/:comparisonId/approve', async (req, res, next) => {
|
||||
try {
|
||||
const comparisonId = String(req.params['comparisonId']);
|
||||
const result = await deps.approveBaseline.execute({ comparisonId });
|
||||
if ((0, Result_1.isErr)(result)) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ baselineId: result.value.baselineId, status: 'approved' });
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/visual/baselines/:comparisonId/reject
|
||||
router.post('/baselines/:comparisonId/reject', async (req, res, next) => {
|
||||
try {
|
||||
const comparisonId = String(req.params['comparisonId']);
|
||||
const result = await deps.rejectComparison.execute({ comparisonId });
|
||||
if ((0, Result_1.isErr)(result)) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ status: 'rejected' });
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/visual/baselines/approve-all
|
||||
router.post('/baselines/approve-all', async (req, res, next) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
const result = await deps.approveAllNewStates.execute({ sessionId });
|
||||
if ((0, Result_1.isErr)(result)) {
|
||||
res.status(500).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ approved: result.value.approved });
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
130
dist/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.js
vendored
Normal file
130
dist/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.KyselyVisualComparisonRepository = exports.KyselyVisualBaselineRepository = void 0;
|
||||
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||
const VisualBaseline_1 = require("../../domain/entities/VisualBaseline");
|
||||
const VisualComparison_1 = require("../../domain/entities/VisualComparison");
|
||||
const ComparisonStatus_1 = require("../../domain/value-objects/ComparisonStatus");
|
||||
class KyselyVisualBaselineRepository {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
async save(baseline) {
|
||||
await this.db.insertInto('visual_baselines').values({
|
||||
id: baseline.id.toString(),
|
||||
state_id: baseline.stateId,
|
||||
url: baseline.url,
|
||||
screenshot_path: baseline.screenshotPath,
|
||||
approved_at: baseline.approvedAt.getTime(),
|
||||
approved_by: baseline.approvedBy,
|
||||
width: baseline.width,
|
||||
height: baseline.height,
|
||||
}).onConflict(oc => oc.column('id').doUpdateSet({
|
||||
screenshot_path: baseline.screenshotPath,
|
||||
approved_at: baseline.approvedAt.getTime(),
|
||||
approved_by: baseline.approvedBy,
|
||||
})).execute();
|
||||
}
|
||||
async findByStateId(stateId) {
|
||||
const row = await this.db
|
||||
.selectFrom('visual_baselines')
|
||||
.selectAll()
|
||||
.where('state_id', '=', stateId)
|
||||
.orderBy('approved_at', 'desc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
async findById(id) {
|
||||
const row = await this.db
|
||||
.selectFrom('visual_baselines')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
toDomain(row) {
|
||||
return VisualBaseline_1.VisualBaseline.reconstitute({
|
||||
stateId: row.state_id,
|
||||
url: row.url,
|
||||
screenshotPath: row.screenshot_path,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
approvedAt: new Date(row.approved_at),
|
||||
approvedBy: row.approved_by ?? 'user',
|
||||
}, UniqueId_1.UniqueId.from(row.id));
|
||||
}
|
||||
}
|
||||
exports.KyselyVisualBaselineRepository = KyselyVisualBaselineRepository;
|
||||
class KyselyVisualComparisonRepository {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
async save(comparison) {
|
||||
await this.db.insertInto('visual_comparisons').values({
|
||||
id: comparison.id.toString(),
|
||||
session_id: comparison.sessionId,
|
||||
state_id: comparison.stateId,
|
||||
baseline_id: comparison.baselineId,
|
||||
current_screenshot_path: comparison.currentScreenshotPath,
|
||||
diff_screenshot_path: comparison.diffScreenshotPath,
|
||||
diff_pixels: comparison.diffPixels,
|
||||
diff_percent: comparison.diffPercent,
|
||||
status: comparison.status.value,
|
||||
created_at: comparison.createdAt.getTime(),
|
||||
}).execute();
|
||||
}
|
||||
async update(comparison) {
|
||||
await this.db.updateTable('visual_comparisons')
|
||||
.set({
|
||||
status: comparison.status.value,
|
||||
baseline_id: comparison.baselineId,
|
||||
})
|
||||
.where('id', '=', comparison.id.toString())
|
||||
.execute();
|
||||
}
|
||||
async findById(id) {
|
||||
const row = await this.db
|
||||
.selectFrom('visual_comparisons')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
async findAll(filters) {
|
||||
let query = this.db.selectFrom('visual_comparisons').selectAll();
|
||||
if (filters?.sessionId) {
|
||||
query = query.where('session_id', '=', filters.sessionId);
|
||||
}
|
||||
if (filters?.status) {
|
||||
query = query.where('status', '=', filters.status);
|
||||
}
|
||||
const rows = await query.orderBy('created_at', 'desc').execute();
|
||||
return rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
async findByStatus(sessionId, status) {
|
||||
let query = this.db
|
||||
.selectFrom('visual_comparisons')
|
||||
.selectAll()
|
||||
.where('status', '=', status);
|
||||
if (sessionId) {
|
||||
query = query.where('session_id', '=', sessionId);
|
||||
}
|
||||
const rows = await query.orderBy('created_at', 'desc').execute();
|
||||
return rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
toDomain(row) {
|
||||
return VisualComparison_1.VisualComparison.reconstitute({
|
||||
sessionId: row.session_id,
|
||||
stateId: row.state_id,
|
||||
baselineId: row.baseline_id,
|
||||
currentScreenshotPath: row.current_screenshot_path,
|
||||
diffScreenshotPath: row.diff_screenshot_path,
|
||||
diffPixels: row.diff_pixels,
|
||||
diffPercent: row.diff_percent,
|
||||
status: ComparisonStatus_1.ComparisonStatus.from(row.status),
|
||||
createdAt: new Date(row.created_at),
|
||||
}, UniqueId_1.UniqueId.from(row.id));
|
||||
}
|
||||
}
|
||||
exports.KyselyVisualComparisonRepository = KyselyVisualComparisonRepository;
|
||||
Reference in New Issue
Block a user