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)}% - )} -

-
- -
+ { if (!open) onClose(); }}> + + + + Visual Review + + {STATUS_LABEL[comparison.status]} + + +

+ State: {comparison.state_id.slice(0, 16)}… + {comparison.diff_percent !== null && ( + + {comparison.diff_percent.toFixed(2)}% diff + + )} +

+
-
- {/* Baseline */} +
-

Baseline

+

Baseline

{comparison.baseline_id ? ( -
- Baseline +
+ Baseline
) : ( -
No baseline
+
+ No baseline +
)}
- {/* Current */}
-

Current

+

Current

{currentUrl ? ( -
+
Current screenshot
) : ( -
No screenshot
+
+ No screenshot +
)}
- {/* Diff */}
-

Diff

+

Diff

{diffUrl ? ( -
+
Diff image
) : ( -
No diff
+
+ No diff +
)}
-
+ {comparison.status !== 'passed' && ( - + {approveMutation.isPending ? 'Approving…' : 'Approve as Baseline'} + )} {comparison.status === 'failed' && ( - + {rejectMutation.isPending ? 'Rejecting…' : 'Mark as Rejected'} + )} - -
-
-
+ + + +
); } -const ALL_STATUSES: Array = ['all', 'new_state', 'failed', 'passed', 'pending']; - export function VisualReview() { - const [comparisons, setComparisons] = useState([]); const [statusFilter, setStatusFilter] = useState('all'); - const [loading, setLoading] = useState(true); const [selected, setSelected] = useState(null); - const [approvingAll, setApprovingAll] = useState(false); - const [bulkResult, setBulkResult] = useState(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 ( -
-
+
+
- ← Dashboard -

Visual Regression Review

-

- {failedCount > 0 && {failedCount} regression{failedCount > 1 ? 's' : ''}} - {newStateCount > 0 && {newStateCount} new state{newStateCount > 1 ? 's' : ''}} +

Visual Regression Review

+

+ {failedCount > 0 && ( + + {failedCount} regression{failedCount !== 1 ? 's' : ''} + + )} + {newStateCount > 0 && ( + + {newStateCount} new state{newStateCount !== 1 ? 's' : ''} + + )} + {failedCount === 0 && newStateCount === 0 && comparisons.length > 0 && ( + All comparisons reviewed + )}

{newStateCount > 0 && ( - + {approveAllMutation.isPending + ? 'Approving…' + : `Approve All New (${newStateCount})`} + )} -
- - {bulkResult && ( -
- {bulkResult} -
- )} - - {/* Status filter */} -
- {ALL_STATUSES.map((s) => ( - - ))}
- {loading ? ( -

Loading comparisons…

- ) : comparisons.length === 0 ? ( -
- No visual comparisons found. Run an exploration with visual regression enabled. + {/* Status filter */} +
+ Filter: + +
+ + {/* Grid */} + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))}
+ ) : comparisons.length === 0 ? ( + + + No visual comparisons found. Run an exploration with visual regression enabled. + + ) : (
{comparisons.map((cmp) => { const imgUrl = screenshotUrl(cmp.current_screenshot_path); return ( - + + ); })}
)} {selected && ( - { setSelected(null); load(); }} - onReject={() => { setSelected(null); load(); }} onClose={() => setSelected(null)} + onApproved={() => { setSelected(null); handleRefresh(); }} + onRejected={() => { setSelected(null); handleRefresh(); }} /> )}
diff --git a/src/api/router.ts b/src/api/router.ts index 7185bcb..1936e53 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -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); diff --git a/src/api/server.ts b/src/api/server.ts index cc6cfac..a857688 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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; } diff --git a/src/main.ts b/src/main.ts index 357d978..ce22291 100644 --- a/src/main.ts +++ b/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 { 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 { 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/src/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.ts b/src/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.ts new file mode 100644 index 0000000..4da17ab --- /dev/null +++ b/src/modules/visual-regression/application/commands/ApproveAllNewStatesCommand.ts @@ -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> { + 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 }); + } +} diff --git a/src/modules/visual-regression/application/commands/ApproveBaselineCommand.ts b/src/modules/visual-regression/application/commands/ApproveBaselineCommand.ts new file mode 100644 index 0000000..5cba65d --- /dev/null +++ b/src/modules/visual-regression/application/commands/ApproveBaselineCommand.ts @@ -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> { + 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() }); + } +} diff --git a/src/modules/visual-regression/application/commands/RejectComparisonCommand.ts b/src/modules/visual-regression/application/commands/RejectComparisonCommand.ts new file mode 100644 index 0000000..a0c7134 --- /dev/null +++ b/src/modules/visual-regression/application/commands/RejectComparisonCommand.ts @@ -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> { + 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); + } +} diff --git a/src/modules/visual-regression/application/queries/ListComparisonsQuery.ts b/src/modules/visual-regression/application/queries/ListComparisonsQuery.ts new file mode 100644 index 0000000..b6749ce --- /dev/null +++ b/src/modules/visual-regression/application/queries/ListComparisonsQuery.ts @@ -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> { + const comparisons = await this.repo.findAll(filters); + return Ok(comparisons); + } +} diff --git a/src/modules/visual-regression/domain/entities/VisualBaseline.ts b/src/modules/visual-regression/domain/entities/VisualBaseline.ts new file mode 100644 index 0000000..2d360ce --- /dev/null +++ b/src/modules/visual-regression/domain/entities/VisualBaseline.ts @@ -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 { + 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; } +} diff --git a/src/modules/visual-regression/domain/entities/VisualComparison.ts b/src/modules/visual-regression/domain/entities/VisualComparison.ts new file mode 100644 index 0000000..c37f35f --- /dev/null +++ b/src/modules/visual-regression/domain/entities/VisualComparison.ts @@ -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 { + static create(props: Omit, 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(); + } +} diff --git a/src/modules/visual-regression/domain/events/BaselineApproved.ts b/src/modules/visual-regression/domain/events/BaselineApproved.ts new file mode 100644 index 0000000..57178a4 --- /dev/null +++ b/src/modules/visual-regression/domain/events/BaselineApproved.ts @@ -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(); + } +} diff --git a/src/modules/visual-regression/domain/events/RegressionDetected.ts b/src/modules/visual-regression/domain/events/RegressionDetected.ts new file mode 100644 index 0000000..36c65db --- /dev/null +++ b/src/modules/visual-regression/domain/events/RegressionDetected.ts @@ -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(); + } +} diff --git a/src/modules/visual-regression/domain/ports/IVisualBaselineRepository.ts b/src/modules/visual-regression/domain/ports/IVisualBaselineRepository.ts new file mode 100644 index 0000000..ee4ec11 --- /dev/null +++ b/src/modules/visual-regression/domain/ports/IVisualBaselineRepository.ts @@ -0,0 +1,7 @@ +import { VisualBaseline } from '../entities/VisualBaseline'; + +export interface IVisualBaselineRepository { + save(baseline: VisualBaseline): Promise; + findByStateId(stateId: string): Promise; + findById(id: string): Promise; +} diff --git a/src/modules/visual-regression/domain/ports/IVisualComparisonRepository.ts b/src/modules/visual-regression/domain/ports/IVisualComparisonRepository.ts new file mode 100644 index 0000000..3cab8de --- /dev/null +++ b/src/modules/visual-regression/domain/ports/IVisualComparisonRepository.ts @@ -0,0 +1,14 @@ +import { VisualComparison } from '../entities/VisualComparison'; + +export interface ComparisonFilters { + sessionId?: string; + status?: string; +} + +export interface IVisualComparisonRepository { + save(comparison: VisualComparison): Promise; + update(comparison: VisualComparison): Promise; + findById(id: string): Promise; + findAll(filters?: ComparisonFilters): Promise; + findByStatus(sessionId: string | undefined, status: string): Promise; +} diff --git a/src/modules/visual-regression/domain/value-objects/ComparisonStatus.ts b/src/modules/visual-regression/domain/value-objects/ComparisonStatus.ts new file mode 100644 index 0000000..10a3af6 --- /dev/null +++ b/src/modules/visual-regression/domain/value-objects/ComparisonStatus.ts @@ -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 { + 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'; } +} diff --git a/src/modules/visual-regression/index.ts b/src/modules/visual-regression/index.ts new file mode 100644 index 0000000..4e470ca --- /dev/null +++ b/src/modules/visual-regression/index.ts @@ -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'; diff --git a/src/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.ts b/src/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.ts new file mode 100644 index 0000000..f236ed0 --- /dev/null +++ b/src/modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter.ts @@ -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 = {} + ) { + this.config = { ...DEFAULT_VISUAL_CONFIG, ...config }; + } + + async processScreenshot( + screenshotPath: string, + state: IState, + sessionId: string, + actionTrace: IAnomaly['actionTrace'] + ): Promise { + 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; + } +} diff --git a/src/modules/visual-regression/infrastructure/http/VisualRegressionController.ts b/src/modules/visual-regression/infrastructure/http/VisualRegressionController.ts new file mode 100644 index 0000000..fe48225 --- /dev/null +++ b/src/modules/visual-regression/infrastructure/http/VisualRegressionController.ts @@ -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; +} diff --git a/src/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.ts b/src/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.ts new file mode 100644 index 0000000..6fdb86e --- /dev/null +++ b/src/modules/visual-regression/infrastructure/repositories/KyselyVisualRepository.ts @@ -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) {} + + async save(baseline: VisualBaseline): Promise { + 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 { + 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 { + 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) {} + + async save(comparison: VisualComparison): Promise { + 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 { + 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 { + const row = await this.db + .selectFrom('visual_comparisons') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + + return row ? this.toDomain(row) : null; + } + + async findAll(filters?: ComparisonFilters): Promise { + 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 { + 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) + ); + } +}