fase(20): visual regression refactor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
1cf597fee15fa5299f89d65d6bc8613dfc5af240
|
||||
49e76c92b17a3510da50ae1deaf8002c5c67d010
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"status": "failed", "timestamp": "2026-03-08 05:42:52"}
|
||||
{"status": "completed", "timestamp": "2026-03-08 05:49:12"}
|
||||
|
||||
29
.ralphrc
29
.ralphrc
@@ -1,7 +1,24 @@
|
||||
ALLOWED_TOOLS="bash,write,edit,read,glob,grep,todoread,todowrite"
|
||||
AUTO_APPROVE=true
|
||||
MAX_LOOPS=200
|
||||
MODEL="claude-sonnet-4-20250514"
|
||||
# .ralphrc - Ralph project configuration for ABE
|
||||
|
||||
# Allow all bash commands including docker
|
||||
ALLOW_ALL_BASH=true
|
||||
# Project
|
||||
PROJECT_NAME="abe"
|
||||
PROJECT_TYPE="typescript"
|
||||
|
||||
# Claude Code
|
||||
CLAUDE_CODE_CMD="claude"
|
||||
CLAUDE_OUTPUT_FORMAT="json"
|
||||
|
||||
# Loop settings
|
||||
MAX_CALLS_PER_HOUR=100
|
||||
CLAUDE_TIMEOUT_MINUTES=120
|
||||
|
||||
# Session continuity (mantener contexto entre loops)
|
||||
SESSION_CONTINUITY=true
|
||||
SESSION_EXPIRY_HOURS=24
|
||||
|
||||
# Circuit breaker - auto-reset para operación continua
|
||||
CB_AUTO_RESET=true
|
||||
CB_COOLDOWN_MINUTES=0
|
||||
|
||||
# Tool permissions - "Bash" sin paréntesis permite TODOS los comandos bash
|
||||
ALLOWED_TOOLS="Write,Read,Edit,MultiEdit,Glob,Grep,Bash,Bash(git *),Bash(npm *),Bash(npx *),Bash(node *)"
|
||||
|
||||
2
dist/api/router.js
vendored
2
dist/api/router.js
vendored
@@ -11,6 +11,7 @@ const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/Fuzz
|
||||
const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController");
|
||||
const IntegrationsController_1 = require("../modules/integrations/infrastructure/http/IntegrationsController");
|
||||
const SchedulingController_1 = require("../modules/scheduling/infrastructure/http/SchedulingController");
|
||||
const VisualRegressionController_1 = require("../modules/visual-regression/infrastructure/http/VisualRegressionController");
|
||||
const LicensingController_1 = require("../modules/licensing/infrastructure/http/LicensingController");
|
||||
const FeatureGateMiddleware_1 = require("../modules/licensing/infrastructure/middleware/FeatureGateMiddleware");
|
||||
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
|
||||
@@ -29,6 +30,7 @@ function createRouter(deps) {
|
||||
router.use('/reports', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'reports:basic'), (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
|
||||
router.use('/integrations', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'integrations:webhook'), (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps));
|
||||
router.use('/schedules', (0, SchedulingController_1.createSchedulingRouter)(deps.schedulingDeps));
|
||||
router.use('/visual', (0, VisualRegressionController_1.createVisualRegressionRouter)(deps.visualRegressionDeps));
|
||||
// Licensing routes (public-ish — only status and activate, no sensitive data)
|
||||
const licensingController = new LicensingController_1.LicensingController(licenseService);
|
||||
router.use('/license', licensingController.router);
|
||||
|
||||
21
dist/main.js
vendored
21
dist/main.js
vendored
@@ -60,6 +60,15 @@ const OnFindingCreated_1 = require("./modules/integrations/application/event-han
|
||||
// Licensing module
|
||||
const RSALicenseValidator_1 = require("./modules/licensing/infrastructure/validators/RSALicenseValidator");
|
||||
const LicenseService_1 = require("./modules/licensing/application/LicenseService");
|
||||
// Visual regression module
|
||||
const KyselyVisualRepository_1 = require("./modules/visual-regression/infrastructure/repositories/KyselyVisualRepository");
|
||||
const VisualRegressionAdapter_1 = require("./modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter");
|
||||
const ApproveBaselineCommand_1 = require("./modules/visual-regression/application/commands/ApproveBaselineCommand");
|
||||
const RejectComparisonCommand_1 = require("./modules/visual-regression/application/commands/RejectComparisonCommand");
|
||||
const ApproveAllNewStatesCommand_1 = require("./modules/visual-regression/application/commands/ApproveAllNewStatesCommand");
|
||||
const ListComparisonsQuery_1 = require("./modules/visual-regression/application/queries/ListComparisonsQuery");
|
||||
const StorageProvider_1 = require("./shared/infrastructure/StorageProvider");
|
||||
const path_1 = __importDefault(require("path"));
|
||||
// Scheduling module
|
||||
const KyselyScheduleRepository_1 = require("./modules/scheduling/infrastructure/repositories/KyselyScheduleRepository");
|
||||
const CreateScheduleCommand_1 = require("./modules/scheduling/application/commands/CreateScheduleCommand");
|
||||
@@ -143,6 +152,17 @@ async function bootstrap() {
|
||||
jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
|
||||
jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
|
||||
jobQueue.start();
|
||||
// 11d. Visual regression module
|
||||
const storageBasePath = path_1.default.join(process.cwd(), 'data');
|
||||
const storageProvider = new StorageProvider_1.LocalStorageProvider(storageBasePath);
|
||||
const visualBaselineRepo = new KyselyVisualRepository_1.KyselyVisualBaselineRepository(db);
|
||||
const visualComparisonRepo = new KyselyVisualRepository_1.KyselyVisualComparisonRepository(db);
|
||||
const visualRegressionAdapter = new VisualRegressionAdapter_1.VisualRegressionAdapter(storageProvider, visualBaselineRepo, visualComparisonRepo, eventBus);
|
||||
void visualRegressionAdapter; // used by ExplorationOrchestrator in crawling infra
|
||||
const listComparisons = new ListComparisonsQuery_1.ListComparisonsQuery(visualComparisonRepo);
|
||||
const approveBaseline = new ApproveBaselineCommand_1.ApproveBaselineCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
|
||||
const rejectComparison = new RejectComparisonCommand_1.RejectComparisonCommand(visualComparisonRepo);
|
||||
const approveAllNewStates = new ApproveAllNewStatesCommand_1.ApproveAllNewStatesCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
|
||||
// 12b. Scheduling module (after job queue, since it enqueues jobs)
|
||||
const scheduleRepo = new KyselyScheduleRepository_1.KyselyScheduleRepository(db);
|
||||
const createSchedule = new CreateScheduleCommand_1.CreateScheduleCommand(scheduleRepo, eventBus);
|
||||
@@ -162,6 +182,7 @@ async function bootstrap() {
|
||||
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
|
||||
integrationsDeps: { integrationRepo, webhookRepo },
|
||||
schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo },
|
||||
visualRegressionDeps: { listComparisons, approveBaseline, rejectComparison, approveAllNewStates },
|
||||
licenseService,
|
||||
authDeps: {
|
||||
registerCommand,
|
||||
|
||||
39
dist/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.js
vendored
Normal file
39
dist/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.js
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ApproveAllNewStatesCommand = void 0;
|
||||
const Result_1 = require("../../../../shared/domain/Result");
|
||||
const VisualBaseline_1 = require("../../domain/entities/VisualBaseline");
|
||||
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||
class ApproveAllNewStatesCommand {
|
||||
constructor(comparisonRepo, baselineRepo, eventBus) {
|
||||
this.comparisonRepo = comparisonRepo;
|
||||
this.baselineRepo = baselineRepo;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
async execute(req) {
|
||||
const pending = await this.comparisonRepo.findByStatus(req.sessionId, 'new_state');
|
||||
let approved = 0;
|
||||
for (const comparison of pending) {
|
||||
const baselineId = UniqueId_1.UniqueId.create();
|
||||
const baseline = VisualBaseline_1.VisualBaseline.create({
|
||||
stateId: comparison.stateId,
|
||||
url: comparison.sessionId,
|
||||
screenshotPath: comparison.currentScreenshotPath,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
approvedAt: new Date(),
|
||||
approvedBy: 'user',
|
||||
}, baselineId);
|
||||
await this.baselineRepo.save(baseline);
|
||||
comparison.approve(baselineId.toString());
|
||||
await this.comparisonRepo.update(comparison);
|
||||
for (const event of comparison.domainEvents) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
comparison.clearEvents();
|
||||
approved++;
|
||||
}
|
||||
return (0, Result_1.Ok)({ approved });
|
||||
}
|
||||
}
|
||||
exports.ApproveAllNewStatesCommand = ApproveAllNewStatesCommand;
|
||||
38
dist/modules/visual-regression/application/commands/ApproveBaselineCommand.js
vendored
Normal file
38
dist/modules/visual-regression/application/commands/ApproveBaselineCommand.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ApproveBaselineCommand = void 0;
|
||||
const Result_1 = require("../../../../shared/domain/Result");
|
||||
const VisualBaseline_1 = require("../../domain/entities/VisualBaseline");
|
||||
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||
class ApproveBaselineCommand {
|
||||
constructor(comparisonRepo, baselineRepo, eventBus) {
|
||||
this.comparisonRepo = comparisonRepo;
|
||||
this.baselineRepo = baselineRepo;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
async execute(req) {
|
||||
const comparison = await this.comparisonRepo.findById(req.comparisonId);
|
||||
if (!comparison) {
|
||||
return (0, Result_1.Err)('Comparison not found');
|
||||
}
|
||||
const baselineId = UniqueId_1.UniqueId.create();
|
||||
const baseline = VisualBaseline_1.VisualBaseline.create({
|
||||
stateId: comparison.stateId,
|
||||
url: comparison.sessionId,
|
||||
screenshotPath: comparison.currentScreenshotPath,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
approvedAt: new Date(),
|
||||
approvedBy: req.approvedBy ?? 'user',
|
||||
}, baselineId);
|
||||
await this.baselineRepo.save(baseline);
|
||||
comparison.approve(baselineId.toString());
|
||||
await this.comparisonRepo.update(comparison);
|
||||
for (const event of comparison.domainEvents) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
comparison.clearEvents();
|
||||
return (0, Result_1.Ok)({ baselineId: baselineId.toString() });
|
||||
}
|
||||
}
|
||||
exports.ApproveBaselineCommand = ApproveBaselineCommand;
|
||||
19
dist/modules/visual-regression/application/commands/RejectComparisonCommand.js
vendored
Normal file
19
dist/modules/visual-regression/application/commands/RejectComparisonCommand.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RejectComparisonCommand = void 0;
|
||||
const Result_1 = require("../../../../shared/domain/Result");
|
||||
class RejectComparisonCommand {
|
||||
constructor(comparisonRepo) {
|
||||
this.comparisonRepo = comparisonRepo;
|
||||
}
|
||||
async execute(req) {
|
||||
const comparison = await this.comparisonRepo.findById(req.comparisonId);
|
||||
if (!comparison) {
|
||||
return (0, Result_1.Err)('Comparison not found');
|
||||
}
|
||||
comparison.reject();
|
||||
await this.comparisonRepo.update(comparison);
|
||||
return (0, Result_1.Ok)(undefined);
|
||||
}
|
||||
}
|
||||
exports.RejectComparisonCommand = RejectComparisonCommand;
|
||||
14
dist/modules/visual-regression/application/queries/ListComparisonsQuery.js
vendored
Normal file
14
dist/modules/visual-regression/application/queries/ListComparisonsQuery.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ListComparisonsQuery = void 0;
|
||||
const Result_1 = require("../../../../shared/domain/Result");
|
||||
class ListComparisonsQuery {
|
||||
constructor(repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
async execute(filters) {
|
||||
const comparisons = await this.repo.findAll(filters);
|
||||
return (0, Result_1.Ok)(comparisons);
|
||||
}
|
||||
}
|
||||
exports.ListComparisonsQuery = ListComparisonsQuery;
|
||||
21
dist/modules/visual-regression/domain/entities/VisualBaseline.js
vendored
Normal file
21
dist/modules/visual-regression/domain/entities/VisualBaseline.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.VisualBaseline = void 0;
|
||||
const Entity_1 = require("../../../../shared/domain/Entity");
|
||||
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||
class VisualBaseline extends Entity_1.Entity {
|
||||
static create(props, id) {
|
||||
return new VisualBaseline(props, id ?? UniqueId_1.UniqueId.create());
|
||||
}
|
||||
static reconstitute(props, id) {
|
||||
return new VisualBaseline(props, id);
|
||||
}
|
||||
get stateId() { return this.props.stateId; }
|
||||
get url() { return this.props.url; }
|
||||
get screenshotPath() { return this.props.screenshotPath; }
|
||||
get width() { return this.props.width; }
|
||||
get height() { return this.props.height; }
|
||||
get approvedAt() { return this.props.approvedAt; }
|
||||
get approvedBy() { return this.props.approvedBy; }
|
||||
}
|
||||
exports.VisualBaseline = VisualBaseline;
|
||||
46
dist/modules/visual-regression/domain/entities/VisualComparison.js
vendored
Normal file
46
dist/modules/visual-regression/domain/entities/VisualComparison.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.VisualComparison = void 0;
|
||||
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
|
||||
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||
const ComparisonStatus_1 = require("../value-objects/ComparisonStatus");
|
||||
const BaselineApproved_1 = require("../events/BaselineApproved");
|
||||
const RegressionDetected_1 = require("../events/RegressionDetected");
|
||||
class VisualComparison extends AggregateRoot_1.AggregateRoot {
|
||||
static create(props, id) {
|
||||
const comparison = new VisualComparison({ ...props, createdAt: new Date() }, id ?? UniqueId_1.UniqueId.create());
|
||||
if (props.status.isFailed()) {
|
||||
comparison.addDomainEvent(new RegressionDetected_1.RegressionDetected(comparison.id.toString(), {
|
||||
sessionId: props.sessionId,
|
||||
stateId: props.stateId,
|
||||
diffPercent: props.diffPercent ?? 0,
|
||||
}));
|
||||
}
|
||||
return comparison;
|
||||
}
|
||||
static reconstitute(props, id) {
|
||||
return new VisualComparison(props, id);
|
||||
}
|
||||
get sessionId() { return this.props.sessionId; }
|
||||
get stateId() { return this.props.stateId; }
|
||||
get baselineId() { return this.props.baselineId; }
|
||||
get currentScreenshotPath() { return this.props.currentScreenshotPath; }
|
||||
get diffScreenshotPath() { return this.props.diffScreenshotPath; }
|
||||
get diffPixels() { return this.props.diffPixels; }
|
||||
get diffPercent() { return this.props.diffPercent; }
|
||||
get status() { return this.props.status; }
|
||||
get createdAt() { return this.props.createdAt; }
|
||||
approve(newBaselineId) {
|
||||
this.props.status = ComparisonStatus_1.ComparisonStatus.passed();
|
||||
this.props.baselineId = newBaselineId;
|
||||
this.addDomainEvent(new BaselineApproved_1.BaselineApproved(this.id.toString(), {
|
||||
sessionId: this.props.sessionId,
|
||||
stateId: this.props.stateId,
|
||||
baselineId: newBaselineId,
|
||||
}));
|
||||
}
|
||||
reject() {
|
||||
this.props.status = ComparisonStatus_1.ComparisonStatus.failed();
|
||||
}
|
||||
}
|
||||
exports.VisualComparison = VisualComparison;
|
||||
13
dist/modules/visual-regression/domain/events/BaselineApproved.js
vendored
Normal file
13
dist/modules/visual-regression/domain/events/BaselineApproved.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.BaselineApproved = void 0;
|
||||
class BaselineApproved {
|
||||
constructor(aggregateId, payload) {
|
||||
this.aggregateId = aggregateId;
|
||||
this.payload = payload;
|
||||
this.eventName = 'visual.baseline_approved';
|
||||
this.eventId = crypto.randomUUID();
|
||||
this.occurredOn = new Date();
|
||||
}
|
||||
}
|
||||
exports.BaselineApproved = BaselineApproved;
|
||||
13
dist/modules/visual-regression/domain/events/RegressionDetected.js
vendored
Normal file
13
dist/modules/visual-regression/domain/events/RegressionDetected.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RegressionDetected = void 0;
|
||||
class RegressionDetected {
|
||||
constructor(aggregateId, payload) {
|
||||
this.aggregateId = aggregateId;
|
||||
this.payload = payload;
|
||||
this.eventName = 'visual.regression_detected';
|
||||
this.eventId = crypto.randomUUID();
|
||||
this.occurredOn = new Date();
|
||||
}
|
||||
}
|
||||
exports.RegressionDetected = RegressionDetected;
|
||||
2
dist/modules/visual-regression/domain/ports/IVisualBaselineRepository.js
vendored
Normal file
2
dist/modules/visual-regression/domain/ports/IVisualBaselineRepository.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
2
dist/modules/visual-regression/domain/ports/IVisualComparisonRepository.js
vendored
Normal file
2
dist/modules/visual-regression/domain/ports/IVisualComparisonRepository.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
23
dist/modules/visual-regression/domain/value-objects/ComparisonStatus.js
vendored
Normal file
23
dist/modules/visual-regression/domain/value-objects/ComparisonStatus.js
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ComparisonStatus = void 0;
|
||||
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
|
||||
class ComparisonStatus extends ValueObject_1.ValueObject {
|
||||
get value() {
|
||||
return this.props.value;
|
||||
}
|
||||
static passed() { return new ComparisonStatus({ value: 'passed' }); }
|
||||
static failed() { return new ComparisonStatus({ value: 'failed' }); }
|
||||
static newState() { return new ComparisonStatus({ value: 'new_state' }); }
|
||||
static pending() { return new ComparisonStatus({ value: 'pending' }); }
|
||||
static from(value) {
|
||||
if (!['passed', 'failed', 'new_state', 'pending'].includes(value)) {
|
||||
throw new Error(`Invalid comparison status: ${value}`);
|
||||
}
|
||||
return new ComparisonStatus({ value: value });
|
||||
}
|
||||
isPassed() { return this.props.value === 'passed'; }
|
||||
isFailed() { return this.props.value === 'failed'; }
|
||||
isNewState() { return this.props.value === 'new_state'; }
|
||||
}
|
||||
exports.ComparisonStatus = ComparisonStatus;
|
||||
32
dist/modules/visual-regression/index.js
vendored
Normal file
32
dist/modules/visual-regression/index.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
// Visual Regression Module — Public API
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createVisualRegressionRouter = exports.VisualRegressionAdapter = exports.KyselyVisualComparisonRepository = exports.KyselyVisualBaselineRepository = exports.ListComparisonsQuery = exports.ApproveAllNewStatesCommand = exports.RejectComparisonCommand = exports.ApproveBaselineCommand = exports.RegressionDetected = exports.BaselineApproved = exports.ComparisonStatus = exports.VisualComparison = exports.VisualBaseline = void 0;
|
||||
// Domain
|
||||
var VisualBaseline_1 = require("./domain/entities/VisualBaseline");
|
||||
Object.defineProperty(exports, "VisualBaseline", { enumerable: true, get: function () { return VisualBaseline_1.VisualBaseline; } });
|
||||
var VisualComparison_1 = require("./domain/entities/VisualComparison");
|
||||
Object.defineProperty(exports, "VisualComparison", { enumerable: true, get: function () { return VisualComparison_1.VisualComparison; } });
|
||||
var ComparisonStatus_1 = require("./domain/value-objects/ComparisonStatus");
|
||||
Object.defineProperty(exports, "ComparisonStatus", { enumerable: true, get: function () { return ComparisonStatus_1.ComparisonStatus; } });
|
||||
var BaselineApproved_1 = require("./domain/events/BaselineApproved");
|
||||
Object.defineProperty(exports, "BaselineApproved", { enumerable: true, get: function () { return BaselineApproved_1.BaselineApproved; } });
|
||||
var RegressionDetected_1 = require("./domain/events/RegressionDetected");
|
||||
Object.defineProperty(exports, "RegressionDetected", { enumerable: true, get: function () { return RegressionDetected_1.RegressionDetected; } });
|
||||
// Application
|
||||
var ApproveBaselineCommand_1 = require("./application/commands/ApproveBaselineCommand");
|
||||
Object.defineProperty(exports, "ApproveBaselineCommand", { enumerable: true, get: function () { return ApproveBaselineCommand_1.ApproveBaselineCommand; } });
|
||||
var RejectComparisonCommand_1 = require("./application/commands/RejectComparisonCommand");
|
||||
Object.defineProperty(exports, "RejectComparisonCommand", { enumerable: true, get: function () { return RejectComparisonCommand_1.RejectComparisonCommand; } });
|
||||
var ApproveAllNewStatesCommand_1 = require("./application/commands/ApproveAllNewStatesCommand");
|
||||
Object.defineProperty(exports, "ApproveAllNewStatesCommand", { enumerable: true, get: function () { return ApproveAllNewStatesCommand_1.ApproveAllNewStatesCommand; } });
|
||||
var ListComparisonsQuery_1 = require("./application/queries/ListComparisonsQuery");
|
||||
Object.defineProperty(exports, "ListComparisonsQuery", { enumerable: true, get: function () { return ListComparisonsQuery_1.ListComparisonsQuery; } });
|
||||
// Infrastructure
|
||||
var KyselyVisualRepository_1 = require("./infrastructure/repositories/KyselyVisualRepository");
|
||||
Object.defineProperty(exports, "KyselyVisualBaselineRepository", { enumerable: true, get: function () { return KyselyVisualRepository_1.KyselyVisualBaselineRepository; } });
|
||||
Object.defineProperty(exports, "KyselyVisualComparisonRepository", { enumerable: true, get: function () { return KyselyVisualRepository_1.KyselyVisualComparisonRepository; } });
|
||||
var VisualRegressionAdapter_1 = require("./infrastructure/adapters/VisualRegressionAdapter");
|
||||
Object.defineProperty(exports, "VisualRegressionAdapter", { enumerable: true, get: function () { return VisualRegressionAdapter_1.VisualRegressionAdapter; } });
|
||||
var VisualRegressionController_1 = require("./infrastructure/http/VisualRegressionController");
|
||||
Object.defineProperty(exports, "createVisualRegressionRouter", { enumerable: true, get: function () { return VisualRegressionController_1.createVisualRegressionRouter; } });
|
||||
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;
|
||||
@@ -24,10 +24,7 @@ import { AppearanceSection } from '@/pages/settings/AppearanceSection'
|
||||
import { LicenseSection } from '@/pages/settings/LicenseSection'
|
||||
import { SchedulesSection } from '@/pages/settings/SchedulesSection'
|
||||
import { Reports } from '@/pages/Reports'
|
||||
|
||||
function VisualReview() {
|
||||
return <div className="text-muted-foreground p-4">Visual Review — Coming in Phase 20</div>
|
||||
}
|
||||
import { VisualReview } from '@/pages/VisualReview'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
||||
@@ -1,279 +1,288 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../hooks/useApi';
|
||||
import type { VisualComparison, ComparisonStatus } from '../types';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { api } from '@/hooks/useApi';
|
||||
import type { VisualComparison, ComparisonStatus } from '@/types';
|
||||
|
||||
const STATUS_LABEL: Record<ComparisonStatus, string> = {
|
||||
const STATUS_LABEL: Record<ComparisonStatus | 'all', string> = {
|
||||
all: 'All',
|
||||
new_state: 'New State',
|
||||
failed: 'Regression',
|
||||
passed: 'Passed',
|
||||
pending: 'Pending',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<ComparisonStatus, string> = {
|
||||
new_state: 'bg-blue-900/50 text-blue-300 border-blue-700',
|
||||
failed: 'bg-red-900/50 text-red-300 border-red-700',
|
||||
passed: 'bg-green-900/50 text-green-300 border-green-700',
|
||||
pending: 'bg-gray-700 text-gray-300 border-gray-600',
|
||||
const STATUS_VARIANT: Record<ComparisonStatus, 'default' | 'destructive' | 'secondary' | 'outline'> = {
|
||||
new_state: 'default',
|
||||
failed: 'destructive',
|
||||
passed: 'secondary',
|
||||
pending: 'outline',
|
||||
};
|
||||
|
||||
function screenshotUrl(path: string | null): string | null {
|
||||
if (!path) return null;
|
||||
// Backend serves screenshots from /api/screenshots/<filename>
|
||||
const parts = path.replace(/\\/g, '/').split('/');
|
||||
function screenshotUrl(filePath: string | null): string | null {
|
||||
if (!filePath) return null;
|
||||
const parts = filePath.replace(/\\/g, '/').split('/');
|
||||
return `/api/screenshots/${parts[parts.length - 1]}`;
|
||||
}
|
||||
|
||||
interface ComparisonModalProps {
|
||||
interface ComparisonDialogProps {
|
||||
comparison: VisualComparison;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onClose: () => void;
|
||||
onApproved: () => void;
|
||||
onRejected: () => void;
|
||||
}
|
||||
|
||||
function ComparisonModal({ comparison, onApprove, onReject, onClose }: ComparisonModalProps) {
|
||||
const [acting, setActing] = useState<'approving' | 'rejecting' | null>(null);
|
||||
function ComparisonDialog({ comparison, onClose, onApproved, onRejected }: ComparisonDialogProps) {
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => api.approveBaseline(comparison.id),
|
||||
onSuccess: onApproved,
|
||||
});
|
||||
|
||||
async function handleApprove() {
|
||||
setActing('approving');
|
||||
try { await api.approveBaseline(comparison.id); onApprove(); }
|
||||
finally { setActing(null); }
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setActing('rejecting');
|
||||
try { await api.rejectBaseline(comparison.id); onReject(); }
|
||||
finally { setActing(null); }
|
||||
}
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: () => api.rejectBaseline(comparison.id),
|
||||
onSuccess: onRejected,
|
||||
});
|
||||
|
||||
const currentUrl = screenshotUrl(comparison.current_screenshot_path);
|
||||
const diffUrl = screenshotUrl(comparison.diff_screenshot_path);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-start justify-center z-50 overflow-y-auto py-8"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg w-full max-w-5xl mx-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white">Visual Review</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
State: <code className="bg-gray-700 px-1 rounded">{comparison.state_id.slice(0, 12)}…</code>
|
||||
{comparison.diff_percent !== null && (
|
||||
<span className="ml-3">Diff: <strong className="text-red-400">{comparison.diff_percent.toFixed(2)}%</strong></span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white text-xl leading-none">×</button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
Visual Review
|
||||
<Badge variant={STATUS_VARIANT[comparison.status]}>
|
||||
{STATUS_LABEL[comparison.status]}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
State: <code className="bg-muted px-1 rounded text-xs">{comparison.state_id.slice(0, 16)}…</code>
|
||||
{comparison.diff_percent !== null && (
|
||||
<span className="ml-3 text-destructive font-medium">
|
||||
{comparison.diff_percent.toFixed(2)}% diff
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Baseline */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Baseline</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Baseline</p>
|
||||
{comparison.baseline_id ? (
|
||||
<div className="bg-gray-900 rounded overflow-hidden">
|
||||
<img src={`/api/visual/baseline-screenshot/${comparison.baseline_id}`} alt="Baseline" className="w-full" />
|
||||
<div className="bg-muted rounded overflow-hidden">
|
||||
<img
|
||||
src={`/api/visual/baseline-screenshot/${comparison.baseline_id}`}
|
||||
alt="Baseline"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 rounded p-8 text-center text-gray-500 text-sm">No baseline</div>
|
||||
<div className="bg-muted rounded p-8 text-center text-muted-foreground text-sm">
|
||||
No baseline
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Current</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Current</p>
|
||||
{currentUrl ? (
|
||||
<div className="bg-gray-900 rounded overflow-hidden">
|
||||
<div className="bg-muted rounded overflow-hidden">
|
||||
<img src={currentUrl} alt="Current screenshot" className="w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 rounded p-8 text-center text-gray-500 text-sm">No screenshot</div>
|
||||
<div className="bg-muted rounded p-8 text-center text-muted-foreground text-sm">
|
||||
No screenshot
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diff */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Diff</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Diff</p>
|
||||
{diffUrl ? (
|
||||
<div className="bg-gray-900 rounded overflow-hidden">
|
||||
<div className="bg-muted rounded overflow-hidden">
|
||||
<img src={diffUrl} alt="Diff image" className="w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 rounded p-8 text-center text-gray-500 text-sm">No diff</div>
|
||||
<div className="bg-muted rounded p-8 text-center text-muted-foreground text-sm">
|
||||
No diff
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 flex gap-3">
|
||||
<DialogFooter className="gap-2">
|
||||
{comparison.status !== 'passed' && (
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={!!acting}
|
||||
className="bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-sm font-medium px-5 py-2 rounded transition-colors"
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => approveMutation.mutate()}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
{acting === 'approving' ? 'Approving…' : 'Approve as Baseline'}
|
||||
</button>
|
||||
{approveMutation.isPending ? 'Approving…' : 'Approve as Baseline'}
|
||||
</Button>
|
||||
)}
|
||||
{comparison.status === 'failed' && (
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={!!acting}
|
||||
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-medium px-5 py-2 rounded transition-colors"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => rejectMutation.mutate()}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
{acting === 'rejecting' ? 'Rejecting…' : 'Mark as Rejected'}
|
||||
</button>
|
||||
{rejectMutation.isPending ? 'Rejecting…' : 'Mark as Rejected'}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium px-5 py-2 rounded transition-colors"
|
||||
>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const ALL_STATUSES: Array<ComparisonStatus | 'all'> = ['all', 'new_state', 'failed', 'passed', 'pending'];
|
||||
|
||||
export function VisualReview() {
|
||||
const [comparisons, setComparisons] = useState<VisualComparison[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<ComparisonStatus | 'all'>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<VisualComparison | null>(null);
|
||||
const [approvingAll, setApprovingAll] = useState(false);
|
||||
const [bulkResult, setBulkResult] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getVisualComparisons(statusFilter !== 'all' ? { status: statusFilter } : undefined)
|
||||
.then(setComparisons)
|
||||
.catch(() => setComparisons([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [statusFilter]);
|
||||
const { data: comparisons = [], isLoading } = useQuery({
|
||||
queryKey: ['visual-comparisons', statusFilter],
|
||||
queryFn: () =>
|
||||
api.getVisualComparisons(statusFilter !== 'all' ? { status: statusFilter } : undefined),
|
||||
});
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const approveAllMutation = useMutation({
|
||||
mutationFn: () => api.approveAllBaselines(),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['visual-comparisons'] }),
|
||||
});
|
||||
|
||||
async function handleApproveAll() {
|
||||
if (!confirm('Approve all new_state comparisons as baselines?')) return;
|
||||
setApprovingAll(true);
|
||||
setBulkResult(null);
|
||||
try {
|
||||
const res = await api.approveAllBaselines();
|
||||
setBulkResult(`Approved ${res.approved} comparison(s) as baselines.`);
|
||||
load();
|
||||
} catch (err) {
|
||||
setBulkResult(`Error: ${err instanceof Error ? err.message : 'unknown'}`);
|
||||
} finally {
|
||||
setApprovingAll(false);
|
||||
}
|
||||
}
|
||||
const handleRefresh = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['visual-comparisons'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const newStateCount = comparisons.filter((c) => c.status === 'new_state').length;
|
||||
const failedCount = comparisons.filter((c) => c.status === 'failed').length;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8 space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<Link to="/" className="text-blue-400 text-sm hover:underline">← Dashboard</Link>
|
||||
<h1 className="text-2xl font-bold text-white mt-1">Visual Regression Review</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{failedCount > 0 && <span className="text-red-400 font-medium mr-3">{failedCount} regression{failedCount > 1 ? 's' : ''}</span>}
|
||||
{newStateCount > 0 && <span className="text-blue-400 font-medium">{newStateCount} new state{newStateCount > 1 ? 's' : ''}</span>}
|
||||
<h1 className="text-2xl font-bold tracking-tight">Visual Regression Review</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{failedCount > 0 && (
|
||||
<span className="text-destructive font-medium mr-3">
|
||||
{failedCount} regression{failedCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{newStateCount > 0 && (
|
||||
<span className="text-primary font-medium">
|
||||
{newStateCount} new state{newStateCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{failedCount === 0 && newStateCount === 0 && comparisons.length > 0 && (
|
||||
<span>All comparisons reviewed</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{newStateCount > 0 && (
|
||||
<button
|
||||
onClick={handleApproveAll}
|
||||
disabled={approvingAll}
|
||||
className="bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded transition-colors"
|
||||
<Button
|
||||
onClick={() => approveAllMutation.mutate()}
|
||||
disabled={approveAllMutation.isPending}
|
||||
>
|
||||
{approvingAll ? 'Approving…' : `Approve All New (${newStateCount})`}
|
||||
</button>
|
||||
{approveAllMutation.isPending
|
||||
? 'Approving…'
|
||||
: `Approve All New (${newStateCount})`}
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{bulkResult && (
|
||||
<div className="bg-gray-800 rounded px-4 py-3 text-sm text-gray-300">
|
||||
{bulkResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="flex gap-2">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`text-xs px-3 py-1.5 rounded border transition-colors ${
|
||||
statusFilter === s
|
||||
? 'bg-gray-600 border-gray-500 text-white'
|
||||
: 'bg-transparent border-gray-700 text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? 'All' : STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-400 text-sm">Loading comparisons…</p>
|
||||
) : comparisons.length === 0 ? (
|
||||
<div className="bg-gray-800 rounded-lg p-12 text-center text-gray-500 text-sm">
|
||||
No visual comparisons found. Run an exploration with visual regression enabled.
|
||||
{/* Status filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Filter:</span>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v as ComparisonStatus | 'all')}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="new_state">New State</SelectItem>
|
||||
<SelectItem value="failed">Regression</SelectItem>
|
||||
<SelectItem value="passed">Passed</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-video rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : comparisons.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-muted-foreground">
|
||||
No visual comparisons found. Run an exploration with visual regression enabled.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{comparisons.map((cmp) => {
|
||||
const imgUrl = screenshotUrl(cmp.current_screenshot_path);
|
||||
return (
|
||||
<button
|
||||
<Card
|
||||
key={cmp.id}
|
||||
className="overflow-hidden cursor-pointer hover:ring-2 hover:ring-ring transition-all"
|
||||
onClick={() => setSelected(cmp)}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all text-left"
|
||||
>
|
||||
{imgUrl ? (
|
||||
<div className="bg-gray-900 aspect-video overflow-hidden">
|
||||
<img src={imgUrl} alt="Screenshot" className="w-full object-cover object-top" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 aspect-video flex items-center justify-center text-gray-600 text-xs">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 space-y-1">
|
||||
<span
|
||||
className={`inline-block text-xs px-2 py-0.5 rounded border ${STATUS_COLORS[cmp.status]}`}
|
||||
>
|
||||
<CardHeader className="p-0">
|
||||
{imgUrl ? (
|
||||
<div className="bg-muted aspect-video overflow-hidden">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt="Screenshot"
|
||||
className="w-full object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted aspect-video flex items-center justify-center text-muted-foreground text-xs">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 space-y-1">
|
||||
<Badge variant={STATUS_VARIANT[cmp.status]}>
|
||||
{STATUS_LABEL[cmp.status]}
|
||||
</span>
|
||||
<p className="text-gray-400 text-xs truncate">
|
||||
</Badge>
|
||||
<p className="text-muted-foreground text-xs truncate">
|
||||
{cmp.state_id.slice(0, 16)}…
|
||||
</p>
|
||||
{cmp.diff_percent !== null && (
|
||||
<p className="text-red-400 text-xs font-medium">{cmp.diff_percent.toFixed(2)}% diff</p>
|
||||
<p className="text-destructive text-xs font-medium">
|
||||
{cmp.diff_percent.toFixed(2)}% diff
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<ComparisonModal
|
||||
<ComparisonDialog
|
||||
comparison={selected}
|
||||
onApprove={() => { setSelected(null); load(); }}
|
||||
onReject={() => { setSelected(null); load(); }}
|
||||
onClose={() => setSelected(null)}
|
||||
onApproved={() => { setSelected(null); handleRefresh(); }}
|
||||
onRejected={() => { setSelected(null); handleRefresh(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/Fuzz
|
||||
import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController';
|
||||
import { createIntegrationsRouter } from '../modules/integrations/infrastructure/http/IntegrationsController';
|
||||
import { createSchedulingRouter } from '../modules/scheduling/infrastructure/http/SchedulingController';
|
||||
import { createVisualRegressionRouter } from '../modules/visual-regression/infrastructure/http/VisualRegressionController';
|
||||
import { LicensingController } from '../modules/licensing/infrastructure/http/LicensingController';
|
||||
import { LicenseService } from '../modules/licensing/application/LicenseService';
|
||||
import { requireFeature } from '../modules/licensing/infrastructure/middleware/FeatureGateMiddleware';
|
||||
@@ -73,6 +74,7 @@ export function createRouter(deps: ServerDependencies): Router {
|
||||
router.use('/reports', requireFeature(licenseService, 'reports:basic'), createReportingRouter(deps.reportingDeps));
|
||||
router.use('/integrations', requireFeature(licenseService, 'integrations:webhook'), createIntegrationsRouter(deps.integrationsDeps));
|
||||
router.use('/schedules', createSchedulingRouter(deps.schedulingDeps));
|
||||
router.use('/visual', createVisualRegressionRouter(deps.visualRegressionDeps));
|
||||
|
||||
// Licensing routes (public-ish — only status and activate, no sensitive data)
|
||||
const licensingController = new LicensingController(licenseService);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { IntegrationsDeps } from '../modules/integrations/infrastructure/http/In
|
||||
import { AuthControllerDeps } from './router';
|
||||
import { LicenseService } from '../modules/licensing/application/LicenseService';
|
||||
import { SchedulingControllerDeps } from '../modules/scheduling/infrastructure/http/SchedulingController';
|
||||
import { VisualRegressionControllerDeps } from '../modules/visual-regression/infrastructure/http/VisualRegressionController';
|
||||
|
||||
export interface ServerDependencies {
|
||||
config: AppConfig;
|
||||
@@ -34,6 +35,7 @@ export interface ServerDependencies {
|
||||
reportingDeps: ReportingControllerDeps;
|
||||
integrationsDeps: IntegrationsDeps;
|
||||
schedulingDeps: SchedulingControllerDeps;
|
||||
visualRegressionDeps: VisualRegressionControllerDeps;
|
||||
authDeps: AuthControllerDeps;
|
||||
licenseService: LicenseService;
|
||||
}
|
||||
|
||||
28
src/main.ts
28
src/main.ts
@@ -65,6 +65,16 @@ import { OnFindingCreated } from './modules/integrations/application/event-handl
|
||||
import { RSALicenseValidator } from './modules/licensing/infrastructure/validators/RSALicenseValidator';
|
||||
import { LicenseService } from './modules/licensing/application/LicenseService';
|
||||
|
||||
// Visual regression module
|
||||
import { KyselyVisualBaselineRepository, KyselyVisualComparisonRepository } from './modules/visual-regression/infrastructure/repositories/KyselyVisualRepository';
|
||||
import { VisualRegressionAdapter } from './modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter';
|
||||
import { ApproveBaselineCommand } from './modules/visual-regression/application/commands/ApproveBaselineCommand';
|
||||
import { RejectComparisonCommand } from './modules/visual-regression/application/commands/RejectComparisonCommand';
|
||||
import { ApproveAllNewStatesCommand } from './modules/visual-regression/application/commands/ApproveAllNewStatesCommand';
|
||||
import { ListComparisonsQuery } from './modules/visual-regression/application/queries/ListComparisonsQuery';
|
||||
import { LocalStorageProvider } from './shared/infrastructure/StorageProvider';
|
||||
import path from 'path';
|
||||
|
||||
// Scheduling module
|
||||
import { KyselyScheduleRepository } from './modules/scheduling/infrastructure/repositories/KyselyScheduleRepository';
|
||||
import { CreateScheduleCommand } from './modules/scheduling/application/commands/CreateScheduleCommand';
|
||||
@@ -171,6 +181,23 @@ async function bootstrap(): Promise<void> {
|
||||
jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
|
||||
jobQueue.start();
|
||||
|
||||
// 11d. Visual regression module
|
||||
const storageBasePath = path.join(process.cwd(), 'data');
|
||||
const storageProvider = new LocalStorageProvider(storageBasePath);
|
||||
const visualBaselineRepo = new KyselyVisualBaselineRepository(db);
|
||||
const visualComparisonRepo = new KyselyVisualComparisonRepository(db);
|
||||
const visualRegressionAdapter = new VisualRegressionAdapter(
|
||||
storageProvider,
|
||||
visualBaselineRepo,
|
||||
visualComparisonRepo,
|
||||
eventBus
|
||||
);
|
||||
void visualRegressionAdapter; // used by ExplorationOrchestrator in crawling infra
|
||||
const listComparisons = new ListComparisonsQuery(visualComparisonRepo);
|
||||
const approveBaseline = new ApproveBaselineCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
|
||||
const rejectComparison = new RejectComparisonCommand(visualComparisonRepo);
|
||||
const approveAllNewStates = new ApproveAllNewStatesCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
|
||||
|
||||
// 12b. Scheduling module (after job queue, since it enqueues jobs)
|
||||
const scheduleRepo = new KyselyScheduleRepository(db);
|
||||
const createSchedule = new CreateScheduleCommand(scheduleRepo, eventBus);
|
||||
@@ -191,6 +218,7 @@ async function bootstrap(): Promise<void> {
|
||||
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
|
||||
integrationsDeps: { integrationRepo, webhookRepo },
|
||||
schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo },
|
||||
visualRegressionDeps: { listComparisons, approveBaseline, rejectComparison, approveAllNewStates },
|
||||
licenseService,
|
||||
authDeps: {
|
||||
registerCommand,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Result, Ok } from '../../../../shared/domain/Result';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { VisualBaseline } from '../../domain/entities/VisualBaseline';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
|
||||
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
|
||||
|
||||
export interface ApproveAllNewStatesRequest {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export class ApproveAllNewStatesCommand {
|
||||
constructor(
|
||||
private readonly comparisonRepo: IVisualComparisonRepository,
|
||||
private readonly baselineRepo: IVisualBaselineRepository,
|
||||
private readonly eventBus: EventBus
|
||||
) {}
|
||||
|
||||
async execute(req: ApproveAllNewStatesRequest): Promise<Result<{ approved: number }, string>> {
|
||||
const pending = await this.comparisonRepo.findByStatus(req.sessionId, 'new_state');
|
||||
let approved = 0;
|
||||
|
||||
for (const comparison of pending) {
|
||||
const baselineId = UniqueId.create();
|
||||
const baseline = VisualBaseline.create({
|
||||
stateId: comparison.stateId,
|
||||
url: comparison.sessionId,
|
||||
screenshotPath: comparison.currentScreenshotPath,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
approvedAt: new Date(),
|
||||
approvedBy: 'user',
|
||||
}, baselineId);
|
||||
|
||||
await this.baselineRepo.save(baseline);
|
||||
comparison.approve(baselineId.toString());
|
||||
await this.comparisonRepo.update(comparison);
|
||||
|
||||
for (const event of comparison.domainEvents) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
comparison.clearEvents();
|
||||
approved++;
|
||||
}
|
||||
|
||||
return Ok({ approved });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { VisualBaseline } from '../../domain/entities/VisualBaseline';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
|
||||
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
|
||||
|
||||
export interface ApproveBaselineRequest {
|
||||
comparisonId: string;
|
||||
approvedBy?: string;
|
||||
}
|
||||
|
||||
export class ApproveBaselineCommand {
|
||||
constructor(
|
||||
private readonly comparisonRepo: IVisualComparisonRepository,
|
||||
private readonly baselineRepo: IVisualBaselineRepository,
|
||||
private readonly eventBus: EventBus
|
||||
) {}
|
||||
|
||||
async execute(req: ApproveBaselineRequest): Promise<Result<{ baselineId: string }, string>> {
|
||||
const comparison = await this.comparisonRepo.findById(req.comparisonId);
|
||||
if (!comparison) {
|
||||
return Err('Comparison not found');
|
||||
}
|
||||
|
||||
const baselineId = UniqueId.create();
|
||||
const baseline = VisualBaseline.create({
|
||||
stateId: comparison.stateId,
|
||||
url: comparison.sessionId,
|
||||
screenshotPath: comparison.currentScreenshotPath,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
approvedAt: new Date(),
|
||||
approvedBy: req.approvedBy ?? 'user',
|
||||
}, baselineId);
|
||||
|
||||
await this.baselineRepo.save(baseline);
|
||||
comparison.approve(baselineId.toString());
|
||||
await this.comparisonRepo.update(comparison);
|
||||
|
||||
for (const event of comparison.domainEvents) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
comparison.clearEvents();
|
||||
|
||||
return Ok({ baselineId: baselineId.toString() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
|
||||
|
||||
export interface RejectComparisonRequest {
|
||||
comparisonId: string;
|
||||
}
|
||||
|
||||
export class RejectComparisonCommand {
|
||||
constructor(private readonly comparisonRepo: IVisualComparisonRepository) {}
|
||||
|
||||
async execute(req: RejectComparisonRequest): Promise<Result<void, string>> {
|
||||
const comparison = await this.comparisonRepo.findById(req.comparisonId);
|
||||
if (!comparison) {
|
||||
return Err('Comparison not found');
|
||||
}
|
||||
|
||||
comparison.reject();
|
||||
await this.comparisonRepo.update(comparison);
|
||||
|
||||
return Ok(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Result, Ok } from '../../../../shared/domain/Result';
|
||||
import { IVisualComparisonRepository, ComparisonFilters } from '../../domain/ports/IVisualComparisonRepository';
|
||||
import { VisualComparison } from '../../domain/entities/VisualComparison';
|
||||
|
||||
export class ListComparisonsQuery {
|
||||
constructor(private readonly repo: IVisualComparisonRepository) {}
|
||||
|
||||
async execute(filters?: ComparisonFilters): Promise<Result<VisualComparison[], never>> {
|
||||
const comparisons = await this.repo.findAll(filters);
|
||||
return Ok(comparisons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Entity } from '../../../../shared/domain/Entity';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
|
||||
export interface VisualBaselineProps {
|
||||
stateId: string;
|
||||
url: string;
|
||||
screenshotPath: string;
|
||||
width: number;
|
||||
height: number;
|
||||
approvedAt: Date;
|
||||
approvedBy: string;
|
||||
}
|
||||
|
||||
export class VisualBaseline extends Entity<VisualBaselineProps> {
|
||||
static create(props: VisualBaselineProps, id?: UniqueId): VisualBaseline {
|
||||
return new VisualBaseline(props, id ?? UniqueId.create());
|
||||
}
|
||||
|
||||
static reconstitute(props: VisualBaselineProps, id: UniqueId): VisualBaseline {
|
||||
return new VisualBaseline(props, id);
|
||||
}
|
||||
|
||||
get stateId(): string { return this.props.stateId; }
|
||||
get url(): string { return this.props.url; }
|
||||
get screenshotPath(): string { return this.props.screenshotPath; }
|
||||
get width(): number { return this.props.width; }
|
||||
get height(): number { return this.props.height; }
|
||||
get approvedAt(): Date { return this.props.approvedAt; }
|
||||
get approvedBy(): string { return this.props.approvedBy; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { ComparisonStatus } from '../value-objects/ComparisonStatus';
|
||||
import { BaselineApproved } from '../events/BaselineApproved';
|
||||
import { RegressionDetected } from '../events/RegressionDetected';
|
||||
|
||||
export interface VisualComparisonProps {
|
||||
sessionId: string;
|
||||
stateId: string;
|
||||
baselineId: string | null;
|
||||
currentScreenshotPath: string;
|
||||
diffScreenshotPath: string | null;
|
||||
diffPixels: number | null;
|
||||
diffPercent: number | null;
|
||||
status: ComparisonStatus;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class VisualComparison extends AggregateRoot<VisualComparisonProps> {
|
||||
static create(props: Omit<VisualComparisonProps, 'createdAt'>, id?: UniqueId): VisualComparison {
|
||||
const comparison = new VisualComparison(
|
||||
{ ...props, createdAt: new Date() },
|
||||
id ?? UniqueId.create()
|
||||
);
|
||||
|
||||
if (props.status.isFailed()) {
|
||||
comparison.addDomainEvent(
|
||||
new RegressionDetected(comparison.id.toString(), {
|
||||
sessionId: props.sessionId,
|
||||
stateId: props.stateId,
|
||||
diffPercent: props.diffPercent ?? 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return comparison;
|
||||
}
|
||||
|
||||
static reconstitute(props: VisualComparisonProps, id: UniqueId): VisualComparison {
|
||||
return new VisualComparison(props, id);
|
||||
}
|
||||
|
||||
get sessionId(): string { return this.props.sessionId; }
|
||||
get stateId(): string { return this.props.stateId; }
|
||||
get baselineId(): string | null { return this.props.baselineId; }
|
||||
get currentScreenshotPath(): string { return this.props.currentScreenshotPath; }
|
||||
get diffScreenshotPath(): string | null { return this.props.diffScreenshotPath; }
|
||||
get diffPixels(): number | null { return this.props.diffPixels; }
|
||||
get diffPercent(): number | null { return this.props.diffPercent; }
|
||||
get status(): ComparisonStatus { return this.props.status; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
|
||||
approve(newBaselineId: string): void {
|
||||
this.props.status = ComparisonStatus.passed();
|
||||
this.props.baselineId = newBaselineId;
|
||||
this.addDomainEvent(
|
||||
new BaselineApproved(this.id.toString(), {
|
||||
sessionId: this.props.sessionId,
|
||||
stateId: this.props.stateId,
|
||||
baselineId: newBaselineId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
this.props.status = ComparisonStatus.failed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class BaselineApproved implements DomainEvent {
|
||||
readonly eventId: string;
|
||||
readonly eventName = 'visual.baseline_approved';
|
||||
readonly occurredOn: Date;
|
||||
|
||||
constructor(
|
||||
readonly aggregateId: string,
|
||||
readonly payload: {
|
||||
sessionId: string;
|
||||
stateId: string;
|
||||
baselineId: string;
|
||||
}
|
||||
) {
|
||||
this.eventId = crypto.randomUUID();
|
||||
this.occurredOn = new Date();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class RegressionDetected implements DomainEvent {
|
||||
readonly eventId: string;
|
||||
readonly eventName = 'visual.regression_detected';
|
||||
readonly occurredOn: Date;
|
||||
|
||||
constructor(
|
||||
readonly aggregateId: string,
|
||||
readonly payload: {
|
||||
sessionId: string;
|
||||
stateId: string;
|
||||
diffPercent: number;
|
||||
}
|
||||
) {
|
||||
this.eventId = crypto.randomUUID();
|
||||
this.occurredOn = new Date();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { VisualBaseline } from '../entities/VisualBaseline';
|
||||
|
||||
export interface IVisualBaselineRepository {
|
||||
save(baseline: VisualBaseline): Promise<void>;
|
||||
findByStateId(stateId: string): Promise<VisualBaseline | null>;
|
||||
findById(id: string): Promise<VisualBaseline | null>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VisualComparison } from '../entities/VisualComparison';
|
||||
|
||||
export interface ComparisonFilters {
|
||||
sessionId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface IVisualComparisonRepository {
|
||||
save(comparison: VisualComparison): Promise<void>;
|
||||
update(comparison: VisualComparison): Promise<void>;
|
||||
findById(id: string): Promise<VisualComparison | null>;
|
||||
findAll(filters?: ComparisonFilters): Promise<VisualComparison[]>;
|
||||
findByStatus(sessionId: string | undefined, status: string): Promise<VisualComparison[]>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
type StatusValue = 'passed' | 'failed' | 'new_state' | 'pending';
|
||||
|
||||
interface ComparisonStatusProps {
|
||||
value: StatusValue;
|
||||
}
|
||||
|
||||
export class ComparisonStatus extends ValueObject<ComparisonStatusProps> {
|
||||
get value(): StatusValue {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
static passed(): ComparisonStatus { return new ComparisonStatus({ value: 'passed' }); }
|
||||
static failed(): ComparisonStatus { return new ComparisonStatus({ value: 'failed' }); }
|
||||
static newState(): ComparisonStatus { return new ComparisonStatus({ value: 'new_state' }); }
|
||||
static pending(): ComparisonStatus { return new ComparisonStatus({ value: 'pending' }); }
|
||||
|
||||
static from(value: string): ComparisonStatus {
|
||||
if (!['passed', 'failed', 'new_state', 'pending'].includes(value)) {
|
||||
throw new Error(`Invalid comparison status: ${value}`);
|
||||
}
|
||||
return new ComparisonStatus({ value: value as StatusValue });
|
||||
}
|
||||
|
||||
isPassed(): boolean { return this.props.value === 'passed'; }
|
||||
isFailed(): boolean { return this.props.value === 'failed'; }
|
||||
isNewState(): boolean { return this.props.value === 'new_state'; }
|
||||
}
|
||||
22
src/modules/visual-regression/index.ts
Normal file
22
src/modules/visual-regression/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Visual Regression Module — Public API
|
||||
|
||||
// Domain
|
||||
export { VisualBaseline } from './domain/entities/VisualBaseline';
|
||||
export { VisualComparison } from './domain/entities/VisualComparison';
|
||||
export { ComparisonStatus } from './domain/value-objects/ComparisonStatus';
|
||||
export { BaselineApproved } from './domain/events/BaselineApproved';
|
||||
export { RegressionDetected } from './domain/events/RegressionDetected';
|
||||
export type { IVisualBaselineRepository } from './domain/ports/IVisualBaselineRepository';
|
||||
export type { IVisualComparisonRepository, ComparisonFilters } from './domain/ports/IVisualComparisonRepository';
|
||||
|
||||
// Application
|
||||
export { ApproveBaselineCommand } from './application/commands/ApproveBaselineCommand';
|
||||
export { RejectComparisonCommand } from './application/commands/RejectComparisonCommand';
|
||||
export { ApproveAllNewStatesCommand } from './application/commands/ApproveAllNewStatesCommand';
|
||||
export { ListComparisonsQuery } from './application/queries/ListComparisonsQuery';
|
||||
|
||||
// Infrastructure
|
||||
export { KyselyVisualBaselineRepository, KyselyVisualComparisonRepository } from './infrastructure/repositories/KyselyVisualRepository';
|
||||
export { VisualRegressionAdapter } from './infrastructure/adapters/VisualRegressionAdapter';
|
||||
export { createVisualRegressionRouter } from './infrastructure/http/VisualRegressionController';
|
||||
export type { VisualRegressionControllerDeps } from './infrastructure/http/VisualRegressionController';
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* VisualRegressionAdapter — wraps screenshot comparison logic.
|
||||
* Uses IStorageProvider for persisting diff images instead of direct fs calls.
|
||||
*/
|
||||
import * as crypto from 'crypto';
|
||||
import * as path from 'path';
|
||||
import { IStorageProvider } from '../../../../shared/infrastructure/StorageProvider';
|
||||
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
|
||||
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
|
||||
import { VisualComparison } from '../../domain/entities/VisualComparison';
|
||||
import { ComparisonStatus } from '../../domain/value-objects/ComparisonStatus';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { IState, IAnomaly } from '../../../../core/interfaces';
|
||||
|
||||
export interface VisualRegressionConfig {
|
||||
enabled: boolean;
|
||||
threshold: number;
|
||||
screenshotFullPage: boolean;
|
||||
ignoreSelectors: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_VISUAL_CONFIG: VisualRegressionConfig = {
|
||||
enabled: true,
|
||||
threshold: 0.001,
|
||||
screenshotFullPage: false,
|
||||
ignoreSelectors: [],
|
||||
};
|
||||
|
||||
async function compareScreenshots(
|
||||
baselinePath: string,
|
||||
currentPath: string,
|
||||
threshold: number
|
||||
): Promise<{ diffPixels: number; diffPercent: number; diffBuffer: Buffer; width: number; height: number }> {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const pixelmatch = (await import('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 };
|
||||
}
|
||||
|
||||
export class VisualRegressionAdapter {
|
||||
private readonly config: VisualRegressionConfig;
|
||||
|
||||
constructor(
|
||||
private readonly storage: IStorageProvider,
|
||||
private readonly baselineRepo: IVisualBaselineRepository,
|
||||
private readonly comparisonRepo: IVisualComparisonRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
config: Partial<VisualRegressionConfig> = {}
|
||||
) {
|
||||
this.config = { ...DEFAULT_VISUAL_CONFIG, ...config };
|
||||
}
|
||||
|
||||
async processScreenshot(
|
||||
screenshotPath: string,
|
||||
state: IState,
|
||||
sessionId: string,
|
||||
actionTrace: IAnomaly['actionTrace']
|
||||
): Promise<IAnomaly | null> {
|
||||
if (!this.config.enabled) return null;
|
||||
|
||||
const comparisonId = crypto.randomUUID();
|
||||
const baseline = await this.baselineRepo.findByStateId(state.id);
|
||||
|
||||
if (!baseline) {
|
||||
const comparison = VisualComparison.create({
|
||||
sessionId,
|
||||
stateId: state.id,
|
||||
baselineId: null,
|
||||
currentScreenshotPath: screenshotPath,
|
||||
diffScreenshotPath: null,
|
||||
diffPixels: null,
|
||||
diffPercent: null,
|
||||
status: ComparisonStatus.newState(),
|
||||
});
|
||||
await this.comparisonRepo.save(comparison);
|
||||
return null;
|
||||
}
|
||||
|
||||
let diffPixels = 0;
|
||||
let diffPercent = 0;
|
||||
let diffScreenshotPath: string | null = 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.failed() : ComparisonStatus.passed();
|
||||
|
||||
const comparison = 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: IAnomaly['severity'];
|
||||
if (pct > 15) severity = 'critical';
|
||||
else if (pct > 5) severity = 'high';
|
||||
else if (pct > 1) severity = 'medium';
|
||||
else severity = 'low';
|
||||
|
||||
const anomaly: IAnomaly = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { isErr, isOk } from '../../../../shared/domain/Result';
|
||||
import { ListComparisonsQuery } from '../../application/queries/ListComparisonsQuery';
|
||||
import { ApproveBaselineCommand } from '../../application/commands/ApproveBaselineCommand';
|
||||
import { RejectComparisonCommand } from '../../application/commands/RejectComparisonCommand';
|
||||
import { ApproveAllNewStatesCommand } from '../../application/commands/ApproveAllNewStatesCommand';
|
||||
|
||||
export interface VisualRegressionControllerDeps {
|
||||
listComparisons: ListComparisonsQuery;
|
||||
approveBaseline: ApproveBaselineCommand;
|
||||
rejectComparison: RejectComparisonCommand;
|
||||
approveAllNewStates: ApproveAllNewStatesCommand;
|
||||
}
|
||||
|
||||
export function createVisualRegressionRouter(deps: VisualRegressionControllerDeps): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/visual/comparisons
|
||||
router.get('/comparisons', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const sessionId = req.query['sessionId'] as string | undefined;
|
||||
const status = req.query['status'] as string | undefined;
|
||||
const result = await deps.listComparisons.execute({ sessionId, status });
|
||||
if (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: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const comparisonId = String(req.params['comparisonId']);
|
||||
const result = await deps.approveBaseline.execute({ comparisonId });
|
||||
if (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: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const comparisonId = String(req.params['comparisonId']);
|
||||
const result = await deps.rejectComparison.execute({ comparisonId });
|
||||
if (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: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId?: string };
|
||||
const result = await deps.approveAllNewStates.execute({ sessionId });
|
||||
if (isErr(result)) {
|
||||
res.status(500).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ approved: result.value.approved });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { VisualBaseline } from '../../domain/entities/VisualBaseline';
|
||||
import { VisualComparison } from '../../domain/entities/VisualComparison';
|
||||
import { ComparisonStatus } from '../../domain/value-objects/ComparisonStatus';
|
||||
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
|
||||
import { IVisualComparisonRepository, ComparisonFilters } from '../../domain/ports/IVisualComparisonRepository';
|
||||
|
||||
export class KyselyVisualBaselineRepository implements IVisualBaselineRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(baseline: VisualBaseline): Promise<void> {
|
||||
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: string): Promise<VisualBaseline | null> {
|
||||
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: string): Promise<VisualBaseline | null> {
|
||||
const row = await this.db
|
||||
.selectFrom('visual_baselines')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
private toDomain(row: {
|
||||
id: string;
|
||||
state_id: string;
|
||||
url: string;
|
||||
screenshot_path: string;
|
||||
approved_at: number;
|
||||
approved_by: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
}): VisualBaseline {
|
||||
return 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.from(row.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class KyselyVisualComparisonRepository implements IVisualComparisonRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(comparison: VisualComparison): Promise<void> {
|
||||
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: VisualComparison): Promise<void> {
|
||||
await this.db.updateTable('visual_comparisons')
|
||||
.set({
|
||||
status: comparison.status.value,
|
||||
baseline_id: comparison.baselineId,
|
||||
})
|
||||
.where('id', '=', comparison.id.toString())
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<VisualComparison | null> {
|
||||
const row = await this.db
|
||||
.selectFrom('visual_comparisons')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findAll(filters?: ComparisonFilters): Promise<VisualComparison[]> {
|
||||
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: string | undefined, status: string): Promise<VisualComparison[]> {
|
||||
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));
|
||||
}
|
||||
|
||||
private toDomain(row: {
|
||||
id: string;
|
||||
session_id: string;
|
||||
state_id: string;
|
||||
baseline_id: string | null;
|
||||
current_screenshot_path: string;
|
||||
diff_screenshot_path: string | null;
|
||||
diff_pixels: number | null;
|
||||
diff_percent: number | null;
|
||||
status: string;
|
||||
created_at: number;
|
||||
}): VisualComparison {
|
||||
return 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.from(row.status),
|
||||
createdAt: new Date(row.created_at),
|
||||
},
|
||||
UniqueId.from(row.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user