fase(20): visual regression refactor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
debian
2026-03-08 06:02:37 -04:00
parent 49e76c92b1
commit 94defee1f8
40 changed files with 1670 additions and 190 deletions

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -65,6 +65,16 @@ import { OnFindingCreated } from './modules/integrations/application/event-handl
import { RSALicenseValidator } from './modules/licensing/infrastructure/validators/RSALicenseValidator';
import { LicenseService } from './modules/licensing/application/LicenseService';
// Visual regression module
import { KyselyVisualBaselineRepository, KyselyVisualComparisonRepository } from './modules/visual-regression/infrastructure/repositories/KyselyVisualRepository';
import { VisualRegressionAdapter } from './modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter';
import { ApproveBaselineCommand } from './modules/visual-regression/application/commands/ApproveBaselineCommand';
import { RejectComparisonCommand } from './modules/visual-regression/application/commands/RejectComparisonCommand';
import { ApproveAllNewStatesCommand } from './modules/visual-regression/application/commands/ApproveAllNewStatesCommand';
import { ListComparisonsQuery } from './modules/visual-regression/application/queries/ListComparisonsQuery';
import { LocalStorageProvider } from './shared/infrastructure/StorageProvider';
import path from 'path';
// Scheduling module
import { KyselyScheduleRepository } from './modules/scheduling/infrastructure/repositories/KyselyScheduleRepository';
import { CreateScheduleCommand } from './modules/scheduling/application/commands/CreateScheduleCommand';
@@ -171,6 +181,23 @@ async function bootstrap(): Promise<void> {
jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
jobQueue.start();
// 11d. Visual regression module
const storageBasePath = path.join(process.cwd(), 'data');
const storageProvider = new LocalStorageProvider(storageBasePath);
const visualBaselineRepo = new KyselyVisualBaselineRepository(db);
const visualComparisonRepo = new KyselyVisualComparisonRepository(db);
const visualRegressionAdapter = new VisualRegressionAdapter(
storageProvider,
visualBaselineRepo,
visualComparisonRepo,
eventBus
);
void visualRegressionAdapter; // used by ExplorationOrchestrator in crawling infra
const listComparisons = new ListComparisonsQuery(visualComparisonRepo);
const approveBaseline = new ApproveBaselineCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
const rejectComparison = new RejectComparisonCommand(visualComparisonRepo);
const approveAllNewStates = new ApproveAllNewStatesCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
// 12b. Scheduling module (after job queue, since it enqueues jobs)
const scheduleRepo = new KyselyScheduleRepository(db);
const createSchedule = new CreateScheduleCommand(scheduleRepo, eventBus);
@@ -191,6 +218,7 @@ async function bootstrap(): Promise<void> {
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
integrationsDeps: { integrationRepo, webhookRepo },
schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo },
visualRegressionDeps: { listComparisons, approveBaseline, rejectComparison, approveAllNewStates },
licenseService,
authDeps: {
registerCommand,

View File

@@ -0,0 +1,48 @@
import { Result, Ok } from '../../../../shared/domain/Result';
import { EventBus } from '../../../../shared/application/EventBus';
import { VisualBaseline } from '../../domain/entities/VisualBaseline';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
export interface ApproveAllNewStatesRequest {
sessionId?: string;
}
export class ApproveAllNewStatesCommand {
constructor(
private readonly comparisonRepo: IVisualComparisonRepository,
private readonly baselineRepo: IVisualBaselineRepository,
private readonly eventBus: EventBus
) {}
async execute(req: ApproveAllNewStatesRequest): Promise<Result<{ approved: number }, string>> {
const pending = await this.comparisonRepo.findByStatus(req.sessionId, 'new_state');
let approved = 0;
for (const comparison of pending) {
const baselineId = UniqueId.create();
const baseline = VisualBaseline.create({
stateId: comparison.stateId,
url: comparison.sessionId,
screenshotPath: comparison.currentScreenshotPath,
width: 1280,
height: 720,
approvedAt: new Date(),
approvedBy: 'user',
}, baselineId);
await this.baselineRepo.save(baseline);
comparison.approve(baselineId.toString());
await this.comparisonRepo.update(comparison);
for (const event of comparison.domainEvents) {
await this.eventBus.publish(event);
}
comparison.clearEvents();
approved++;
}
return Ok({ approved });
}
}

View File

@@ -0,0 +1,48 @@
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { EventBus } from '../../../../shared/application/EventBus';
import { VisualBaseline } from '../../domain/entities/VisualBaseline';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
export interface ApproveBaselineRequest {
comparisonId: string;
approvedBy?: string;
}
export class ApproveBaselineCommand {
constructor(
private readonly comparisonRepo: IVisualComparisonRepository,
private readonly baselineRepo: IVisualBaselineRepository,
private readonly eventBus: EventBus
) {}
async execute(req: ApproveBaselineRequest): Promise<Result<{ baselineId: string }, string>> {
const comparison = await this.comparisonRepo.findById(req.comparisonId);
if (!comparison) {
return Err('Comparison not found');
}
const baselineId = UniqueId.create();
const baseline = VisualBaseline.create({
stateId: comparison.stateId,
url: comparison.sessionId,
screenshotPath: comparison.currentScreenshotPath,
width: 1280,
height: 720,
approvedAt: new Date(),
approvedBy: req.approvedBy ?? 'user',
}, baselineId);
await this.baselineRepo.save(baseline);
comparison.approve(baselineId.toString());
await this.comparisonRepo.update(comparison);
for (const event of comparison.domainEvents) {
await this.eventBus.publish(event);
}
comparison.clearEvents();
return Ok({ baselineId: baselineId.toString() });
}
}

View File

@@ -0,0 +1,22 @@
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
export interface RejectComparisonRequest {
comparisonId: string;
}
export class RejectComparisonCommand {
constructor(private readonly comparisonRepo: IVisualComparisonRepository) {}
async execute(req: RejectComparisonRequest): Promise<Result<void, string>> {
const comparison = await this.comparisonRepo.findById(req.comparisonId);
if (!comparison) {
return Err('Comparison not found');
}
comparison.reject();
await this.comparisonRepo.update(comparison);
return Ok(undefined);
}
}

View File

@@ -0,0 +1,12 @@
import { Result, Ok } from '../../../../shared/domain/Result';
import { IVisualComparisonRepository, ComparisonFilters } from '../../domain/ports/IVisualComparisonRepository';
import { VisualComparison } from '../../domain/entities/VisualComparison';
export class ListComparisonsQuery {
constructor(private readonly repo: IVisualComparisonRepository) {}
async execute(filters?: ComparisonFilters): Promise<Result<VisualComparison[], never>> {
const comparisons = await this.repo.findAll(filters);
return Ok(comparisons);
}
}

View File

@@ -0,0 +1,30 @@
import { Entity } from '../../../../shared/domain/Entity';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export interface VisualBaselineProps {
stateId: string;
url: string;
screenshotPath: string;
width: number;
height: number;
approvedAt: Date;
approvedBy: string;
}
export class VisualBaseline extends Entity<VisualBaselineProps> {
static create(props: VisualBaselineProps, id?: UniqueId): VisualBaseline {
return new VisualBaseline(props, id ?? UniqueId.create());
}
static reconstitute(props: VisualBaselineProps, id: UniqueId): VisualBaseline {
return new VisualBaseline(props, id);
}
get stateId(): string { return this.props.stateId; }
get url(): string { return this.props.url; }
get screenshotPath(): string { return this.props.screenshotPath; }
get width(): number { return this.props.width; }
get height(): number { return this.props.height; }
get approvedAt(): Date { return this.props.approvedAt; }
get approvedBy(): string { return this.props.approvedBy; }
}

View File

@@ -0,0 +1,68 @@
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { ComparisonStatus } from '../value-objects/ComparisonStatus';
import { BaselineApproved } from '../events/BaselineApproved';
import { RegressionDetected } from '../events/RegressionDetected';
export interface VisualComparisonProps {
sessionId: string;
stateId: string;
baselineId: string | null;
currentScreenshotPath: string;
diffScreenshotPath: string | null;
diffPixels: number | null;
diffPercent: number | null;
status: ComparisonStatus;
createdAt: Date;
}
export class VisualComparison extends AggregateRoot<VisualComparisonProps> {
static create(props: Omit<VisualComparisonProps, 'createdAt'>, id?: UniqueId): VisualComparison {
const comparison = new VisualComparison(
{ ...props, createdAt: new Date() },
id ?? UniqueId.create()
);
if (props.status.isFailed()) {
comparison.addDomainEvent(
new RegressionDetected(comparison.id.toString(), {
sessionId: props.sessionId,
stateId: props.stateId,
diffPercent: props.diffPercent ?? 0,
})
);
}
return comparison;
}
static reconstitute(props: VisualComparisonProps, id: UniqueId): VisualComparison {
return new VisualComparison(props, id);
}
get sessionId(): string { return this.props.sessionId; }
get stateId(): string { return this.props.stateId; }
get baselineId(): string | null { return this.props.baselineId; }
get currentScreenshotPath(): string { return this.props.currentScreenshotPath; }
get diffScreenshotPath(): string | null { return this.props.diffScreenshotPath; }
get diffPixels(): number | null { return this.props.diffPixels; }
get diffPercent(): number | null { return this.props.diffPercent; }
get status(): ComparisonStatus { return this.props.status; }
get createdAt(): Date { return this.props.createdAt; }
approve(newBaselineId: string): void {
this.props.status = ComparisonStatus.passed();
this.props.baselineId = newBaselineId;
this.addDomainEvent(
new BaselineApproved(this.id.toString(), {
sessionId: this.props.sessionId,
stateId: this.props.stateId,
baselineId: newBaselineId,
})
);
}
reject(): void {
this.props.status = ComparisonStatus.failed();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,7 @@
import { VisualBaseline } from '../entities/VisualBaseline';
export interface IVisualBaselineRepository {
save(baseline: VisualBaseline): Promise<void>;
findByStateId(stateId: string): Promise<VisualBaseline | null>;
findById(id: string): Promise<VisualBaseline | null>;
}

View File

@@ -0,0 +1,14 @@
import { VisualComparison } from '../entities/VisualComparison';
export interface ComparisonFilters {
sessionId?: string;
status?: string;
}
export interface IVisualComparisonRepository {
save(comparison: VisualComparison): Promise<void>;
update(comparison: VisualComparison): Promise<void>;
findById(id: string): Promise<VisualComparison | null>;
findAll(filters?: ComparisonFilters): Promise<VisualComparison[]>;
findByStatus(sessionId: string | undefined, status: string): Promise<VisualComparison[]>;
}

View File

@@ -0,0 +1,29 @@
import { ValueObject } from '../../../../shared/domain/ValueObject';
type StatusValue = 'passed' | 'failed' | 'new_state' | 'pending';
interface ComparisonStatusProps {
value: StatusValue;
}
export class ComparisonStatus extends ValueObject<ComparisonStatusProps> {
get value(): StatusValue {
return this.props.value;
}
static passed(): ComparisonStatus { return new ComparisonStatus({ value: 'passed' }); }
static failed(): ComparisonStatus { return new ComparisonStatus({ value: 'failed' }); }
static newState(): ComparisonStatus { return new ComparisonStatus({ value: 'new_state' }); }
static pending(): ComparisonStatus { return new ComparisonStatus({ value: 'pending' }); }
static from(value: string): ComparisonStatus {
if (!['passed', 'failed', 'new_state', 'pending'].includes(value)) {
throw new Error(`Invalid comparison status: ${value}`);
}
return new ComparisonStatus({ value: value as StatusValue });
}
isPassed(): boolean { return this.props.value === 'passed'; }
isFailed(): boolean { return this.props.value === 'failed'; }
isNewState(): boolean { return this.props.value === 'new_state'; }
}

View File

@@ -0,0 +1,22 @@
// Visual Regression Module — Public API
// Domain
export { VisualBaseline } from './domain/entities/VisualBaseline';
export { VisualComparison } from './domain/entities/VisualComparison';
export { ComparisonStatus } from './domain/value-objects/ComparisonStatus';
export { BaselineApproved } from './domain/events/BaselineApproved';
export { RegressionDetected } from './domain/events/RegressionDetected';
export type { IVisualBaselineRepository } from './domain/ports/IVisualBaselineRepository';
export type { IVisualComparisonRepository, ComparisonFilters } from './domain/ports/IVisualComparisonRepository';
// Application
export { ApproveBaselineCommand } from './application/commands/ApproveBaselineCommand';
export { RejectComparisonCommand } from './application/commands/RejectComparisonCommand';
export { ApproveAllNewStatesCommand } from './application/commands/ApproveAllNewStatesCommand';
export { ListComparisonsQuery } from './application/queries/ListComparisonsQuery';
// Infrastructure
export { KyselyVisualBaselineRepository, KyselyVisualComparisonRepository } from './infrastructure/repositories/KyselyVisualRepository';
export { VisualRegressionAdapter } from './infrastructure/adapters/VisualRegressionAdapter';
export { createVisualRegressionRouter } from './infrastructure/http/VisualRegressionController';
export type { VisualRegressionControllerDeps } from './infrastructure/http/VisualRegressionController';

View File

@@ -0,0 +1,171 @@
/**
* VisualRegressionAdapter — wraps screenshot comparison logic.
* Uses IStorageProvider for persisting diff images instead of direct fs calls.
*/
import * as crypto from 'crypto';
import * as path from 'path';
import { IStorageProvider } from '../../../../shared/infrastructure/StorageProvider';
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
import { IVisualComparisonRepository } from '../../domain/ports/IVisualComparisonRepository';
import { VisualComparison } from '../../domain/entities/VisualComparison';
import { ComparisonStatus } from '../../domain/value-objects/ComparisonStatus';
import { EventBus } from '../../../../shared/application/EventBus';
import { IState, IAnomaly } from '../../../../core/interfaces';
export interface VisualRegressionConfig {
enabled: boolean;
threshold: number;
screenshotFullPage: boolean;
ignoreSelectors: string[];
}
export const DEFAULT_VISUAL_CONFIG: VisualRegressionConfig = {
enabled: true,
threshold: 0.001,
screenshotFullPage: false,
ignoreSelectors: [],
};
async function compareScreenshots(
baselinePath: string,
currentPath: string,
threshold: number
): Promise<{ diffPixels: number; diffPercent: number; diffBuffer: Buffer; width: number; height: number }> {
const sharp = (await import('sharp')).default;
const pixelmatch = (await import('pixelmatch')).default;
const [baselineRaw, currentRaw] = await Promise.all([
sharp(baselinePath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
sharp(currentPath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
]);
const { width, height } = baselineRaw.info;
const diffBuffer = Buffer.alloc(width * height * 4);
const diffPixels = pixelmatch(
baselineRaw.data,
currentRaw.data,
diffBuffer,
width,
height,
{ threshold }
);
const totalPixels = width * height;
const diffPercent = totalPixels > 0 ? diffPixels / totalPixels : 0;
// Encode diff as PNG
const pngBuffer = await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
.png()
.toBuffer();
return { diffPixels, diffPercent, diffBuffer: pngBuffer, width, height };
}
export class VisualRegressionAdapter {
private readonly config: VisualRegressionConfig;
constructor(
private readonly storage: IStorageProvider,
private readonly baselineRepo: IVisualBaselineRepository,
private readonly comparisonRepo: IVisualComparisonRepository,
private readonly eventBus: EventBus,
config: Partial<VisualRegressionConfig> = {}
) {
this.config = { ...DEFAULT_VISUAL_CONFIG, ...config };
}
async processScreenshot(
screenshotPath: string,
state: IState,
sessionId: string,
actionTrace: IAnomaly['actionTrace']
): Promise<IAnomaly | null> {
if (!this.config.enabled) return null;
const comparisonId = crypto.randomUUID();
const baseline = await this.baselineRepo.findByStateId(state.id);
if (!baseline) {
const comparison = VisualComparison.create({
sessionId,
stateId: state.id,
baselineId: null,
currentScreenshotPath: screenshotPath,
diffScreenshotPath: null,
diffPixels: null,
diffPercent: null,
status: ComparisonStatus.newState(),
});
await this.comparisonRepo.save(comparison);
return null;
}
let diffPixels = 0;
let diffPercent = 0;
let diffScreenshotPath: string | null = null;
try {
const result = await compareScreenshots(
baseline.screenshotPath,
screenshotPath,
this.config.threshold
);
diffPixels = result.diffPixels;
diffPercent = result.diffPercent;
if (diffPixels > 0) {
const diffRelativePath = path.join('visual', comparisonId, 'diff.png');
diffScreenshotPath = await this.storage.save(diffRelativePath, result.diffBuffer);
}
} catch {
return null;
}
const hasFailed = diffPercent > this.config.threshold;
const status = hasFailed ? ComparisonStatus.failed() : ComparisonStatus.passed();
const comparison = VisualComparison.create({
sessionId,
stateId: state.id,
baselineId: baseline.id.toString(),
currentScreenshotPath: screenshotPath,
diffScreenshotPath,
diffPixels,
diffPercent,
status,
});
await this.comparisonRepo.save(comparison);
// Publish domain events
for (const event of comparison.domainEvents) {
await this.eventBus.publish(event);
}
comparison.clearEvents();
if (!hasFailed) return null;
const pct = diffPercent * 100;
let severity: IAnomaly['severity'];
if (pct > 15) severity = 'critical';
else if (pct > 5) severity = 'high';
else if (pct > 1) severity = 'medium';
else severity = 'low';
const anomaly: IAnomaly = {
id: crypto.randomUUID(),
type: 'visual_regression',
severity,
observationId: state.id,
actionTrace,
description: `Visual regression detected: ${pct.toFixed(2)}% of pixels changed`,
evidence: {
screenshotPath: diffScreenshotPath ?? screenshotPath,
rawErrors: [`Diff: ${diffPixels} pixels (${pct.toFixed(2)}%)`],
},
timestamp: Date.now(),
};
return anomaly;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,170 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { VisualBaseline } from '../../domain/entities/VisualBaseline';
import { VisualComparison } from '../../domain/entities/VisualComparison';
import { ComparisonStatus } from '../../domain/value-objects/ComparisonStatus';
import { IVisualBaselineRepository } from '../../domain/ports/IVisualBaselineRepository';
import { IVisualComparisonRepository, ComparisonFilters } from '../../domain/ports/IVisualComparisonRepository';
export class KyselyVisualBaselineRepository implements IVisualBaselineRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(baseline: VisualBaseline): Promise<void> {
await this.db.insertInto('visual_baselines').values({
id: baseline.id.toString(),
state_id: baseline.stateId,
url: baseline.url,
screenshot_path: baseline.screenshotPath,
approved_at: baseline.approvedAt.getTime(),
approved_by: baseline.approvedBy,
width: baseline.width,
height: baseline.height,
}).onConflict(oc => oc.column('id').doUpdateSet({
screenshot_path: baseline.screenshotPath,
approved_at: baseline.approvedAt.getTime(),
approved_by: baseline.approvedBy,
})).execute();
}
async findByStateId(stateId: string): Promise<VisualBaseline | null> {
const row = await this.db
.selectFrom('visual_baselines')
.selectAll()
.where('state_id', '=', stateId)
.orderBy('approved_at', 'desc')
.limit(1)
.executeTakeFirst();
return row ? this.toDomain(row) : null;
}
async findById(id: string): Promise<VisualBaseline | null> {
const row = await this.db
.selectFrom('visual_baselines')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : null;
}
private toDomain(row: {
id: string;
state_id: string;
url: string;
screenshot_path: string;
approved_at: number;
approved_by: string | null;
width: number;
height: number;
}): VisualBaseline {
return VisualBaseline.reconstitute(
{
stateId: row.state_id,
url: row.url,
screenshotPath: row.screenshot_path,
width: row.width,
height: row.height,
approvedAt: new Date(row.approved_at),
approvedBy: row.approved_by ?? 'user',
},
UniqueId.from(row.id)
);
}
}
export class KyselyVisualComparisonRepository implements IVisualComparisonRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(comparison: VisualComparison): Promise<void> {
await this.db.insertInto('visual_comparisons').values({
id: comparison.id.toString(),
session_id: comparison.sessionId,
state_id: comparison.stateId,
baseline_id: comparison.baselineId,
current_screenshot_path: comparison.currentScreenshotPath,
diff_screenshot_path: comparison.diffScreenshotPath,
diff_pixels: comparison.diffPixels,
diff_percent: comparison.diffPercent,
status: comparison.status.value,
created_at: comparison.createdAt.getTime(),
}).execute();
}
async update(comparison: VisualComparison): Promise<void> {
await this.db.updateTable('visual_comparisons')
.set({
status: comparison.status.value,
baseline_id: comparison.baselineId,
})
.where('id', '=', comparison.id.toString())
.execute();
}
async findById(id: string): Promise<VisualComparison | null> {
const row = await this.db
.selectFrom('visual_comparisons')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : null;
}
async findAll(filters?: ComparisonFilters): Promise<VisualComparison[]> {
let query = this.db.selectFrom('visual_comparisons').selectAll();
if (filters?.sessionId) {
query = query.where('session_id', '=', filters.sessionId);
}
if (filters?.status) {
query = query.where('status', '=', filters.status);
}
const rows = await query.orderBy('created_at', 'desc').execute();
return rows.map((r) => this.toDomain(r));
}
async findByStatus(sessionId: string | undefined, status: string): Promise<VisualComparison[]> {
let query = this.db
.selectFrom('visual_comparisons')
.selectAll()
.where('status', '=', status);
if (sessionId) {
query = query.where('session_id', '=', sessionId);
}
const rows = await query.orderBy('created_at', 'desc').execute();
return rows.map((r) => this.toDomain(r));
}
private toDomain(row: {
id: string;
session_id: string;
state_id: string;
baseline_id: string | null;
current_screenshot_path: string;
diff_screenshot_path: string | null;
diff_pixels: number | null;
diff_percent: number | null;
status: string;
created_at: number;
}): VisualComparison {
return VisualComparison.reconstitute(
{
sessionId: row.session_id,
stateId: row.state_id,
baselineId: row.baseline_id,
currentScreenshotPath: row.current_screenshot_path,
diffScreenshotPath: row.diff_screenshot_path,
diffPixels: row.diff_pixels,
diffPercent: row.diff_percent,
status: ComparisonStatus.from(row.status),
createdAt: new Date(row.created_at),
},
UniqueId.from(row.id)
);
}
}