diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha
index 13f2495..d1bb909 100644
--- a/.ralph/.loop_start_sha
+++ b/.ralph/.loop_start_sha
@@ -1 +1 @@
-1cf597fee15fa5299f89d65d6bc8613dfc5af240
+49e76c92b17a3510da50ae1deaf8002c5c67d010
diff --git a/.ralph/progress.json b/.ralph/progress.json
index 6dcda21..2a47513 100644
--- a/.ralph/progress.json
+++ b/.ralph/progress.json
@@ -1 +1 @@
-{"status": "failed", "timestamp": "2026-03-08 05:42:52"}
+{"status": "completed", "timestamp": "2026-03-08 05:49:12"}
diff --git a/.ralphrc b/.ralphrc
index 6841dcb..aea28c5 100644
--- a/.ralphrc
+++ b/.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 *)"
diff --git a/dist/api/router.js b/dist/api/router.js
index 05821df..8c5c583 100644
--- a/dist/api/router.js
+++ b/dist/api/router.js
@@ -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);
diff --git a/dist/main.js b/dist/main.js
index f2592e0..757fc56 100644
--- a/dist/main.js
+++ b/dist/main.js
@@ -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,
diff --git a/dist/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.js b/dist/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.js
new file mode 100644
index 0000000..5a1f7d3
--- /dev/null
+++ b/dist/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.js
@@ -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;
diff --git a/dist/modules/visual-regression/application/commands/ApproveBaselineCommand.js b/dist/modules/visual-regression/application/commands/ApproveBaselineCommand.js
new file mode 100644
index 0000000..0a52901
--- /dev/null
+++ b/dist/modules/visual-regression/application/commands/ApproveBaselineCommand.js
@@ -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;
diff --git a/dist/modules/visual-regression/application/commands/RejectComparisonCommand.js b/dist/modules/visual-regression/application/commands/RejectComparisonCommand.js
new file mode 100644
index 0000000..42a9fad
--- /dev/null
+++ b/dist/modules/visual-regression/application/commands/RejectComparisonCommand.js
@@ -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;
diff --git a/dist/modules/visual-regression/application/queries/ListComparisonsQuery.js b/dist/modules/visual-regression/application/queries/ListComparisonsQuery.js
new file mode 100644
index 0000000..ec5d31a
--- /dev/null
+++ b/dist/modules/visual-regression/application/queries/ListComparisonsQuery.js
@@ -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;
diff --git a/dist/modules/visual-regression/domain/entities/VisualBaseline.js b/dist/modules/visual-regression/domain/entities/VisualBaseline.js
new file mode 100644
index 0000000..83f91d4
--- /dev/null
+++ b/dist/modules/visual-regression/domain/entities/VisualBaseline.js
@@ -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;
diff --git a/dist/modules/visual-regression/domain/entities/VisualComparison.js b/dist/modules/visual-regression/domain/entities/VisualComparison.js
new file mode 100644
index 0000000..7aa5e0d
--- /dev/null
+++ b/dist/modules/visual-regression/domain/entities/VisualComparison.js
@@ -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;
diff --git a/dist/modules/visual-regression/domain/events/BaselineApproved.js b/dist/modules/visual-regression/domain/events/BaselineApproved.js
new file mode 100644
index 0000000..b059f72
--- /dev/null
+++ b/dist/modules/visual-regression/domain/events/BaselineApproved.js
@@ -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;
diff --git a/dist/modules/visual-regression/domain/events/RegressionDetected.js b/dist/modules/visual-regression/domain/events/RegressionDetected.js
new file mode 100644
index 0000000..8b702a4
--- /dev/null
+++ b/dist/modules/visual-regression/domain/events/RegressionDetected.js
@@ -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;
diff --git a/dist/modules/visual-regression/domain/ports/IVisualBaselineRepository.js b/dist/modules/visual-regression/domain/ports/IVisualBaselineRepository.js
new file mode 100644
index 0000000..c8ad2e5
--- /dev/null
+++ b/dist/modules/visual-regression/domain/ports/IVisualBaselineRepository.js
@@ -0,0 +1,2 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/dist/modules/visual-regression/domain/ports/IVisualComparisonRepository.js b/dist/modules/visual-regression/domain/ports/IVisualComparisonRepository.js
new file mode 100644
index 0000000..c8ad2e5
--- /dev/null
+++ b/dist/modules/visual-regression/domain/ports/IVisualComparisonRepository.js
@@ -0,0 +1,2 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/dist/modules/visual-regression/domain/value-objects/ComparisonStatus.js b/dist/modules/visual-regression/domain/value-objects/ComparisonStatus.js
new file mode 100644
index 0000000..cdb8e54
--- /dev/null
+++ b/dist/modules/visual-regression/domain/value-objects/ComparisonStatus.js
@@ -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;
diff --git a/dist/modules/visual-regression/index.js b/dist/modules/visual-regression/index.js
new file mode 100644
index 0000000..4d1c202
--- /dev/null
+++ b/dist/modules/visual-regression/index.js
@@ -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; } });
diff --git a/dist/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.js b/dist/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.js
new file mode 100644
index 0000000..00c4472
--- /dev/null
+++ b/dist/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.js
@@ -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;
diff --git a/dist/modules/visual-regression/infrastructure/http/VisualRegressionController.js b/dist/modules/visual-regression/infrastructure/http/VisualRegressionController.js
new file mode 100644
index 0000000..88079ad
--- /dev/null
+++ b/dist/modules/visual-regression/infrastructure/http/VisualRegressionController.js
@@ -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;
+}
diff --git a/dist/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.js b/dist/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.js
new file mode 100644
index 0000000..ad3f9d6
--- /dev/null
+++ b/dist/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.js
@@ -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;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7c2287a..4ac6f7a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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
Visual Review — Coming in Phase 20
-}
+import { VisualReview } from '@/pages/VisualReview'
export default function App() {
return (
diff --git a/frontend/src/pages/VisualReview.tsx b/frontend/src/pages/VisualReview.tsx
index d88be5c..93b6c1c 100644
--- a/frontend/src/pages/VisualReview.tsx
+++ b/frontend/src/pages/VisualReview.tsx
@@ -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 = {
+const STATUS_LABEL: Record = {
+ all: 'All',
new_state: 'New State',
failed: 'Regression',
passed: 'Passed',
pending: 'Pending',
};
-const STATUS_COLORS: Record = {
- 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 = {
+ 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/
- 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 (
-
-
e.stopPropagation()}
- >
-
-
-
Visual Review
-
- State: {comparison.state_id.slice(0, 12)}…
- {comparison.diff_percent !== null && (
- Diff: {comparison.diff_percent.toFixed(2)}%
- )}
-
-
-
-
+