docs: enterprise refactor plan with ralph specs
This commit is contained in:
124
dist/plugins/collectors/AccessibilityCollector.js
vendored
Normal file
124
dist/plugins/collectors/AccessibilityCollector.js
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
"use strict";
|
||||
/**
|
||||
* AccessibilityCollector — runs axe-core after state changes to detect WCAG violations.
|
||||
* Converts axe violations to IAnomaly with severity mapped from impact level.
|
||||
*/
|
||||
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.AccessibilityCollector = exports.DEFAULT_A11Y_CONFIG = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
exports.DEFAULT_A11Y_CONFIG = {
|
||||
enabled: true,
|
||||
minImpact: 'serious',
|
||||
wcagLevel: 'AA',
|
||||
};
|
||||
const IMPACT_TO_SEVERITY = {
|
||||
minor: 'low',
|
||||
moderate: 'medium',
|
||||
serious: 'high',
|
||||
critical: 'critical',
|
||||
};
|
||||
const IMPACT_RANK = {
|
||||
minor: 0,
|
||||
moderate: 1,
|
||||
serious: 2,
|
||||
critical: 3,
|
||||
};
|
||||
class AccessibilityCollector {
|
||||
constructor(config = {}) {
|
||||
this.config = { ...exports.DEFAULT_A11Y_CONFIG, ...config };
|
||||
}
|
||||
async collect(page, stateId, sessionId, actionTrace) {
|
||||
if (!this.config.enabled)
|
||||
return [];
|
||||
try {
|
||||
const violations = await this.runAxe(page);
|
||||
const minRank = IMPACT_RANK[this.config.minImpact] ?? 2;
|
||||
const anomalies = [];
|
||||
for (const violation of violations) {
|
||||
const impact = violation.impact ?? 'minor';
|
||||
if ((IMPACT_RANK[impact] ?? 0) < minRank)
|
||||
continue;
|
||||
const severity = IMPACT_TO_SEVERITY[impact] ?? 'medium';
|
||||
anomalies.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'accessibility_violation',
|
||||
severity,
|
||||
observationId: stateId,
|
||||
actionTrace,
|
||||
description: `[axe] ${violation.description}`,
|
||||
evidence: {
|
||||
rawErrors: [
|
||||
`Rule: ${violation.id}`,
|
||||
`Impact: ${impact}`,
|
||||
`Affected nodes: ${violation.nodes.length}`,
|
||||
`Help: ${violation.helpUrl}`,
|
||||
],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
return anomalies;
|
||||
}
|
||||
catch {
|
||||
// axe might fail if page is not in a valid state
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async runAxe(page) {
|
||||
try {
|
||||
const { AxeBuilder } = await Promise.resolve().then(() => __importStar(require('@axe-core/playwright')));
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze();
|
||||
return results.violations;
|
||||
}
|
||||
catch {
|
||||
// Fallback: try via page.evaluate if AxeBuilder fails
|
||||
const results = await page.evaluate(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const win = window;
|
||||
if (typeof win.axe === 'undefined')
|
||||
return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
const r = await win.axe.run();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return r.violations;
|
||||
}).catch(() => []);
|
||||
return Array.isArray(results) ? results : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AccessibilityCollector = AccessibilityCollector;
|
||||
56
dist/plugins/collectors/DOMSnapshotCollector.js
vendored
Normal file
56
dist/plugins/collectors/DOMSnapshotCollector.js
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
/**
|
||||
* DOMSnapshotCollector — writes the DOM snapshot at anomaly moment to disk.
|
||||
*/
|
||||
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.DOMSnapshotCollector = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
class DOMSnapshotCollector {
|
||||
constructor(outputDir = './reports') {
|
||||
this.outputDir = outputDir;
|
||||
this.name = 'DOMSnapshotCollector';
|
||||
}
|
||||
async collect(anomaly, agent) {
|
||||
const state = await agent.captureState();
|
||||
const dir = path.join(this.outputDir, anomaly.id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const domPath = path.join(dir, 'dom.html');
|
||||
fs.writeFileSync(domPath, state.domSnapshot, 'utf8');
|
||||
return { domSnapshotPath: path.relative(this.outputDir, domPath) };
|
||||
}
|
||||
}
|
||||
exports.DOMSnapshotCollector = DOMSnapshotCollector;
|
||||
18
dist/plugins/collectors/NetworkCollector.js
vendored
Normal file
18
dist/plugins/collectors/NetworkCollector.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
/**
|
||||
* NetworkCollector — logs all HTTP responses from the current observation.
|
||||
* The data is already captured in the observation; this collector formats it.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NetworkCollector = void 0;
|
||||
class NetworkCollector {
|
||||
constructor() {
|
||||
this.name = 'NetworkCollector';
|
||||
}
|
||||
async collect(anomaly, _agent) {
|
||||
// HTTP responses are captured in the observation → anomaly evidence
|
||||
const httpLog = anomaly.evidence.httpLog ?? [];
|
||||
return { httpLog };
|
||||
}
|
||||
}
|
||||
exports.NetworkCollector = NetworkCollector;
|
||||
177
dist/plugins/collectors/PerformanceCollector.js
vendored
Normal file
177
dist/plugins/collectors/PerformanceCollector.js
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
/**
|
||||
* PerformanceCollector — captures Navigation Timing and Core Web Vitals after each navigation.
|
||||
* Detects performance_degradation anomalies based on configurable thresholds.
|
||||
*/
|
||||
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.PerformanceCollector = exports.DEFAULT_PERF_CONFIG = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
exports.DEFAULT_PERF_CONFIG = {
|
||||
enabled: true,
|
||||
lcpThresholdMs: 4000,
|
||||
clsThreshold: 0.25,
|
||||
inpThresholdMs: 500,
|
||||
ttfbThresholdMs: 1800,
|
||||
};
|
||||
class PerformanceCollector {
|
||||
constructor(config = {}) {
|
||||
this.metricsStore = [];
|
||||
this.config = { ...exports.DEFAULT_PERF_CONFIG, ...config };
|
||||
}
|
||||
async collect(page, stateId, sessionId, actionTrace) {
|
||||
if (!this.config.enabled) {
|
||||
const empty = {
|
||||
id: crypto.randomUUID(), sessionId, stateId, url: page.url(),
|
||||
ttfb: 0, domContentLoaded: 0, loadComplete: 0,
|
||||
lcp: null, cls: null, fid: null, inp: null,
|
||||
totalRequests: 0, failedRequests: 0, capturedAt: Date.now(),
|
||||
};
|
||||
return { metrics: empty, anomalies: [] };
|
||||
}
|
||||
// Capture Navigation Timing
|
||||
const timing = await page.evaluate(() => {
|
||||
const t = performance.timing;
|
||||
return {
|
||||
ttfb: t.responseStart - t.requestStart,
|
||||
domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
|
||||
loadComplete: t.loadEventEnd - t.navigationStart,
|
||||
};
|
||||
}).catch(() => ({ ttfb: 0, domContentLoaded: 0, loadComplete: 0 }));
|
||||
// Capture Core Web Vitals via PerformanceObserver
|
||||
const vitals = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const result = {
|
||||
lcp: null, cls: null, inp: null,
|
||||
};
|
||||
try {
|
||||
// Try to observe LCP
|
||||
if ('PerformanceObserver' in window) {
|
||||
try {
|
||||
const lcpObs = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
result.lcp = entries[entries.length - 1].startTime;
|
||||
}
|
||||
});
|
||||
lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
}
|
||||
catch { /* not supported */ }
|
||||
try {
|
||||
const clsObs = new PerformanceObserver((list) => {
|
||||
let clsScore = 0;
|
||||
for (const entry of list.getEntries()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
clsScore += entry.value ?? 0;
|
||||
}
|
||||
result.cls = clsScore;
|
||||
});
|
||||
clsObs.observe({ type: 'layout-shift', buffered: true });
|
||||
}
|
||||
catch { /* not supported */ }
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
// Resolve after short wait
|
||||
setTimeout(() => resolve(result), 500);
|
||||
});
|
||||
}).catch(() => ({ lcp: null, cls: null, inp: null }));
|
||||
const metrics = {
|
||||
id: crypto.randomUUID(),
|
||||
sessionId,
|
||||
stateId,
|
||||
url: page.url(),
|
||||
ttfb: timing.ttfb,
|
||||
domContentLoaded: timing.domContentLoaded,
|
||||
loadComplete: timing.loadComplete,
|
||||
lcp: vitals.lcp,
|
||||
cls: vitals.cls,
|
||||
fid: null,
|
||||
inp: vitals.inp,
|
||||
totalRequests: 0,
|
||||
failedRequests: 0,
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
this.metricsStore.push(metrics);
|
||||
const anomalies = this.detectAnomalies(metrics, stateId, actionTrace);
|
||||
return { metrics, anomalies };
|
||||
}
|
||||
getMetrics() {
|
||||
return this.metricsStore;
|
||||
}
|
||||
detectAnomalies(metrics, stateId, actionTrace) {
|
||||
const anomalies = [];
|
||||
const issues = [];
|
||||
let severityRank = 0; // 0=low,1=medium,2=high,3=critical
|
||||
if (metrics.lcp !== null && metrics.lcp > this.config.lcpThresholdMs) {
|
||||
issues.push(`LCP: ${metrics.lcp}ms (threshold: ${this.config.lcpThresholdMs}ms)`);
|
||||
if (severityRank < 2)
|
||||
severityRank = 2; // high
|
||||
}
|
||||
if (metrics.cls !== null && metrics.cls > this.config.clsThreshold) {
|
||||
issues.push(`CLS: ${metrics.cls.toFixed(3)} (threshold: ${this.config.clsThreshold})`);
|
||||
if (severityRank < 1)
|
||||
severityRank = 1; // medium
|
||||
}
|
||||
if (metrics.inp !== null && metrics.inp > this.config.inpThresholdMs) {
|
||||
issues.push(`INP: ${metrics.inp}ms (threshold: ${this.config.inpThresholdMs}ms)`);
|
||||
if (severityRank < 2)
|
||||
severityRank = 2; // high
|
||||
}
|
||||
if (metrics.ttfb > this.config.ttfbThresholdMs) {
|
||||
issues.push(`TTFB: ${metrics.ttfb}ms (threshold: ${this.config.ttfbThresholdMs}ms)`);
|
||||
if (severityRank < 1)
|
||||
severityRank = 1; // medium
|
||||
}
|
||||
const RANK_TO_SEVERITY = ['low', 'medium', 'high', 'critical'];
|
||||
const maxSeverity = RANK_TO_SEVERITY[severityRank] ?? 'low';
|
||||
if (issues.length === 0)
|
||||
return anomalies;
|
||||
anomalies.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'performance_degradation',
|
||||
severity: maxSeverity,
|
||||
observationId: stateId,
|
||||
actionTrace,
|
||||
description: `Performance degradation at ${metrics.url}: ${issues[0]}`,
|
||||
evidence: {
|
||||
rawErrors: issues,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return anomalies;
|
||||
}
|
||||
}
|
||||
exports.PerformanceCollector = PerformanceCollector;
|
||||
63
dist/plugins/collectors/ScreenshotCollector.js
vendored
Normal file
63
dist/plugins/collectors/ScreenshotCollector.js
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
/**
|
||||
* ScreenshotCollector — captures a PNG screenshot at anomaly moment.
|
||||
* Requires the agent to be a PlaywrightAgent (duck-typing check).
|
||||
*/
|
||||
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.ScreenshotCollector = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
function isPlaywrightAgent(agent) {
|
||||
return typeof agent.getPage === 'function';
|
||||
}
|
||||
class ScreenshotCollector {
|
||||
constructor(outputDir = './reports') {
|
||||
this.outputDir = outputDir;
|
||||
this.name = 'ScreenshotCollector';
|
||||
}
|
||||
async collect(anomaly, agent) {
|
||||
if (!isPlaywrightAgent(agent)) {
|
||||
return {};
|
||||
}
|
||||
const page = agent.getPage();
|
||||
const dir = path.join(this.outputDir, anomaly.id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const screenshotPath = path.join(dir, 'screenshot.png');
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
return { screenshotPath: path.relative(this.outputDir, screenshotPath) };
|
||||
}
|
||||
}
|
||||
exports.ScreenshotCollector = ScreenshotCollector;
|
||||
155
dist/plugins/collectors/VisualRegressionCollector.js
vendored
Normal file
155
dist/plugins/collectors/VisualRegressionCollector.js
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
"use strict";
|
||||
/**
|
||||
* VisualRegressionCollector — captures screenshots and compares against baselines.
|
||||
* Uses pixelmatch for pixel-level comparison and sharp for image normalization.
|
||||
*/
|
||||
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.VisualRegressionCollector = exports.DEFAULT_VISUAL_CONFIG = void 0;
|
||||
exports.compareScreenshots = compareScreenshots;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const path = __importStar(require("path"));
|
||||
const fs = __importStar(require("fs"));
|
||||
exports.DEFAULT_VISUAL_CONFIG = {
|
||||
enabled: true,
|
||||
threshold: 0.001,
|
||||
screenshotFullPage: false,
|
||||
ignoreSelectors: [],
|
||||
};
|
||||
async function compareScreenshots(baselinePath, currentPath, diffOutputPath, threshold = 0.1) {
|
||||
// Dynamic imports to avoid loading heavy deps at startup
|
||||
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;
|
||||
// Write diff image
|
||||
await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
|
||||
.png()
|
||||
.toFile(diffOutputPath);
|
||||
return { diffPixels, diffPercent, hasDiff: diffPixels > 0 };
|
||||
}
|
||||
class VisualRegressionCollector {
|
||||
constructor(outputDir, repo, config = {}) {
|
||||
this.outputDir = outputDir;
|
||||
this.repo = repo;
|
||||
this.config = { ...exports.DEFAULT_VISUAL_CONFIG, ...config };
|
||||
}
|
||||
/**
|
||||
* Process a screenshot for visual regression.
|
||||
* Returns an anomaly if a regression is detected, otherwise null.
|
||||
*/
|
||||
async processScreenshot(screenshotPath, state, sessionId, actionTrace) {
|
||||
if (!this.config.enabled)
|
||||
return null;
|
||||
const comparisonId = crypto.randomUUID();
|
||||
const baseline = this.repo.findBaselineByStateId(state.id);
|
||||
if (!baseline) {
|
||||
// No baseline: create a new_state comparison record
|
||||
this.repo.createComparison({
|
||||
id: comparisonId,
|
||||
sessionId,
|
||||
stateId: state.id,
|
||||
currentScreenshotPath: screenshotPath,
|
||||
status: 'new_state',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Compare against baseline
|
||||
const diffDir = path.join(this.outputDir, 'visual', comparisonId);
|
||||
if (!fs.existsSync(diffDir)) {
|
||||
fs.mkdirSync(diffDir, { recursive: true });
|
||||
}
|
||||
const diffPath = path.join(diffDir, 'diff.png');
|
||||
let diffPixels = 0;
|
||||
let diffPercent = 0;
|
||||
try {
|
||||
const result = await compareScreenshots(baseline.screenshot_path, screenshotPath, diffPath, this.config.threshold);
|
||||
diffPixels = result.diffPixels;
|
||||
diffPercent = result.diffPercent;
|
||||
}
|
||||
catch {
|
||||
// If comparison fails (e.g. image format issues), skip
|
||||
return null;
|
||||
}
|
||||
const thresholdPct = this.config.threshold;
|
||||
const status = diffPercent > thresholdPct ? 'failed' : 'passed';
|
||||
this.repo.createComparison({
|
||||
id: comparisonId,
|
||||
sessionId,
|
||||
stateId: state.id,
|
||||
baselineId: baseline.id,
|
||||
currentScreenshotPath: screenshotPath,
|
||||
diffScreenshotPath: status === 'failed' ? diffPath : undefined,
|
||||
diffPixels,
|
||||
diffPercent,
|
||||
status,
|
||||
});
|
||||
if (status !== 'failed')
|
||||
return null;
|
||||
// Determine severity from diff percent
|
||||
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: diffPath,
|
||||
rawErrors: [`Diff: ${diffPixels} pixels (${(pct).toFixed(2)}%)`],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return anomaly;
|
||||
}
|
||||
}
|
||||
exports.VisualRegressionCollector = VisualRegressionCollector;
|
||||
Reference in New Issue
Block a user