docs: enterprise refactor plan with ralph specs

This commit is contained in:
debian
2026-03-04 16:17:03 -05:00
parent 4c92712d20
commit f8191133c8
204 changed files with 32722 additions and 422 deletions

252
dist/cli.js vendored Normal file
View File

@@ -0,0 +1,252 @@
"use strict";
/**
* ABE CLI — command-line interface for running explorations.
* Usage: abe run --url http://localhost:3000
*/
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 });
const commander_1 = require("commander");
const ExplorationEngine_1 = require("./core/ExplorationEngine");
const StateGraph_1 = require("./core/StateGraph");
const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector");
const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter");
const JSONExporter_1 = require("./plugins/exporters/JSONExporter");
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
const ExplorationConfig_1 = require("./core/ExplorationConfig");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const program = new commander_1.Command();
program
.name('abe')
.description('Autonomous Bug Explorer — explore web apps and find bugs')
.version('0.1.0');
program
.command('run')
.description('Run an exploration session against a target URL')
.requiredOption('--url <url>', 'Target URL to explore')
.option('--seed <seed>', 'Deterministic seed', parseInt, 42)
.option('--max-states <n>', 'Max states to explore', parseInt, 50)
.option('--max-depth <n>', 'Max click depth', parseInt, 5)
.option('--allowed-domains <domains>', 'Comma-separated allowed domains')
.option('--excluded-paths <paths>', 'Comma-separated excluded paths')
.option('--action-delay <ms>', 'Delay between actions in ms', parseInt, 500)
.option('--session-timeout <ms>', 'Session timeout in ms', parseInt, 300000)
// Auth options
.option('--auth-type <type>', 'Auth type: cookies | headers | login_flow')
.option('--login-url <url>', 'Login page URL (for login_flow)')
.option('--username <user>', 'Username (for login_flow)')
.option('--password <pass>', 'Password (for login_flow)')
.option('--username-selector <sel>', 'Username field selector (for login_flow)')
.option('--password-selector <sel>', 'Password field selector (for login_flow)')
.option('--submit-selector <sel>', 'Submit button selector (for login_flow)')
// Output
.option('--output <format>', 'Output format: human | json | junit', 'human')
.option('--reports-dir <dir>', 'Output directory for reports', './reports')
// CI flags
.option('--fail-on-anomaly', 'Exit 1 if any anomaly found')
.option('--fail-on-severity <level>', 'Exit 1 if anomaly at or above severity found')
// Remote server
.option('--server <url>', 'Connect to remote ABE server instead of running inline')
.option('--api-key <key>', 'API key for remote server')
.action(async (opts) => {
const startMs = Date.now();
// Build auth config
let auth = null;
if (opts.authType === 'login_flow') {
auth = {
type: 'login_flow',
loginUrl: opts.loginUrl ?? '',
usernameSelector: opts.usernameSelector ?? 'input[type="email"]',
passwordSelector: opts.passwordSelector ?? 'input[type="password"]',
submitSelector: opts.submitSelector ?? 'button[type="submit"]',
username: opts.username ?? '',
password: opts.password ?? '',
};
}
else if (opts.authType === 'headers') {
auth = { type: 'headers', headers: {} };
}
else if (opts.authType === 'cookies') {
auth = { type: 'cookies', cookies: [] };
}
const config = {
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
maxStates: opts.maxStates,
maxDepth: opts.maxDepth,
actionDelayMs: opts.actionDelay,
sessionTimeoutMs: opts.sessionTimeout,
allowedDomains: opts.allowedDomains
? opts.allowedDomains.split(',').map((d) => d.trim())
: [new URL(opts.url).hostname],
excludedPaths: opts.excludedPaths
? opts.excludedPaths.split(',').map((p) => p.trim())
: [],
auth,
};
// If remote server mode
if (opts.server) {
await runRemote(opts, config);
return;
}
const anomalies = [];
let exitCode = 0;
let explorationError;
try {
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: opts.seed, explorationConfig: config });
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
seed: opts.seed,
url: opts.url,
maxSteps: opts.maxStates,
outputDir: opts.reportsDir,
explorationConfig: config,
collectors: [
new ScreenshotCollector_1.ScreenshotCollector(opts.reportsDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(opts.reportsDir),
],
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
events: {
onAnomalyDetected: (_, anomaly) => {
anomalies.push(anomaly);
},
onSessionError: (_, error) => {
explorationError = error;
},
},
});
await engine.run();
}
catch (err) {
explorationError = err instanceof Error ? err.message : String(err);
exitCode = 2;
}
if (explorationError && exitCode === 0)
exitCode = 2;
// Determine exit code from flags
if (exitCode === 0 && opts.failOnAnomaly && anomalies.length > 0) {
exitCode = 1;
}
if (exitCode === 0 && opts.failOnSeverity) {
const severityRank = { low: 0, medium: 1, high: 2, critical: 3 };
const threshold = severityRank[opts.failOnSeverity] ?? 0;
const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold);
if (failing.length > 0)
exitCode = 1;
}
const durationMs = Date.now() - startMs;
const summary = {
url: opts.url,
duration_ms: durationMs,
anomalies: anomalies.map((a) => ({
id: a.id,
type: a.type,
severity: a.severity,
description: a.description,
report_path: path.join(opts.reportsDir, a.id, 'report.json'),
})),
exit_code: exitCode,
};
if (opts.output === 'json') {
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
}
else if (opts.output === 'junit') {
const xml = buildJunit(summary, opts.url);
const outPath = path.join(process.cwd(), 'abe-results.xml');
fs.writeFileSync(outPath, xml, 'utf8');
if (opts.output !== 'json') {
console.log(`JUnit results written to ${outPath}`);
}
}
else {
if (anomalies.length === 0) {
console.log(`✓ ABE finished. No anomalies found. (${durationMs}ms)`);
}
else {
console.log(`⚠ ABE finished. ${anomalies.length} anomaly(ies) found:`);
for (const a of anomalies) {
console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`);
}
}
}
process.exit(exitCode);
});
async function runRemote(opts, _config) {
const serverUrl = opts['server'];
const apiKey = opts['apiKey'];
const url = opts['url'];
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-abe-api-key'] = apiKey;
const res = await fetch(`${serverUrl}/api/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({ url }),
});
if (!res.ok) {
console.error(`Server error: ${res.status} ${await res.text()}`);
process.exit(2);
return;
}
const session = await res.json();
console.log(`Session started: ${session.sessionId}`);
process.exit(0);
}
function buildJunit(summary, url) {
const anomalyCount = summary.anomalies.length;
const cases = summary.anomalies
.map((a) => ` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
` </testcase>`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="ABE Exploration: ${escapeXml(url)}" tests="${anomalyCount}" failures="${anomalyCount}">\n` +
cases + '\n' +
`</testsuite>\n`;
}
function escapeXml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
program.parse(process.argv);

137
dist/core/AnomalyDetector.js vendored Normal file
View File

@@ -0,0 +1,137 @@
"use strict";
/**
* AnomalyDetector — heuristic rules to detect anomalies from observations.
* Each rule is independent and testable in isolation.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyDetector = void 0;
let anomalyCounter = 0;
function makeId() {
anomalyCounter += 1;
return `anom_${Date.now()}_${anomalyCounter.toString().padStart(4, '0')}`;
}
class AnomalyDetector {
detect(observation, actionTrace) {
const anomalies = [];
const httpAnomaly = this.checkHttpErrors(observation, actionTrace);
if (httpAnomaly)
anomalies.push(httpAnomaly);
const jsAnomaly = this.checkJsExceptions(observation, actionTrace);
if (jsAnomaly)
anomalies.push(jsAnomaly);
const consoleAnomaly = this.checkConsoleErrors(observation, actionTrace);
if (consoleAnomaly)
anomalies.push(consoleAnomaly);
return anomalies;
}
/** Rule: HTTP 4xx or 5xx responses */
checkHttpErrors(observation, actionTrace) {
const errorResponses = observation.httpResponses.filter((r) => r.status >= 400);
if (errorResponses.length === 0)
return null;
const hasServerError = errorResponses.some((r) => r.status >= 500);
const severity = hasServerError ? 'high' : 'medium';
const statusCodes = errorResponses.map((r) => r.status).join(', ');
return this.buildAnomaly({
type: 'http_error',
severity,
observationId: observation.id,
actionTrace,
description: `HTTP error responses detected: ${statusCodes}`,
evidence: {
httpLog: errorResponses,
rawErrors: errorResponses.map((r) => `${r.method} ${r.url}${r.status} (${r.durationMs}ms)`),
},
});
}
/** Rule: uncaught JS exceptions */
checkJsExceptions(observation, actionTrace) {
if (observation.jsExceptions.length === 0)
return null;
return this.buildAnomaly({
type: 'js_exception',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Uncaught JS exception: ${observation.jsExceptions[0]}`,
evidence: {
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: console.error messages */
checkConsoleErrors(observation, actionTrace) {
if (observation.consoleErrors.length === 0)
return null;
return this.buildAnomaly({
type: 'console_error',
severity: 'low',
observationId: observation.id,
actionTrace,
description: `Console error detected: ${observation.consoleErrors[0]}`,
evidence: {
rawErrors: observation.consoleErrors,
},
});
}
/**
* Rule: server accepted clearly invalid/empty fuzz input (got 2xx).
* fuzzedValue is the value that was submitted; responseStatus is the HTTP response.
*/
checkValidationBypass(observation, actionTrace, fuzzedValue) {
const has2xx = observation.httpResponses.some((r) => r.status >= 200 && r.status < 300);
if (!has2xx)
return null;
return this.buildAnomaly({
type: 'validation_bypass',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Server accepted invalid input without error (value: ${JSON.stringify(fuzzedValue).substring(0, 50)})`,
evidence: { httpLog: observation.httpResponses, rawErrors: [`Fuzzed with: ${fuzzedValue}`] },
});
}
/** Rule: server returned 500 on a fuzzed input */
checkServerErrorOnFuzz(observation, actionTrace) {
const has5xx = observation.httpResponses.some((r) => r.status >= 500);
if (!has5xx)
return null;
return this.buildAnomaly({
type: 'server_error_on_fuzz',
severity: 'high',
observationId: observation.id,
actionTrace,
description: 'Server returned 5xx on fuzzed input',
evidence: {
httpLog: observation.httpResponses.filter((r) => r.status >= 500),
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: fuzzed script tag appears in response body (XSS reflection) */
checkXssReflection(observation, actionTrace, domSnapshot) {
if (!domSnapshot.includes('<script>alert(1)</script>'))
return null;
return this.buildAnomaly({
type: 'xss_reflection',
severity: 'critical',
observationId: observation.id,
actionTrace,
description: 'XSS reflection detected: fuzzed script tag appeared in DOM',
evidence: { rawErrors: ['XSS payload reflected in DOM'] },
});
}
buildAnomaly(params) {
return {
id: makeId(),
type: params.type,
severity: params.severity,
observationId: params.observationId,
actionTrace: params.actionTrace,
description: params.description,
evidence: params.evidence,
timestamp: Date.now(),
};
}
}
exports.AnomalyDetector = AnomalyDetector;

53
dist/core/ExplorationConfig.js vendored Normal file
View File

@@ -0,0 +1,53 @@
"use strict";
/**
* ExplorationConfig — defines scope, auth, fuzzing, multi-browser, a11y,
* performance, visual regression, and network chaos settings for a session.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_EXPLORATION_CONFIG = exports.NETWORK_PROFILES = void 0;
exports.NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null,
};
exports.DEFAULT_EXPLORATION_CONFIG = {
allowedDomains: [],
maxStates: 50,
maxDepth: 5,
actionDelayMs: 500,
sessionTimeoutMs: 300000,
excludedPaths: [],
excludedSelectors: [],
auth: null,
fuzzingEnabled: true,
fuzzingIntensity: 'medium',
browsers: ['chromium'],
mobileDevice: 'none',
viewport: null,
accessibility: {
enabled: true,
minImpact: 'serious',
wcagLevel: 'AA',
},
performance: {
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
},
visualRegression: {
enabled: false,
threshold: 0.001,
screenshotFullPage: false,
ignoreSelectors: [],
},
networkChaos: {
enabled: false,
profile: 'none',
blockedEndpoints: [],
slowEndpoints: [],
},
};

197
dist/core/ExplorationEngine.js vendored Normal file
View File

@@ -0,0 +1,197 @@
"use strict";
/**
* ExplorationEngine — the core loop of ABE.
* Selects states, executes actions, records observations, and detects anomalies.
* Depends only on core interfaces — never imports concrete plugins.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExplorationEngine = void 0;
const AnomalyDetector_1 = require("./AnomalyDetector");
const Logger_1 = require("./Logger");
class ExplorationEngine {
constructor(config) {
/** Accumulated action trace for the current session */
this.actionTrace = [];
/** Set to true to abort the running loop */
this.aborted = false;
this.graph = config.graph;
this.agent = config.agent;
this.detector = config.detector ?? new AnomalyDetector_1.AnomalyDetector();
this.collectors = config.collectors ?? [];
this.exporters = config.exporters ?? [];
this.reproducer = config.reproducer;
this.logger = config.logger ?? new Logger_1.NullLogger();
this.seed = config.seed;
this.url = config.url;
this.maxSteps = config.maxSteps ?? 100;
this.outputDir = config.outputDir ?? './reports';
this.events = config.events ?? {};
this.sessionId = config.sessionId ?? `${Date.now()}_${config.seed}`;
this.explorationConfig = config.explorationConfig ?? {};
this.fuzzingPlugin = config.fuzzingPlugin;
this.stateHooks = config.stateHooks ?? [];
}
/** Signals the engine to stop after the current step completes. */
stop() {
this.aborted = true;
}
async run() {
const anomalies = [];
let stepsExecuted = 0;
let depth = 0;
const sessionTimeoutMs = this.explorationConfig.sessionTimeoutMs ?? 0;
const maxDepth = this.explorationConfig.maxDepth ?? Infinity;
const sessionStart = Date.now();
this.logger.log({
event: 'session_start',
timestamp: sessionStart,
seed: this.seed,
target: this.url,
});
this.events.onSessionStarted?.(this.sessionId, this.url);
const isTimedOut = () => sessionTimeoutMs > 0 && Date.now() - sessionStart >= sessionTimeoutMs;
try {
await this.agent.launch(this.url);
// Capture initial state
const initialState = await this.agent.captureState();
this.graph.addState(initialState);
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: initialState.id,
url: initialState.url,
title: initialState.title,
});
this.events.onStateDiscovered?.(this.sessionId, initialState.id, initialState.url, initialState.title);
while (stepsExecuted < this.maxSteps && !this.aborted && !isTimedOut() && depth <= maxDepth) {
const currentState = this.graph.getNextToExplore();
if (!currentState)
break;
// Mark state as being explored
this.graph.incrementVisit(currentState.id);
// Discover available actions in this state
const actions = await this.agent.discoverActions(currentState);
if (actions.length === 0)
continue;
// Select action deterministically using seed + step count
const actionIndex = (this.seed + stepsExecuted) % actions.length;
const action = actions[actionIndex];
this.logger.log({
event: 'action_executed',
timestamp: Date.now(),
actionId: action.id,
type: action.type,
selector: action.selector,
value: action.value,
url: action.url,
});
this.events.onActionExecuted?.(this.sessionId, action.type, action.selector, Date.now());
// Execute action and capture observation
const observation = await this.agent.executeAction(action);
this.actionTrace.push(action);
// Record new state if discovered
if (!this.graph.hasState(observation.newStateId)) {
const newState = await this.agent.captureState();
this.graph.addState(newState);
depth += 1;
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: newState.id,
url: newState.url,
title: newState.title,
});
this.events.onStateDiscovered?.(this.sessionId, newState.id, newState.url, newState.title);
// Run per-state hooks (visual regression, accessibility, performance)
for (const hook of this.stateHooks) {
const hookAnomalies = await hook(newState, this.agent, this.sessionId, [...this.actionTrace]).catch(() => []);
for (const anomaly of hookAnomalies) {
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
this.graph.recordTransition(currentState.id, action, observation.newStateId);
this.logger.log({
event: 'exploration_step',
timestamp: Date.now(),
stateId: currentState.id,
actionId: action.id,
});
// Detect anomalies
const detected = this.detector.detect(observation, [...this.actionTrace]);
for (const anomaly of detected) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
const reportDir = `${this.outputDir}/${anomaly.id}`;
await exporter.export(anomaly, reportDir);
}
}
stepsExecuted += 1;
// Run fuzzing if enabled and plugin provided
if (this.fuzzingPlugin &&
this.explorationConfig.fuzzingEnabled !== false &&
currentState.domSnapshot) {
const fuzzActions = this.fuzzingPlugin.generateFuzzActions(currentState.domSnapshot, currentState);
for (const fuzzAction of fuzzActions) {
if (this.aborted || isTimedOut())
break;
const fuzzObs = await this.agent.executeAction(fuzzAction);
this.actionTrace.push(fuzzAction);
const fuzzAnomalies = this.detector.detect(fuzzObs, [...this.actionTrace]);
for (const anomaly of fuzzAnomalies) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.events.onSessionError?.(this.sessionId, msg);
await this.agent.close().catch(() => undefined);
throw err;
}
await this.agent.close();
const statesVisited = this.graph.getAllStates().filter((s) => s.visitCount > 0).length;
this.logger.log({
event: 'session_end',
timestamp: Date.now(),
statesVisited,
anomaliesFound: anomalies.length,
});
this.events.onSessionCompleted?.(this.sessionId, statesVisited, anomalies.length);
return { statesVisited, anomaliesFound: anomalies.length, anomalies };
}
}
exports.ExplorationEngine = ExplorationEngine;

66
dist/core/Logger.js vendored Normal file
View File

@@ -0,0 +1,66 @@
"use strict";
/**
* Logger — writes structured JSON log events to a .jsonl file.
* One JSON object per line.
*/
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.NullLogger = exports.FileLogger = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class FileLogger {
constructor(logDir, sessionId) {
const logPath = path.join(logDir, `session_${sessionId}.jsonl`);
fs.mkdirSync(logDir, { recursive: true });
this.stream = fs.createWriteStream(logPath, { flags: 'a' });
}
log(event) {
this.stream.write(JSON.stringify(event) + '\n');
}
close() {
this.stream.end();
}
}
exports.FileLogger = FileLogger;
/** No-op logger for testing */
class NullLogger {
constructor() {
this.events = [];
}
log(event) {
this.events.push(event);
}
}
exports.NullLogger = NullLogger;

83
dist/core/StateGraph.js vendored Normal file
View File

@@ -0,0 +1,83 @@
"use strict";
/**
* StateGraph — manages known states and transitions between them.
* Uses BFS ordering by default for exploration scheduling.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.StateGraph = void 0;
class StateGraph {
constructor() {
this.states = new Map();
this.transitions = [];
/** Insertion order for BFS */
this.insertionOrder = [];
}
addState(state) {
if (!this.states.has(state.id)) {
this.states.set(state.id, state);
this.insertionOrder.push(state.id);
}
else {
// Update visit count on revisit
const existing = this.states.get(state.id);
this.states.set(state.id, { ...existing, visitCount: existing.visitCount + 1 });
}
}
hasState(stateId) {
return this.states.has(stateId);
}
getState(stateId) {
return this.states.get(stateId);
}
incrementVisit(stateId) {
const state = this.states.get(stateId);
if (state) {
this.states.set(stateId, { ...state, visitCount: state.visitCount + 1 });
}
}
recordTransition(fromId, action, toId) {
this.transitions.push({
fromId,
action,
toId,
timestamp: Date.now(),
});
}
/** Returns all states that have never been visited (visitCount === 0) */
getUnvisited() {
return this.insertionOrder
.map((id) => this.states.get(id))
.filter((s) => s.visitCount === 0);
}
/** BFS heuristic: returns the oldest unvisited state, or null if none */
getNextToExplore() {
const unvisited = this.getUnvisited();
return unvisited.length > 0 ? unvisited[0] : null;
}
getAllStates() {
return this.insertionOrder.map((id) => this.states.get(id));
}
getTransitions() {
return [...this.transitions];
}
toJSON() {
return {
stateCount: this.states.size,
transitionCount: this.transitions.length,
states: this.getAllStates().map((s) => ({
id: s.id,
url: s.url,
title: s.title,
visitCount: s.visitCount,
})),
transitions: this.transitions.map((t) => ({
fromId: t.fromId,
toId: t.toId,
actionId: t.action.id,
actionType: t.action.type,
timestamp: t.timestamp,
})),
};
}
}
exports.StateGraph = StateGraph;

6
dist/core/interfaces.js vendored Normal file
View File

@@ -0,0 +1,6 @@
"use strict";
/**
* ABE Core Interfaces
* Core data types only. Must NOT import from src/plugins/.
*/
Object.defineProperty(exports, "__esModule", { value: true });

76
dist/db/AnomalyRepository.js vendored Normal file
View File

@@ -0,0 +1,76 @@
"use strict";
/**
* AnomalyRepository — CRUD for anomalies table with filters.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyRepository = void 0;
function rowToAnomaly(row) {
return {
id: row.id,
sessionId: row.session_id,
type: row.type,
severity: row.severity,
description: row.description,
actionTrace: JSON.parse(row.action_trace_json),
evidence: {
...JSON.parse(row.evidence_json),
screenshotPath: row.screenshot_path ?? undefined,
domSnapshotPath: row.dom_snapshot_path ?? undefined,
},
observationId: '',
timestamp: row.detected_at,
};
}
class AnomalyRepository {
constructor(db) {
this.db = db;
}
create(anomaly, sessionId) {
this.db
.prepare(`INSERT INTO anomalies
(id, session_id, type, severity, description, action_trace_json, evidence_json, screenshot_path, dom_snapshot_path, detected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(anomaly.id, sessionId, anomaly.type, anomaly.severity, anomaly.description, JSON.stringify(anomaly.actionTrace), JSON.stringify({ httpLog: anomaly.evidence.httpLog, rawErrors: anomaly.evidence.rawErrors }), anomaly.evidence.screenshotPath ?? null, anomaly.evidence.domSnapshotPath ?? null, anomaly.timestamp);
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM anomalies WHERE id = ?')
.get(id);
return row ? rowToAnomaly(row) : undefined;
}
findAll(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.severity) {
conditions.push('severity = ?');
values.push(filters.severity);
}
if (filters?.type) {
conditions.push('type = ?');
values.push(filters.type);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const rows = this.db
.prepare(`SELECT * FROM anomalies ${where} ORDER BY detected_at DESC`)
.all(...values);
return rows.map(rowToAnomaly);
}
countBySeverity(severities) {
if (severities.length === 0)
return 0;
const placeholders = severities.map(() => '?').join(', ');
const result = this.db
.prepare(`SELECT COUNT(*) as cnt FROM anomalies WHERE severity IN (${placeholders})`)
.get(...severities);
return result.cnt;
}
count() {
const result = this.db.prepare('SELECT COUNT(*) as cnt FROM anomalies').get();
return result.cnt;
}
}
exports.AnomalyRepository = AnomalyRepository;

82
dist/db/ScheduleRepository.js vendored Normal file
View File

@@ -0,0 +1,82 @@
"use strict";
/**
* ScheduleRepository — CRUD for schedules table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScheduleRepository = void 0;
function rowToRecord(row) {
return {
id: row.id,
name: row.name,
url: row.url,
configJson: row.config_json,
cronExpression: row.cron_expression,
enabled: row.enabled === 1,
lastRunAt: row.last_run_at,
nextRunAt: row.next_run_at,
createdAt: row.created_at,
};
}
class ScheduleRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO schedules (id, name, url, config_json, cron_expression, enabled, next_run_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(params.id, params.name, params.url, params.configJson, params.cronExpression, params.enabled !== false ? 1 : 0, params.nextRunAt ?? null, Date.now());
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM schedules WHERE id = ?')
.get(id);
return row ? rowToRecord(row) : undefined;
}
findAll(enabledOnly = false) {
const rows = enabledOnly
? this.db.prepare('SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC').all()
: this.db.prepare('SELECT * FROM schedules ORDER BY created_at DESC').all();
return rows.map(rowToRecord);
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.name !== undefined) {
sets.push('name = ?');
values.push(fields.name);
}
if (fields.url !== undefined) {
sets.push('url = ?');
values.push(fields.url);
}
if (fields.configJson !== undefined) {
sets.push('config_json = ?');
values.push(fields.configJson);
}
if (fields.cronExpression !== undefined) {
sets.push('cron_expression = ?');
values.push(fields.cronExpression);
}
if (fields.enabled !== undefined) {
sets.push('enabled = ?');
values.push(fields.enabled ? 1 : 0);
}
if (fields.lastRunAt !== undefined) {
sets.push('last_run_at = ?');
values.push(fields.lastRunAt);
}
if (fields.nextRunAt !== undefined) {
sets.push('next_run_at = ?');
values.push(fields.nextRunAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE schedules SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
}
}
exports.ScheduleRepository = ScheduleRepository;

53
dist/db/SessionRepository.js vendored Normal file
View File

@@ -0,0 +1,53 @@
"use strict";
/**
* SessionRepository — CRUD for sessions table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionRepository = void 0;
class SessionRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO sessions (id, url, status, seed, max_states, started_at, config_json)
VALUES (?, ?, 'running', ?, ?, ?, ?)`)
.run(params.id, params.url, params.seed, params.maxStates, params.startedAt, params.configJson ?? '{}');
}
findById(id) {
return this.db
.prepare('SELECT * FROM sessions WHERE id = ?')
.get(id);
}
findAll() {
return this.db.prepare('SELECT * FROM sessions ORDER BY started_at DESC').all();
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.status !== undefined) {
sets.push('status = ?');
values.push(fields.status);
}
if (fields.statesVisited !== undefined) {
sets.push('states_visited = ?');
values.push(fields.statesVisited);
}
if (fields.anomaliesFound !== undefined) {
sets.push('anomalies_found = ?');
values.push(fields.anomaliesFound);
}
if (fields.finishedAt !== undefined) {
sets.push('finished_at = ?');
values.push(fields.finishedAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
}
exports.SessionRepository = SessionRepository;

77
dist/db/VisualBaselineRepository.js vendored Normal file
View File

@@ -0,0 +1,77 @@
"use strict";
/**
* VisualBaselineRepository — CRUD for visual_baselines and visual_comparisons tables.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.VisualBaselineRepository = void 0;
class VisualBaselineRepository {
constructor(db) {
this.db = db;
}
// ─── Baselines ────────────────────────────────────────────────────────────
createBaseline(params) {
this.db.prepare(`
INSERT OR REPLACE INTO visual_baselines (id, state_id, url, screenshot_path, approved_at, approved_by, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.stateId, params.url, params.screenshotPath, Date.now(), params.approvedBy ?? 'user', params.width, params.height);
}
findBaselineByStateId(stateId) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE state_id = ? ORDER BY approved_at DESC LIMIT 1')
.get(stateId);
}
findBaselineById(id) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE id = ?')
.get(id);
}
// ─── Comparisons ──────────────────────────────────────────────────────────
createComparison(params) {
this.db.prepare(`
INSERT INTO visual_comparisons
(id, session_id, state_id, baseline_id, current_screenshot_path, diff_screenshot_path, diff_pixels, diff_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.sessionId, params.stateId, params.baselineId ?? null, params.currentScreenshotPath, params.diffScreenshotPath ?? null, params.diffPixels ?? null, params.diffPercent ?? null, params.status, Date.now());
}
findComparisonById(id) {
return this.db
.prepare('SELECT * FROM visual_comparisons WHERE id = ?')
.get(id);
}
findComparisons(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.status) {
conditions.push('status = ?');
values.push(filters.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return this.db
.prepare(`SELECT * FROM visual_comparisons ${where} ORDER BY created_at DESC`)
.all(...values);
}
updateComparisonStatus(id, status) {
this.db.prepare('UPDATE visual_comparisons SET status = ? WHERE id = ?').run(status, id);
}
promoteToBaseline(comparisonId) {
const comparison = this.findComparisonById(comparisonId);
if (!comparison)
return null;
const baselineId = `baseline_${Date.now()}`;
this.createBaseline({
id: baselineId,
stateId: comparison.state_id,
url: comparison.session_id,
screenshotPath: comparison.current_screenshot_path,
width: 1280,
height: 720,
});
this.updateComparisonStatus(comparisonId, 'passed');
return baselineId;
}
}
exports.VisualBaselineRepository = VisualBaselineRepository;

43
dist/db/connection.js vendored Normal file
View File

@@ -0,0 +1,43 @@
"use strict";
/**
* ABE Database Connection
* Singleton SQLite connection using better-sqlite3.
* Runs migrations on first access.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getDb = getDb;
exports.setDb = setDb;
exports.closeDb = closeDb;
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const migrations_1 = require("./migrations");
let _db = null;
function getDb() {
if (_db)
return _db;
const dbPath = process.env['ABE_DB_PATH'] ?? path_1.default.join(process.cwd(), 'data', 'abe.db');
const dir = path_1.default.dirname(dbPath);
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
_db = new better_sqlite3_1.default(dbPath);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
(0, migrations_1.runMigrations)(_db);
return _db;
}
/** For testing — inject a custom (in-memory) database instance. */
function setDb(db) {
_db = db;
}
/** Close and reset. Used in tests. */
function closeDb() {
if (_db) {
_db.close();
_db = null;
}
}

126
dist/db/migrations.js vendored Normal file
View File

@@ -0,0 +1,126 @@
"use strict";
/**
* ABE Database Migrations
* Creates all tables if they do not exist.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runMigrations = runMigrations;
function runMigrations(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
seed INTEGER NOT NULL,
max_states INTEGER NOT NULL DEFAULT 50,
states_visited INTEGER NOT NULL DEFAULT 0,
anomalies_found INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
finished_at INTEGER,
config_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
url TEXT NOT NULL,
title TEXT NOT NULL,
dom_snapshot_path TEXT,
visit_count INTEGER NOT NULL DEFAULT 0,
discovered_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
state_id TEXT NOT NULL REFERENCES states(id),
type TEXT NOT NULL,
selector TEXT,
value TEXT,
url TEXT,
seed INTEGER NOT NULL,
executed_at INTEGER NOT NULL,
sequence_order INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS anomalies (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT NOT NULL,
action_trace_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
screenshot_path TEXT,
dom_snapshot_path TEXT,
detected_at INTEGER NOT NULL,
ai_enrichment_json TEXT,
ai_enriched_at INTEGER,
browser TEXT,
browser_version TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
anomaly_id TEXT NOT NULL REFERENCES anomalies(id),
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
sent_at INTEGER,
error TEXT
);
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
config_json TEXT NOT NULL,
cron_expression TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_baselines (
id TEXT PRIMARY KEY,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
screenshot_path TEXT NOT NULL,
approved_at INTEGER NOT NULL,
approved_by TEXT DEFAULT 'user',
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_comparisons (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
baseline_id TEXT,
current_screenshot_path TEXT NOT NULL,
diff_screenshot_path TEXT,
diff_pixels INTEGER,
diff_percent REAL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS performance_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
ttfb INTEGER,
dom_content_loaded INTEGER,
load_complete INTEGER,
lcp INTEGER,
cls REAL,
fid INTEGER,
inp INTEGER,
total_requests INTEGER,
failed_requests INTEGER,
total_transfer_size INTEGER,
captured_at INTEGER NOT NULL
);
`);
}

86
dist/index.js vendored Normal file
View File

@@ -0,0 +1,86 @@
"use strict";
/**
* ABE — Autonomous Bug Explorer
* Entry point: wires all components together and starts exploration.
*
* Usage:
* npm run explore -- --url http://localhost:3000 --output ./reports
* ts-node src/index.ts http://localhost:3000
*/
Object.defineProperty(exports, "__esModule", { value: true });
const ExplorationEngine_1 = require("./core/ExplorationEngine");
const StateGraph_1 = require("./core/StateGraph");
const Logger_1 = require("./core/Logger");
const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector");
const JSONExporter_1 = require("./plugins/exporters/JSONExporter");
const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter");
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
let url = 'http://localhost:3000';
let outputDir = './reports';
let seed = 42;
let maxSteps = 100;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1])
url = args[++i];
else if (args[i] === '--output' && args[i + 1])
outputDir = args[++i];
else if (args[i] === '--seed' && args[i + 1])
seed = parseInt(args[++i], 10);
else if (args[i] === '--max-steps' && args[i + 1])
maxSteps = parseInt(args[++i], 10);
else if (!args[i].startsWith('--'))
url = args[i];
}
return { url, outputDir, seed, maxSteps };
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const { url, outputDir, seed, maxSteps } = parseArgs();
const sessionId = `${new Date().toISOString().replace(/[:.]/g, '-')}_seed${seed}`;
const logger = new Logger_1.FileLogger('./logs', sessionId);
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, headless: true, logger });
const collectors = [
new ScreenshotCollector_1.ScreenshotCollector(outputDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(outputDir),
];
const exporters = [
new JSONExporter_1.JSONExporter(url),
new MarkdownExporter_1.MarkdownExporter(),
];
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
collectors,
exporters,
reproducer,
logger,
seed,
url,
maxSteps,
outputDir,
});
console.log(`[ABE] Starting exploration of ${url} (seed=${seed}, maxSteps=${maxSteps})`);
try {
const result = await engine.run();
console.log(`[ABE] Exploration complete.`);
console.log(` States visited : ${result.statesVisited}`);
console.log(` Anomalies found: ${result.anomaliesFound}`);
if (result.anomaliesFound > 0) {
console.log(` Reports saved to: ${outputDir}/`);
}
}
catch (err) {
console.error('[ABE] Fatal error:', err);
process.exit(1);
}
}
main();

501
dist/plugins/agents/PlaywrightAgent.js vendored Normal file
View File

@@ -0,0 +1,501 @@
"use strict";
/**
* PlaywrightAgent — implements IInteractionAgent using Playwright.
* All random choices use a deterministic seed and are logged.
* Supports scope enforcement, auth injection, and action delay.
*/
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.PlaywrightAgent = void 0;
const crypto = __importStar(require("crypto"));
const playwright_1 = require("playwright");
const Logger_1 = require("../../core/Logger");
const ExplorationConfig_1 = require("../../core/ExplorationConfig");
/** Simple deterministic pseudo-random number generator (LCG) */
class SeededRandom {
constructor(seed) {
this.state = seed;
}
/** Returns a float in [0, 1) */
next() {
this.state = (this.state * 1664525 + 1013904223) & 0xffffffff;
return (this.state >>> 0) / 0x100000000;
}
/** Returns an integer in [0, max) */
nextInt(max) {
return Math.floor(this.next() * max);
}
}
function generateId() {
return crypto.randomUUID();
}
function domHash(url, domSnapshot) {
return crypto
.createHash('sha1')
.update(url + domSnapshot)
.digest('hex')
.substring(0, 16);
}
class PlaywrightAgent {
constructor(config = {}) {
/** Captured HTTP responses for the current action */
this.pendingResponses = [];
this.pendingConsoleErrors = [];
this.pendingJsExceptions = [];
this.seed = config.seed ?? 42;
this.rng = new SeededRandom(this.seed);
this.headless = config.headless ?? true;
this.timeoutMs = config.timeoutMs ?? 30000;
this.logger = config.logger ?? new Logger_1.NullLogger();
this.explorationConfig = config.explorationConfig ?? {};
}
async launch(url) {
// Select browser type
const browserType = this.explorationConfig.browsers?.[0] ?? 'chromium';
const launcher = browserType === 'firefox' ? playwright_1.firefox : browserType === 'webkit' ? playwright_1.webkit : playwright_1.chromium;
this.browser = await launcher.launch({ headless: this.headless });
// Apply auth headers if configured
const auth = this.explorationConfig.auth;
let contextOptions = {};
// Mobile device emulation
const mobileDevice = this.explorationConfig.mobileDevice;
if (mobileDevice && mobileDevice !== 'none') {
const device = playwright_1.devices[mobileDevice];
if (device) {
contextOptions = { ...device, ...contextOptions };
}
}
// Custom viewport
if (this.explorationConfig.viewport) {
contextOptions.viewport = this.explorationConfig.viewport;
}
if (auth?.type === 'headers') {
contextOptions.extraHTTPHeaders = auth.headers;
}
this.context = await this.browser.newContext(contextOptions);
// Apply auth cookies if configured
if (auth?.type === 'cookies') {
await this.context.addCookies(auth.cookies);
}
this.page = await this.context.newPage();
this.setupListeners(this.page);
// Apply network chaos conditions
await this.applyNetworkChaos(this.page);
// Login flow auth
if (auth?.type === 'login_flow') {
await this.performLoginFlow(auth);
}
await this.page.goto(url, { timeout: this.timeoutMs });
}
async close() {
await this.browser?.close();
this.browser = undefined;
this.context = undefined;
this.page = undefined;
}
async captureState() {
const page = this.requirePage();
const url = page.url();
const title = await page.title();
const domSnapshot = await page.evaluate(() => document.body.outerHTML);
const stateId = domHash(url, domSnapshot);
return {
id: stateId,
url,
title,
timestamp: Date.now(),
domSnapshot,
visitCount: 0,
};
}
async discoverActions(state) {
const page = this.requirePage();
const actions = [];
const now = Date.now();
const currentUrl = page.url();
if (this.isExcludedPath(currentUrl)) {
return [];
}
// Discover clickable elements
const clickableSelectors = [
'a[href]',
'button',
'[role="button"]',
'input[type="submit"]',
'input[type="button"]',
];
for (const selector of clickableSelectors) {
if (this.isExcludedSelector(selector))
continue;
const elements = await page.locator(selector).all();
for (const el of elements) {
const isVisible = await el.isVisible().catch(() => false);
if (!isVisible)
continue;
// Check element against excluded selectors
const elSel = await this.buildSelector(el, selector);
if (this.isExcludedSelector(elSel))
continue;
// For links, check domain is allowed
if (selector === 'a[href]') {
const href = await el.getAttribute('href').catch(() => null);
if (href && this.isExternalLink(href, currentUrl))
continue;
}
const actionSeed = this.rng.nextInt(0x7fffffff);
actions.push({
id: generateId(),
type: 'click',
selector: elSel,
timestamp: now,
seed: actionSeed,
stateId: state.id,
});
}
}
// Discover fillable inputs
const inputSelectors = [
'input[type="text"]',
'input[type="email"]',
'input[type="password"]',
'textarea',
];
for (const selector of inputSelectors) {
if (this.isExcludedSelector(selector))
continue;
const elements = await page.locator(selector).all();
for (const el of elements) {
const isVisible = await el.isVisible().catch(() => false);
if (!isVisible)
continue;
const elSel = await this.buildSelector(el, selector);
if (this.isExcludedSelector(elSel))
continue;
const actionSeed = this.rng.nextInt(0x7fffffff);
actions.push({
id: generateId(),
type: 'fill',
selector: elSel,
value: '',
timestamp: now,
seed: actionSeed,
stateId: state.id,
});
}
}
return actions;
}
async executeAction(action) {
const page = this.requirePage();
this.resetPending();
const observationId = generateId();
const actionDelayMs = this.explorationConfig.actionDelayMs ?? 0;
// Skip actions targeting excluded paths
if (action.url && this.isExcludedPath(action.url)) {
const state = await this.captureState();
return this.buildObservation(observationId, action.id, state.id);
}
// Enforce allowed domains for navigate actions
if (action.type === 'navigate' && action.url) {
if (!this.isAllowedUrl(action.url)) {
const state = await this.captureState();
return this.buildObservation(observationId, action.id, state.id);
}
}
try {
switch (action.type) {
case 'click':
await page.locator(action.selector).first().click({ timeout: this.timeoutMs });
break;
case 'fill':
await page.locator(action.selector).first().fill(action.value ?? '', { timeout: this.timeoutMs });
break;
case 'navigate':
await page.goto(action.url, { timeout: this.timeoutMs });
break;
case 'submit':
await page.locator(action.selector).first().dispatchEvent('submit');
break;
case 'select':
await page.locator(action.selector).first().selectOption(action.value ?? '');
break;
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.pendingJsExceptions.push(`Action ${action.type} failed: ${msg}`);
}
// Wait for async effects to settle + configured delay
await page.waitForTimeout(200 + actionDelayMs);
const newState = await this.captureState();
return this.buildObservation(observationId, action.id, newState.id);
}
// ─── Private helpers ──────────────────────────────────────────────────────
getPage() {
return this.requirePage();
}
requirePage() {
if (!this.page)
throw new Error('PlaywrightAgent: not launched. Call launch() first.');
return this.page;
}
resetPending() {
this.pendingResponses = [];
this.pendingConsoleErrors = [];
this.pendingJsExceptions = [];
}
buildObservation(observationId, actionId, newStateId) {
return {
id: observationId,
actionId,
newStateId,
httpResponses: [...this.pendingResponses],
consoleErrors: [...this.pendingConsoleErrors],
jsExceptions: [...this.pendingJsExceptions],
timestamp: Date.now(),
};
}
setupListeners(page) {
const requestTimestamps = new Map();
page.on('request', (req) => {
requestTimestamps.set(req.url(), Date.now());
});
page.on('response', (res) => {
const start = requestTimestamps.get(res.url()) ?? Date.now();
const durationMs = Date.now() - start;
this.pendingResponses.push({
url: res.url(),
status: res.status(),
method: res.request().method(),
durationMs,
});
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
this.pendingConsoleErrors.push(msg.text());
}
});
page.on('pageerror', (err) => {
this.pendingJsExceptions.push(err.message);
});
}
async buildSelector(el, fallback) {
try {
const id = await el.getAttribute('id').catch(() => null);
if (id)
return `#${id}`;
const name = await el.getAttribute('name').catch(() => null);
if (name)
return `[name="${name}"]`;
}
catch {
// ignore
}
return fallback;
}
isExcludedPath(urlOrPath) {
const excludedPaths = this.explorationConfig.excludedPaths ?? [];
if (excludedPaths.length === 0)
return false;
try {
const parsed = new URL(urlOrPath, 'http://placeholder');
return excludedPaths.some((p) => parsed.pathname.startsWith(p));
}
catch {
return false;
}
}
isExcludedSelector(selector) {
const excludedSelectors = this.explorationConfig.excludedSelectors ?? [];
return excludedSelectors.includes(selector);
}
isExternalLink(href, currentUrl) {
const allowedDomains = this.explorationConfig.allowedDomains ?? [];
if (allowedDomains.length === 0)
return false;
try {
const base = new URL(currentUrl);
const target = new URL(href, base.origin);
return !allowedDomains.includes(target.hostname);
}
catch {
return false;
}
}
isAllowedUrl(url) {
const allowedDomains = this.explorationConfig.allowedDomains ?? [];
if (allowedDomains.length === 0)
return true;
try {
const parsed = new URL(url);
return allowedDomains.includes(parsed.hostname);
}
catch {
return false;
}
}
async performLoginFlow(auth) {
const page = this.requirePage();
await page.goto(auth.loginUrl, { timeout: this.timeoutMs });
await page.locator(auth.usernameSelector).first().fill(auth.username);
await page.locator(auth.passwordSelector).first().fill(auth.password);
await page.locator(auth.submitSelector).first().click();
await page.waitForNavigation({ timeout: this.timeoutMs }).catch(() => undefined);
const currentUrl = page.url();
if (currentUrl === auth.loginUrl || currentUrl.includes(new URL(auth.loginUrl).pathname)) {
throw new Error(`Login failed: still on login page ${currentUrl}`);
}
}
async applyNetworkChaos(page) {
const chaos = this.explorationConfig.networkChaos;
if (!chaos?.enabled)
return;
// Apply network condition via CDP (Chromium only)
const profile = chaos.profile ?? 'none';
const condition = ExplorationConfig_1.NETWORK_PROFILES[profile];
if (condition) {
try {
const client = await this.context.newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: condition.offline,
downloadThroughput: condition.offline ? -1 : (condition.downloadKbps * 1024) / 8,
uploadThroughput: condition.offline ? -1 : (condition.uploadKbps * 1024) / 8,
latency: condition.latencyMs,
});
}
catch {
// CDP not available (e.g. non-Chromium browser) — ignore
}
}
// Block specified endpoints
if (chaos.blockedEndpoints && chaos.blockedEndpoints.length > 0) {
await page.route('**/*', (route) => {
const url = route.request().url();
const isBlocked = chaos.blockedEndpoints.some((pattern) => this.matchGlob(url, pattern));
if (isBlocked) {
route.fulfill({ status: 503, body: 'Service Unavailable (ABE Network Chaos)' });
}
else {
route.continue();
}
});
}
// Slow down specified endpoints
if (chaos.slowEndpoints && chaos.slowEndpoints.length > 0) {
for (const slowEp of chaos.slowEndpoints) {
await page.route(slowEp.pattern, async (route) => {
await new Promise((r) => setTimeout(r, slowEp.delayMs));
route.continue();
});
}
}
}
matchGlob(url, pattern) {
// Convert glob pattern to regex
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
try {
return new RegExp(escaped).test(url);
}
catch {
return false;
}
}
/**
* Detects mobile layout issues on the current page:
* - Touch targets smaller than 44x44px (WCAG 2.5.5 / Apple HIG)
* - Horizontal content overflow beyond viewport width
*/
async detectMobileLayoutIssues(stateId, sessionId, actionTrace) {
const page = this.requirePage();
const anomalies = [];
try {
const issues = await page.evaluate(() => {
const findings = [];
// Check for horizontal overflow
const docWidth = document.documentElement.scrollWidth;
const viewportWidth = window.innerWidth;
if (docWidth > viewportWidth) {
findings.push(`horizontal_overflow: ${docWidth}px > ${viewportWidth}px viewport`);
}
// Check for small touch targets (< 44x44 px)
const interactiveSelectors = ['a', 'button', '[role="button"]', 'input', 'select', 'textarea'];
const seen = new Set();
for (const sel of interactiveSelectors) {
for (const el of document.querySelectorAll(sel)) {
if (seen.has(el))
continue;
seen.add(el);
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0)
continue;
if (rect.width < 44 || rect.height < 44) {
const label = el.getAttribute('aria-label') ||
el.textContent?.trim().slice(0, 30) ||
el.tagName.toLowerCase();
findings.push(`small_touch_target: "${label}" (${Math.round(rect.width)}x${Math.round(rect.height)}px)`);
if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5)
break;
}
}
if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5)
break;
}
return findings;
}).catch(() => []);
if (issues.length === 0)
return anomalies;
const hasOverflow = issues.some((i) => i.startsWith('horizontal_overflow'));
const smallTargetCount = issues.filter((i) => i.startsWith('small_touch_target')).length;
const severity = hasOverflow ? 'high' : smallTargetCount >= 3 ? 'medium' : 'low';
anomalies.push({
id: generateId(),
type: 'mobile_layout_issue',
severity,
observationId: stateId,
actionTrace,
description: `Mobile layout issues detected: ${issues.slice(0, 3).join('; ')}`,
evidence: { rawErrors: issues },
timestamp: Date.now(),
});
}
catch {
// Page may be in invalid state — ignore
}
return anomalies;
}
}
exports.PlaywrightAgent = PlaywrightAgent;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

97
dist/plugins/exporters/JSONExporter.js vendored Normal file
View File

@@ -0,0 +1,97 @@
"use strict";
/**
* JSONExporter — produces a structured JSON report for AI debugging workflows.
* Output: reports/{anomaly-id}/report.json
*/
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.JSONExporter = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
class JSONExporter {
constructor(targetUrl = '', abeVersion = '0.1.0') {
this.targetUrl = targetUrl;
this.abeVersion = abeVersion;
this.format = 'json';
}
async export(anomaly, outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
const report = {
version: '1.0',
generated_at: new Date(anomaly.timestamp).toISOString(),
environment: {
target_url: this.targetUrl,
abe_version: this.abeVersion,
os: os.platform(),
node_version: process.version,
},
anomaly: {
id: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
description: anomaly.description,
timestamp: anomaly.timestamp,
},
reproduction: {
seed: anomaly.actionTrace[0]?.seed ?? null,
steps: anomaly.actionTrace.map((action, index) => ({
step: index + 1,
action_type: action.type,
selector: action.selector,
value: action.value,
url: action.url,
timestamp: action.timestamp,
})),
},
evidence: {
screenshot: anomaly.evidence.screenshotPath ?? null,
dom_snapshot: anomaly.evidence.domSnapshotPath ?? null,
http_log: (anomaly.evidence.httpLog ?? []).map((r) => ({
url: r.url,
method: r.method,
status: r.status,
duration_ms: r.durationMs,
})),
console_errors: anomaly.evidence.rawErrors?.filter((e) => e.startsWith('console:')) ?? [],
js_exceptions: anomaly.evidence.rawErrors?.filter((e) => !e.startsWith('console:')) ?? [],
},
};
const filePath = path.join(outputDir, 'report.json');
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf8');
return filePath;
}
}
exports.JSONExporter = JSONExporter;

View File

@@ -0,0 +1,113 @@
"use strict";
/**
* MarkdownExporter — produces a human-readable bug report.
* Output: reports/{anomaly-id}/report.md
*/
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.MarkdownExporter = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class MarkdownExporter {
constructor() {
this.format = 'markdown';
}
async export(anomaly, outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
const date = new Date(anomaly.timestamp).toISOString().split('T')[0];
const seed = anomaly.actionTrace[0]?.seed ?? 'N/A';
const replayCmd = `npm run replay -- --report ${outputDir}/report.json`;
const steps = anomaly.actionTrace
.map((action, i) => {
switch (action.type) {
case 'navigate':
return `${i + 1}. Navigate to \`${action.url}\``;
case 'click':
return `${i + 1}. Click element \`${action.selector}\``;
case 'fill':
return `${i + 1}. Fill \`${action.selector}\` with \`${JSON.stringify(action.value ?? '')}\``;
case 'select':
return `${i + 1}. Select \`${action.value}\` in \`${action.selector}\``;
case 'submit':
return `${i + 1}. Submit form \`${action.selector}\``;
default:
return `${i + 1}. ${action.type}`;
}
})
.join('\n');
const httpTable = (anomaly.evidence.httpLog ?? []).length > 0
? [
'| Method | URL | Status | Duration |',
'|--------|-----|--------|----------|',
...(anomaly.evidence.httpLog ?? []).map((r) => `| ${r.method} | ${r.url} | ${r.status} | ${r.durationMs}ms |`),
].join('\n')
: '_No HTTP log available._';
const rawErrors = (anomaly.evidence.rawErrors ?? []).length > 0
? '```\n' + anomaly.evidence.rawErrors.join('\n') + '\n```'
: '_No raw errors recorded._';
const md = `# Bug Report — ${anomaly.type}${date}
## Summary
${anomaly.description}
## Severity
**${anomaly.severity}** — detected by ABE heuristic rule \`${anomaly.type}\`
## Reproduction Steps
${steps.length > 0 ? steps : '_No steps recorded._'}
**Seed used**: \`${seed}\`
**Replay command**: \`${replayCmd}\`
## Observed Behavior
${anomaly.description}
## Evidence
- Screenshot: \`${anomaly.evidence.screenshotPath ?? 'N/A'}\`
- DOM Snapshot: \`${anomaly.evidence.domSnapshotPath ?? 'N/A'}\`
- HTTP Log:
${httpTable}
## Raw Errors
${rawErrors}
`;
const filePath = path.join(outputDir, 'report.md');
fs.writeFileSync(filePath, md, 'utf8');
return filePath;
}
}
exports.MarkdownExporter = MarkdownExporter;

139
dist/plugins/fuzzers/FuzzingEngine.js vendored Normal file
View File

@@ -0,0 +1,139 @@
"use strict";
/**
* FuzzingEngine — orchestrates fuzzing strategies for form inputs.
* Implements IFuzzingPlugin so ExplorationEngine doesn't need to import it directly.
*/
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.FuzzingEngine = void 0;
const crypto = __importStar(require("crypto"));
const InputTypeDetector_1 = require("./InputTypeDetector");
const EmptyValueStrategy_1 = require("./strategies/EmptyValueStrategy");
const OversizedStringStrategy_1 = require("./strategies/OversizedStringStrategy");
const SpecialCharsStrategy_1 = require("./strategies/SpecialCharsStrategy");
const TypeMismatchStrategy_1 = require("./strategies/TypeMismatchStrategy");
const BoundaryValueStrategy_1 = require("./strategies/BoundaryValueStrategy");
/** Regex to match basic input elements in an HTML string */
const INPUT_RE = /<(input|textarea|select)[^>]*>/gi;
const ATTR_RE = (name) => new RegExp(`${name}="([^"]*)"`, 'i');
function extractFields(domSnapshot) {
const fields = [];
let match;
while ((match = INPUT_RE.exec(domSnapshot)) !== null) {
const tag = match[0] ?? '';
const tagName = match[1] ?? 'input';
const idMatch = ATTR_RE('id').exec(tag);
const nameMatch = ATTR_RE('name').exec(tag);
const typeMatch = ATTR_RE('type').exec(tag);
const placeholderMatch = ATTR_RE('placeholder').exec(tag);
const ariaMatch = ATTR_RE('aria-label').exec(tag);
const selector = idMatch?.[1]
? `#${idMatch[1]}`
: nameMatch?.[1]
? `[name="${nameMatch[1]}"]`
: tagName;
fields.push({
selector,
tagName,
inputType: typeMatch?.[1],
name: nameMatch?.[1],
placeholder: placeholderMatch?.[1],
ariaLabel: ariaMatch?.[1],
});
}
return fields;
}
class FuzzingEngine {
constructor(config) {
this.intensity = config.intensity;
this.seed = config.seed;
}
/** IFuzzingPlugin implementation — parses fields from DOM snapshot */
generateFuzzActions(domSnapshot, state) {
const fields = extractFields(domSnapshot);
return this.generateFuzzActionsForFields(fields, state);
}
/** Generate fuzz actions from explicit field descriptors */
generateFuzzActionsForFields(fields, state) {
const actions = [];
const now = Date.now();
const strategies = this.selectStrategies();
for (const field of fields) {
const detectedType = (0, InputTypeDetector_1.detectInputType)({
tagName: field.tagName,
inputType: field.inputType,
name: field.name,
placeholder: field.placeholder,
ariaLabel: field.ariaLabel,
});
for (const strategy of strategies) {
const values = this.getValuesFromStrategy(strategy, detectedType);
for (const value of values) {
actions.push({
id: crypto.randomUUID(),
type: 'fill',
selector: field.selector,
value,
timestamp: now,
seed: this.seed,
stateId: state.id,
});
}
}
}
return actions;
}
selectStrategies() {
const empty = new EmptyValueStrategy_1.EmptyValueStrategy();
const typeMismatch = new TypeMismatchStrategy_1.TypeMismatchStrategy();
const oversized = new OversizedStringStrategy_1.OversizedStringStrategy(this.intensity);
const boundary = new BoundaryValueStrategy_1.BoundaryValueStrategy();
const special = new SpecialCharsStrategy_1.SpecialCharsStrategy();
switch (this.intensity) {
case 'low':
return [empty, typeMismatch];
case 'medium':
return [empty, typeMismatch, oversized, boundary];
case 'high':
return [empty, typeMismatch, oversized, boundary, special];
}
}
getValuesFromStrategy(strategy, type) {
if (!strategy.appliesTo(type))
return [];
return strategy.values(type);
}
}
exports.FuzzingEngine = FuzzingEngine;

View File

@@ -0,0 +1,52 @@
"use strict";
/**
* InputTypeDetector — detects field type from DOM attributes.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectInputType = detectInputType;
/** Detect type from input[type], name, placeholder, aria-label */
function detectInputType(attrs) {
const tag = (attrs.tagName ?? '').toLowerCase();
if (tag === 'textarea')
return 'textarea';
if (tag === 'select')
return 'select';
const inputType = (attrs.inputType ?? '').toLowerCase();
if (inputType === 'email')
return 'email';
if (inputType === 'password')
return 'password';
if (inputType === 'number')
return 'number';
if (inputType === 'date')
return 'date';
if (inputType === 'tel')
return 'phone';
if (inputType === 'url')
return 'url';
if (inputType === 'search')
return 'search';
if (inputType === 'file')
return 'file';
// Infer from name/placeholder/aria-label
const hints = [
(attrs.name ?? '').toLowerCase(),
(attrs.placeholder ?? '').toLowerCase(),
(attrs.ariaLabel ?? '').toLowerCase(),
].join(' ');
if (/email/.test(hints))
return 'email';
if (/password|pass/.test(hints))
return 'password';
if (/phone|tel|mobile/.test(hints))
return 'phone';
if (/date|birth|dob/.test(hints))
return 'date';
if (/number|qty|quantity|age/.test(hints))
return 'number';
if (/search/.test(hints))
return 'search';
if (/url|website|link/.test(hints))
return 'url';
return 'text';
}

View File

@@ -0,0 +1,26 @@
"use strict";
/**
* BoundaryValueStrategy — tests values at the edges of expected ranges.
* Applies to: number, date.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.BoundaryValueStrategy = void 0;
class BoundaryValueStrategy {
constructor() {
this.name = 'BoundaryValueStrategy';
}
appliesTo(type) {
return type === 'number' || type === 'date';
}
values(type) {
switch (type) {
case 'number':
return ['0', '-1', '2147483647', '2147483648', '-2147483648'];
case 'date':
return ['1900-01-01', '2099-12-31', '1970-01-01'];
default:
return [];
}
}
}
exports.BoundaryValueStrategy = BoundaryValueStrategy;

View File

@@ -0,0 +1,19 @@
"use strict";
/**
* EmptyValueStrategy — submits empty/whitespace values to catch missing server-side validation.
* Applies to: all input types.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.EmptyValueStrategy = void 0;
class EmptyValueStrategy {
constructor() {
this.name = 'EmptyValueStrategy';
}
appliesTo(_type) {
return true;
}
values() {
return ['', ' ', '\t'];
}
}
exports.EmptyValueStrategy = EmptyValueStrategy;

View File

@@ -0,0 +1,28 @@
"use strict";
/**
* OversizedStringStrategy — submits strings far beyond expected length.
* Applies to: text, email, password, textarea.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OversizedStringStrategy = void 0;
const APPLICABLE_TYPES = ['text', 'email', 'password', 'textarea'];
class OversizedStringStrategy {
constructor(intensity) {
this.intensity = intensity;
this.name = 'OversizedStringStrategy';
}
appliesTo(type) {
return APPLICABLE_TYPES.includes(type);
}
values() {
switch (this.intensity) {
case 'low':
return ['A'.repeat(256)];
case 'medium':
return ['A'.repeat(1024)];
case 'high':
return ['A'.repeat(10000) + '日本語テスト𠮷野家'];
}
}
}
exports.OversizedStringStrategy = OversizedStringStrategy;

View File

@@ -0,0 +1,26 @@
"use strict";
/**
* SpecialCharsStrategy — injects characters that break SQL, HTML, and shell contexts.
* Applies to: text, email, search, textarea.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpecialCharsStrategy = void 0;
const APPLICABLE_TYPES = ['text', 'email', 'search', 'textarea'];
class SpecialCharsStrategy {
constructor() {
this.name = 'SpecialCharsStrategy';
}
appliesTo(type) {
return APPLICABLE_TYPES.includes(type);
}
values() {
return [
"' OR 1=1 --",
'<script>alert(1)</script>',
'../../etc/passwd',
'${7*7}',
'\x00\x01\x02',
];
}
}
exports.SpecialCharsStrategy = SpecialCharsStrategy;

View File

@@ -0,0 +1,31 @@
"use strict";
/**
* TypeMismatchStrategy — submits wrong data types for the detected field type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.TypeMismatchStrategy = void 0;
class TypeMismatchStrategy {
constructor() {
this.name = 'TypeMismatchStrategy';
}
appliesTo(type) {
return ['email', 'number', 'date', 'url', 'phone'].includes(type);
}
values(type) {
switch (type) {
case 'email':
return ['not-an-email', '12345', '@@@'];
case 'number':
return ['abc', '-999999', '9.9.9', 'NaN'];
case 'date':
return ['yesterday', '32/13/2025', '0000-00-00'];
case 'url':
return ['javascript:alert(1)', 'not a url'];
case 'phone':
return ['000', '++++', 'abcdefghij'];
default:
return [];
}
}
}
exports.TypeMismatchStrategy = TypeMismatchStrategy;

7
dist/plugins/interfaces.js vendored Normal file
View File

@@ -0,0 +1,7 @@
"use strict";
/**
* Plugin interfaces re-exported from core for plugin implementations to use.
* All interface definitions live in src/core/interfaces.ts so that core
* code can depend on them without creating a circular dependency.
*/
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,59 @@
"use strict";
/**
* PlaywrightReproducer — serializes an action trace and generates a
* deterministic Playwright script for replay.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.PlaywrightReproducer = void 0;
class PlaywrightReproducer {
serialize(trace) {
return JSON.stringify(trace, null, 2);
}
deserialize(raw) {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('PlaywrightReproducer.deserialize: expected a JSON array');
}
return parsed;
}
generateScript(trace) {
const lines = [
'// Auto-generated replay script by ABE (Autonomous Bug Explorer)',
`// Generated at: ${new Date().toISOString()}`,
`// Steps: ${trace.length}`,
'',
"const { chromium } = require('playwright');",
'',
'(async () => {',
' const browser = await chromium.launch({ headless: true });',
' const context = await browser.newContext();',
' const page = await context.newPage();',
'',
];
for (let i = 0; i < trace.length; i++) {
const action = trace[i];
lines.push(` // Step ${i + 1}: ${action.type} (seed=${action.seed})`);
switch (action.type) {
case 'navigate':
lines.push(` await page.goto(${JSON.stringify(action.url)});`);
break;
case 'click':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().click();`);
break;
case 'fill':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().fill(${JSON.stringify(action.value ?? '')});`);
break;
case 'select':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().selectOption(${JSON.stringify(action.value ?? '')});`);
break;
case 'submit':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().dispatchEvent('submit');`);
break;
}
lines.push('');
}
lines.push(" console.log('Replay complete');", ' await browser.close();', '})();');
return lines.join('\n');
}
}
exports.PlaywrightReproducer = PlaywrightReproducer;

84
dist/replay.js vendored Normal file
View File

@@ -0,0 +1,84 @@
"use strict";
/**
* ABE Replay Script Runner
* Loads a report.json and executes the generated Playwright replay script.
*
* Usage:
* npm run replay -- --report reports/anom_xyz/report.json
*/
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 });
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
function parseArgs() {
const args = process.argv.slice(2);
let reportPath = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--report' && args[i + 1])
reportPath = args[++i];
}
if (!reportPath) {
console.error('Usage: npm run replay -- --report <path-to-report.json>');
process.exit(1);
}
return { reportPath };
}
async function main() {
const { reportPath } = parseArgs();
if (!fs.existsSync(reportPath)) {
console.error(`Report not found: ${reportPath}`);
process.exit(1);
}
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
// Reconstruct action trace from report steps
const trace = report.reproduction.steps.map((step) => ({
id: `replay_step_${step.step}`,
type: step.action_type,
selector: step.selector,
value: step.value,
url: step.url,
timestamp: step.timestamp,
seed: report.reproduction.seed ?? 42,
stateId: 'replay',
}));
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const script = reproducer.generateScript(trace);
const scriptPath = path.join(path.dirname(reportPath), 'replay.js');
fs.writeFileSync(scriptPath, script, 'utf8');
console.log(`[ABE Replay] Script written to: ${scriptPath}`);
console.log(`[ABE Replay] Run with: node ${scriptPath}`);
}
main();

398
dist/server/SessionStore.js vendored Normal file
View File

@@ -0,0 +1,398 @@
"use strict";
/**
* SessionStore — manages active sessions and persists to SQLite.
* In-memory map for running engines; DB for durable storage.
*/
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;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionStore = void 0;
const path_1 = __importDefault(require("path"));
const ExplorationEngine_1 = require("../core/ExplorationEngine");
const StateGraph_1 = require("../core/StateGraph");
const PlaywrightAgent_1 = require("../plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("../plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("../plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("../plugins/collectors/DOMSnapshotCollector");
const MarkdownExporter_1 = require("../plugins/exporters/MarkdownExporter");
const JSONExporter_1 = require("../plugins/exporters/JSONExporter");
const PlaywrightReproducer_1 = require("../plugins/reproducers/PlaywrightReproducer");
const FuzzingEngine_1 = require("../plugins/fuzzers/FuzzingEngine");
const VisualRegressionCollector_1 = require("../plugins/collectors/VisualRegressionCollector");
const AccessibilityCollector_1 = require("../plugins/collectors/AccessibilityCollector");
const PerformanceCollector_1 = require("../plugins/collectors/PerformanceCollector");
class SessionStore {
constructor(outputDir = './reports', sessionRepo, anomalyRepo, maxConcurrentSessions = 3, notificationService, visualRepo) {
this.sessions = new Map();
this.emitter = () => undefined;
/** In-memory performance metrics keyed by sessionId */
this.performanceMetrics = new Map();
this.outputDir = outputDir;
this.sessionRepo = sessionRepo ?? null;
this.anomalyRepo = anomalyRepo ?? null;
this.maxConcurrentSessions = maxConcurrentSessions;
this.notificationService = notificationService ?? null;
this.visualRepo = visualRepo ?? null;
}
getPerformanceMetrics(sessionId) {
return this.performanceMetrics.get(sessionId) ?? [];
}
getMaxConcurrent() {
return this.maxConcurrentSessions;
}
setEmitter(emitter) {
this.emitter = emitter;
}
getAllSessions() {
if (this.sessionRepo) {
const rows = this.sessionRepo.findAll();
return rows.map((r) => {
const live = this.sessions.get(r.id);
return {
sessionId: r.id,
url: r.url,
seed: r.seed,
maxStates: r.max_states,
status: r.status,
startedAt: new Date(r.started_at).toISOString(),
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined,
statesVisited: r.states_visited,
anomaliesFound: r.anomalies_found,
anomalies: live?.anomalies ?? [],
engine: live?.engine,
};
});
}
return Array.from(this.sessions.values());
}
getSession(sessionId) {
if (this.sessionRepo) {
const r = this.sessionRepo.findById(sessionId);
if (!r)
return undefined;
const live = this.sessions.get(sessionId);
return {
sessionId: r.id,
url: r.url,
seed: r.seed,
maxStates: r.max_states,
status: r.status,
startedAt: new Date(r.started_at).toISOString(),
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined,
statesVisited: r.states_visited,
anomaliesFound: r.anomalies_found,
anomalies: live?.anomalies ?? [],
engine: live?.engine,
};
}
return this.sessions.get(sessionId);
}
getAllAnomalies(sessionId, severity) {
if (this.anomalyRepo) {
return this.anomalyRepo.findAll({ sessionId, severity });
}
const all = Array.from(this.sessions.values()).flatMap((s) => s.anomalies);
return all
.filter((a) => !sessionId || this.findSessionForAnomaly(a.id) === sessionId)
.filter((a) => !severity || a.severity === severity);
}
getAnomaly(anomalyId) {
if (this.anomalyRepo) {
return this.anomalyRepo.findById(anomalyId) ?? undefined;
}
for (const session of this.sessions.values()) {
const found = session.anomalies.find((a) => a.id === anomalyId);
if (found)
return found;
}
return undefined;
}
findSessionForAnomaly(anomalyId) {
if (this.anomalyRepo) {
const a = this.anomalyRepo.findById(anomalyId);
return a?.sessionId;
}
for (const session of this.sessions.values()) {
if (session.anomalies.some((a) => a.id === anomalyId))
return session.sessionId;
}
return undefined;
}
screenshotPath(anomalyId) {
const anomaly = this.getAnomaly(anomalyId);
if (!anomaly?.evidence.screenshotPath)
return undefined;
const sessionId = this.findSessionForAnomaly(anomalyId);
if (!sessionId)
return undefined;
return path_1.default.resolve(this.outputDir, anomalyId, anomaly.evidence.screenshotPath);
}
stopSession(sessionId) {
const record = this.sessions.get(sessionId);
if (!record || record.status !== 'running')
return false;
record.engine?.stop();
record.status = 'stopped';
record.finishedAt = new Date().toISOString();
this.sessionRepo?.update(sessionId, { status: 'stopped', finishedAt: Date.now() });
return true;
}
getStats() {
if (this.sessionRepo && this.anomalyRepo) {
const rows = this.sessionRepo.findAll();
return {
totalSessions: rows.length,
totalAnomalies: this.anomalyRepo.count(),
criticalHighCount: this.anomalyRepo.countBySeverity(['high', 'critical']),
runningSessions: rows.filter((r) => r.status === 'running').length,
};
}
const sessions = Array.from(this.sessions.values());
const anomalies = sessions.flatMap((s) => s.anomalies);
return {
totalSessions: sessions.length,
totalAnomalies: anomalies.length,
criticalHighCount: anomalies.filter((a) => a.severity === 'high' || a.severity === 'critical').length,
runningSessions: sessions.filter((s) => s.status === 'running').length,
};
}
async startSession(params) {
const sessionId = `sess_${Date.now()}_${params.seed}`;
const startedAt = new Date().toISOString();
const startedAtMs = Date.now();
const record = {
sessionId,
url: params.url,
seed: params.seed,
maxStates: params.maxStates,
status: 'running',
startedAt,
statesVisited: 0,
anomaliesFound: 0,
anomalies: [],
};
this.sessions.set(sessionId, record);
this.sessionRepo?.create({
id: sessionId,
url: params.url,
seed: params.seed,
maxStates: params.maxStates,
startedAt: startedAtMs,
configJson: params.explorationConfig ? JSON.stringify(params.explorationConfig) : '{}',
});
this.emitter('session:started', { sessionId, url: params.url });
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({
seed: params.seed,
explorationConfig: params.explorationConfig,
});
const fuzzingEnabled = params.explorationConfig?.fuzzingEnabled !== false;
const fuzzingIntensity = params.explorationConfig?.fuzzingIntensity ?? 'medium';
const fuzzingPlugin = fuzzingEnabled
? new FuzzingEngine_1.FuzzingEngine({ intensity: fuzzingIntensity, seed: params.seed })
: undefined;
// Build state hooks for visual regression, accessibility, and performance
const stateHooks = [];
// Visual regression hook
if (params.explorationConfig?.visualRegression?.enabled && this.visualRepo) {
const visualCollector = new VisualRegressionCollector_1.VisualRegressionCollector(this.outputDir, this.visualRepo, params.explorationConfig.visualRegression);
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.getPage)
return [];
// Take screenshot for visual comparison
const screenshotPath = path_1.default.join(this.outputDir, sid, `visual_${state.id}.png`);
try {
const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises')));
await fs_mod.mkdir(path_1.default.dirname(screenshotPath), { recursive: true });
await pw.getPage().screenshot({ path: screenshotPath });
const anomaly = await visualCollector.processScreenshot(screenshotPath, state, sid, actionTrace);
return anomaly ? [anomaly] : [];
}
catch {
return [];
}
});
}
// Accessibility hook
if (params.explorationConfig?.accessibility?.enabled !== false) {
const a11yCollector = new AccessibilityCollector_1.AccessibilityCollector(params.explorationConfig?.accessibility);
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.getPage)
return [];
return a11yCollector.collect(pw.getPage(), state.id, sid, actionTrace);
});
}
// Performance hook
if (params.explorationConfig?.performance?.enabled !== false) {
const perfCollector = new PerformanceCollector_1.PerformanceCollector(params.explorationConfig?.performance);
this.performanceMetrics.set(sessionId, []);
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.getPage)
return [];
const { metrics, anomalies } = await perfCollector.collect(pw.getPage(), state.id, sid, actionTrace);
const existing = this.performanceMetrics.get(sid) ?? [];
existing.push(metrics);
this.performanceMetrics.set(sid, existing);
return anomalies;
});
}
// Mobile layout hook (only when a mobile device is emulated)
if (params.explorationConfig?.mobileDevice && params.explorationConfig.mobileDevice !== 'none') {
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.detectMobileLayoutIssues)
return [];
return pw.detectMobileLayoutIssues(state.id, sid, actionTrace);
});
}
const engineConfig = {
graph,
agent,
seed: params.seed,
url: params.url,
maxSteps: params.maxStates,
explorationConfig: params.explorationConfig,
outputDir: this.outputDir,
sessionId,
fuzzingPlugin,
stateHooks,
collectors: [
new ScreenshotCollector_1.ScreenshotCollector(this.outputDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(this.outputDir),
],
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
events: {
onSessionStarted: (sid, url) => {
this.emitter('session:started', { sessionId: sid, url });
},
onStateDiscovered: (sid, stateId, url, title) => {
record.statesVisited += 1;
this.sessionRepo?.update(sid, { statesVisited: record.statesVisited });
this.emitter('state:discovered', { sessionId: sid, stateId, url, title });
},
onActionExecuted: (sid, actionType, selector, timestamp) => {
this.emitter('action:executed', { sessionId: sid, actionType, selector, timestamp });
},
onAnomalyDetected: (sid, anomaly) => {
record.anomalies.push(anomaly);
record.anomaliesFound = record.anomalies.length;
this.sessionRepo?.update(sid, { anomaliesFound: record.anomaliesFound });
this.anomalyRepo?.create(anomaly, sid);
if (this.notificationService) {
void this.notificationService.notify(anomaly, sid, params.url);
}
this.emitter('anomaly:detected', {
sessionId: sid,
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
description: anomaly.description,
});
},
onSessionCompleted: (sid, statesVisited, anomaliesFound) => {
if (record.status === 'running') {
record.status = 'completed';
}
record.finishedAt = new Date().toISOString();
record.statesVisited = statesVisited;
record.anomaliesFound = anomaliesFound;
this.sessionRepo?.update(sid, {
status: record.status,
statesVisited,
anomaliesFound,
finishedAt: Date.now(),
});
this.emitter('session:completed', { sessionId: sid, statesVisited, anomaliesFound });
},
onSessionError: (sid, error) => {
record.status = 'error';
record.finishedAt = new Date().toISOString();
this.sessionRepo?.update(sid, { status: 'error', finishedAt: Date.now() });
this.emitter('session:error', { sessionId: sid, error });
},
},
};
const engine = new ExplorationEngine_1.ExplorationEngine(engineConfig);
record.engine = engine;
// Run in background — do not await
engine.run().catch((err) => {
if (record.status === 'running') {
record.status = 'error';
record.finishedAt = new Date().toISOString();
const msg = err instanceof Error ? err.message : String(err);
this.sessionRepo?.update(sessionId, { status: 'error', finishedAt: Date.now() });
this.emitter('session:error', { sessionId, error: msg });
}
});
return record;
}
async replayAnomaly(anomalyId) {
const replayId = `replay_${Date.now()}`;
const anomaly = this.getAnomaly(anomalyId);
if (!anomaly)
throw new Error(`Anomaly ${anomalyId} not found`);
const sessionId = this.findSessionForAnomaly(anomalyId);
const session = this.getSession(sessionId);
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const script = reproducer.generateScript(anomaly.actionTrace);
const replayDir = path_1.default.join(this.outputDir, anomalyId, 'replays');
setImmediate(async () => {
try {
const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises')));
await fs_mod.mkdir(replayDir, { recursive: true });
const scriptPath = path_1.default.join(replayDir, `${replayId}.ts`);
await fs_mod.writeFile(scriptPath, script, 'utf8');
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: session.seed });
await agent.launch(session.url);
for (const action of anomaly.actionTrace) {
await agent.executeAction(action).catch(() => undefined);
}
await agent.close();
}
catch {
// replay errors are non-fatal
}
});
return replayId;
}
}
exports.SessionStore = SessionStore;

View File

@@ -0,0 +1,71 @@
"use strict";
/**
* AIEnrichmentService — selects AI provider and runs enrichment asynchronously.
* Triggered manually (POST /api/anomalies/:id/enrich) or automatically for high/critical.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AIEnrichmentService = void 0;
const ClaudeProvider_1 = require("./ClaudeProvider");
const OpenAIProvider_1 = require("./OpenAIProvider");
const OllamaProvider_1 = require("./OllamaProvider");
const logger_1 = require("../logger");
class AIEnrichmentService {
constructor(emitter) {
this.emitter = emitter;
this.autoEnrich = process.env['ABE_AI_AUTO_ENRICH'] === 'true';
const minSev = process.env['ABE_AI_MIN_SEVERITY'] ?? 'high';
this.minSeverityRank = AIEnrichmentService.SEVERITY_RANK[minSev] ?? 2;
this.provider = this.createProvider();
}
createProvider() {
const providerName = process.env['ABE_AI_PROVIDER'] ?? 'none';
const model = process.env['ABE_AI_MODEL'];
if (providerName === 'claude') {
const key = process.env['ABE_AI_API_KEY'];
if (!key)
return null;
return new ClaudeProvider_1.ClaudeProvider(key, model);
}
if (providerName === 'openai') {
const key = process.env['ABE_OPENAI_API_KEY'];
if (!key)
return null;
return new OpenAIProvider_1.OpenAIProvider(key, model);
}
if (providerName === 'ollama') {
const url = process.env['ABE_OLLAMA_URL'] ?? 'http://localhost:11434';
return new OllamaProvider_1.OllamaProvider(url, model);
}
return null;
}
/** Check if auto-enrichment should run for this anomaly. */
shouldAutoEnrich(anomaly) {
if (!this.autoEnrich || !this.provider)
return false;
const rank = AIEnrichmentService.SEVERITY_RANK[anomaly.severity] ?? 0;
return rank >= this.minSeverityRank;
}
/** Enrich an anomaly asynchronously and emit WebSocket event when done. */
async enrich(anomaly, context) {
if (!this.provider) {
logger_1.log.warn({ anomalyId: anomaly.id }, 'No AI provider configured');
return;
}
try {
const enrichment = await this.provider.enrich(anomaly, context);
anomaly.aiEnrichment = enrichment;
this.emitter('anomaly:enriched', { anomalyId: anomaly.id, enrichment });
logger_1.log.info({ anomalyId: anomaly.id, provider: this.provider.name }, 'Anomaly enriched');
}
catch (err) {
logger_1.log.error({ anomalyId: anomaly.id, err: err instanceof Error ? err.message : String(err) }, 'AI enrichment failed');
}
}
hasProvider() {
return this.provider !== null;
}
}
exports.AIEnrichmentService = AIEnrichmentService;
AIEnrichmentService.SEVERITY_RANK = {
low: 0, medium: 1, high: 2, critical: 3,
};

View File

@@ -0,0 +1,88 @@
"use strict";
/**
* ClaudeProvider — AI enrichment using Anthropic API.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ClaudeProvider = void 0;
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
class ClaudeProvider {
constructor(apiKey, model = DEFAULT_MODEL) {
this.name = 'claude';
this.apiKey = apiKey;
this.model = model;
}
async enrich(anomaly, context) {
const prompt = buildPrompt(anomaly, context);
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: this.model,
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) {
throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const text = data.content.find((c) => c.type === 'text')?.text ?? '';
return parseEnrichment(text, this.name, this.model);
}
}
exports.ClaudeProvider = ClaudeProvider;
function buildPrompt(anomaly, context) {
return `You are a senior software engineer analyzing a bug report from an automated web testing tool.
Bug Report:
- Type: ${anomaly.type}
- Severity: ${anomaly.severity}
- Description: ${anomaly.description}
- URL: ${context.url}
- Page Title: ${context.pageTitle}
- Action Trace: ${JSON.stringify(anomaly.actionTrace.slice(-5), null, 2)}
${context.httpLog.length > 0 ? `- HTTP Log: ${JSON.stringify(context.httpLog.slice(-3), null, 2)}` : ''}
${context.consoleErrors.length > 0 ? `- Console Errors: ${context.consoleErrors.slice(-3).join('\n')}` : ''}
Please provide a concise analysis in exactly this JSON format:
{
"rootCause": "One sentence explaining the likely root cause",
"userImpact": "One sentence describing the impact on users",
"suggestedFix": "One to two sentences with a concrete fix suggestion",
"confidence": "low|medium|high"
}`;
}
function parseEnrichment(text, provider, model) {
const debugPrompt = `Bug analysis:\n${text}`;
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
const parsed = JSON.parse(match[0]);
return {
rootCause: parsed.rootCause ?? 'Unknown root cause',
userImpact: parsed.userImpact ?? 'Unknown impact',
suggestedFix: parsed.suggestedFix ?? 'No fix suggested',
debugPrompt,
confidence: parsed.confidence ?? 'medium',
generatedAt: Date.now(),
provider,
model,
};
}
}
catch { /* fallback below */ }
return {
rootCause: text.slice(0, 200) || 'Could not parse root cause',
userImpact: 'See full response',
suggestedFix: 'See full response',
debugPrompt,
confidence: 'low',
generatedAt: Date.now(),
provider,
model,
};
}

View File

@@ -0,0 +1,63 @@
"use strict";
/**
* OllamaProvider — AI enrichment using local Ollama API.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OllamaProvider = void 0;
const DEFAULT_MODEL = 'llama3.2';
const DEFAULT_URL = 'http://localhost:11434';
function buildPrompt(anomaly, context) {
return `Analyze this bug and respond ONLY with JSON {"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}.
Bug: ${anomaly.type} (${anomaly.severity}) at ${context.url}
Description: ${anomaly.description}
Last actions: ${anomaly.actionTrace.slice(-3).map((a) => `${a.type} ${a.selector ?? a.url ?? ''}`).join(' → ')}`;
}
class OllamaProvider {
constructor(baseUrl = DEFAULT_URL, model = DEFAULT_MODEL) {
this.name = 'ollama';
this.baseUrl = baseUrl;
this.model = model;
}
async enrich(anomaly, context) {
const prompt = buildPrompt(anomaly, context);
const res = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, prompt, stream: false }),
});
if (!res.ok) {
throw new Error(`Ollama API error: ${res.status}`);
}
const data = await res.json();
const text = data.response ?? '';
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
const p = JSON.parse(match[0]);
return {
rootCause: p['rootCause'] ?? 'Unknown',
userImpact: p['userImpact'] ?? 'Unknown',
suggestedFix: p['suggestedFix'] ?? 'None',
debugPrompt: text,
confidence: p['confidence'] ?? 'low',
generatedAt: Date.now(),
provider: 'ollama',
model: this.model,
};
}
}
catch { /* fallback */ }
return {
rootCause: text.slice(0, 200),
userImpact: 'See response',
suggestedFix: 'See response',
debugPrompt: text,
confidence: 'low',
generatedAt: Date.now(),
provider: 'ollama',
model: this.model,
};
}
}
exports.OllamaProvider = OllamaProvider;

View File

@@ -0,0 +1,81 @@
"use strict";
/**
* OpenAIProvider — AI enrichment using OpenAI API.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OpenAIProvider = void 0;
const DEFAULT_MODEL = 'gpt-4o-mini';
function buildPrompt(anomaly, context) {
return `You are a senior software engineer analyzing a bug report.
Bug:
- Type: ${anomaly.type}
- Severity: ${anomaly.severity}
- Description: ${anomaly.description}
- URL: ${context.url}
- Actions: ${JSON.stringify(anomaly.actionTrace.slice(-5))}
${context.httpLog.length > 0 ? `- HTTP: ${JSON.stringify(context.httpLog.slice(-3))}` : ''}
${context.consoleErrors.length > 0 ? `- Errors: ${context.consoleErrors.slice(-3).join('; ')}` : ''}
Respond ONLY with JSON:
{"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}`;
}
function parseResponse(text, model) {
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
const p = JSON.parse(match[0]);
return {
rootCause: p['rootCause'] ?? 'Unknown',
userImpact: p['userImpact'] ?? 'Unknown',
suggestedFix: p['suggestedFix'] ?? 'None',
debugPrompt: text,
confidence: p['confidence'] ?? 'medium',
generatedAt: Date.now(),
provider: 'openai',
model,
};
}
}
catch { /* fallback */ }
return {
rootCause: text.slice(0, 200),
userImpact: 'See response',
suggestedFix: 'See response',
debugPrompt: text,
confidence: 'low',
generatedAt: Date.now(),
provider: 'openai',
model,
};
}
class OpenAIProvider {
constructor(apiKey, model = DEFAULT_MODEL) {
this.name = 'openai';
this.apiKey = apiKey;
this.model = model;
}
async enrich(anomaly, context) {
const prompt = buildPrompt(anomaly, context);
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 512,
response_format: { type: 'json_object' },
}),
});
if (!res.ok) {
throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const text = data.choices[0]?.message?.content ?? '';
return parseResponse(text, this.model);
}
}
exports.OpenAIProvider = OpenAIProvider;

199
dist/server/index.js vendored Normal file
View File

@@ -0,0 +1,199 @@
"use strict";
/**
* ABE API Server
* Express + socket.io on port 3001.
* Manages exploration sessions and serves REST + WebSocket API.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createApp = createApp;
exports.createServer = createServer;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const http_1 = __importDefault(require("http"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const socket_io_1 = require("socket.io");
const sessions_1 = require("./routes/sessions");
const anomalies_1 = require("./routes/anomalies");
const config_1 = require("./routes/config");
const schedules_1 = require("./routes/schedules");
const visual_1 = require("./routes/visual");
const SessionStore_1 = require("./SessionStore");
const auth_1 = require("./middleware/auth");
const logger_1 = require("./logger");
const AIEnrichmentService_1 = require("./enrichment/AIEnrichmentService");
const PORT = process.env['ABE_PORT']
? parseInt(process.env['ABE_PORT'], 10)
: process.env['PORT']
? parseInt(process.env['PORT'], 10)
: 3001;
function createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService) {
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
const app = (0, express_1.default)();
app.use((0, cors_1.default)({ origin: corsOrigin }));
app.use(express_1.default.json());
// Health endpoints — no auth required
app.get('/health', (_req, res) => {
const uptime = Math.floor(process.uptime());
res.json({ status: 'ok', version: '0.1.0', uptime_seconds: uptime });
});
app.get('/ready', (_req, res) => {
const stats = store.getStats();
if (dbCheck && !dbCheck()) {
res.status(503).json({ status: 'not_ready', db: 'disconnected', active_sessions: stats.runningSessions });
return;
}
res.json({ status: 'ready', db: 'connected', active_sessions: stats.runningSessions });
});
// Apply API key auth to all /api/* routes
app.use('/api', auth_1.apiKeyAuth);
// Global rate limit: 200 req/min
const globalLimiter = (0, express_rate_limit_1.default)({
windowMs: 60 * 1000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', globalLimiter);
// POST /api/sessions rate limit: 20/hour
const sessionCreateLimiter = (0, express_rate_limit_1.default)({
windowMs: 60 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/sessions', sessionCreateLimiter);
app.get('/api/stats', (_req, res) => {
res.json(store.getStats());
});
app.use('/api/sessions', (0, sessions_1.createSessionRouter)(store));
app.use('/api/anomalies', (0, anomalies_1.createAnomalyRouter)(store, enrichmentService));
app.use('/api/config', (0, config_1.createConfigRouter)());
if (scheduleRepo && scheduler) {
app.use('/api/schedules', (0, schedules_1.createScheduleRouter)(scheduleRepo, scheduler));
}
if (visualRepo) {
app.use('/api/visual', (0, visual_1.createVisualRouter)(visualRepo));
}
// Global error handler
app.use((err, _req, res, _next) => {
const isDev = process.env['NODE_ENV'] !== 'production';
const message = isDev && err instanceof Error ? err.message : 'Internal server error';
res.status(500).json({
error: message,
code: 'INTERNAL_ERROR',
timestamp: Date.now(),
});
});
return app;
}
function createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo) {
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
// Deferred emitter: AIEnrichmentService is created before io, using a closure
let ioEmit = () => undefined;
const enrichmentService = new AIEnrichmentService_1.AIEnrichmentService((event, payload) => ioEmit(event, payload));
const app = createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService);
const httpServer = http_1.default.createServer(app);
const io = new socket_io_1.Server(httpServer, {
cors: { origin: corsOrigin },
});
// Now wire the real io emitter
ioEmit = (event, payload) => io.emit(event, payload);
io.on('connection', (socket) => {
socket.on('session:stop', (data) => {
store.stopSession(data.sessionId);
});
});
store.setEmitter((event, payload) => {
io.emit(event, payload);
// Auto-enrich high/critical anomalies
if (event === 'anomaly:detected') {
const p = payload;
if (p?.anomalyId) {
const anomaly = store.getAnomaly(p.anomalyId);
if (anomaly && enrichmentService.shouldAutoEnrich(anomaly)) {
const context = {
domSnapshot: '',
httpLog: anomaly.evidence.httpLog ?? [],
consoleErrors: anomaly.evidence.rawErrors ?? [],
actionTrace: anomaly.actionTrace,
pageTitle: '',
url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '',
};
void enrichmentService.enrich(anomaly, context);
}
}
}
});
return httpServer;
}
if (require.main === module) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getDb } = require('../db/connection');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { SessionRepository } = require('../db/SessionRepository');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AnomalyRepository } = require('../db/AnomalyRepository');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { NotificationService } = require('./notifications/NotificationService');
const db = getDb();
const sessionRepo = new SessionRepository(db);
const anomalyRepo = new AnomalyRepository(db);
const notificationService = new NotificationService({
persister: (record) => {
db.prepare(`INSERT OR REPLACE INTO notifications (id, anomaly_id, channel, status, sent_at, error)
VALUES (?, ?, ?, ?, ?, ?)`).run(record.id, record.anomalyId, record.channel, record.status, record.sentAt ?? null, record.error ?? null);
},
});
const outputDir = process.env['ABE_REPORTS_DIR'] ?? './reports';
const maxConcurrent = process.env['ABE_MAX_CONCURRENT_SESSIONS']
? parseInt(process.env['ABE_MAX_CONCURRENT_SESSIONS'], 10)
: 3;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { VisualBaselineRepository: VisualRepo } = require('../db/VisualBaselineRepository');
const visualRepo = new VisualRepo(db);
const store = new SessionStore_1.SessionStore(outputDir, sessionRepo, anomalyRepo, maxConcurrent, notificationService, visualRepo);
const dbCheck = () => { try {
db.prepare('SELECT 1').run();
return true;
}
catch {
return false;
} };
// Scheduler
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ScheduleRepository: SchedRepo } = require('../db/ScheduleRepository');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { SchedulerService: SchedSvc } = require('./scheduler/SchedulerService');
const scheduleRepo = new SchedRepo(db);
const scheduler = new SchedSvc(scheduleRepo, store);
scheduler.start();
const server = createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo);
// Graceful shutdown
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown)
return;
shuttingDown = true;
logger_1.log.info({ signal }, 'Graceful shutdown initiated');
scheduler.stop();
server.close(() => {
try {
db.close();
}
catch { /* ignore */ }
process.exit(0);
});
setTimeout(() => {
logger_1.log.error('Forced shutdown after 30s');
process.exit(1);
}, 30000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.listen(PORT, () => {
logger_1.log.info({ port: PORT }, 'ABE API server listening');
});
}

13
dist/server/logger.js vendored Normal file
View File

@@ -0,0 +1,13 @@
"use strict";
/**
* Structured logger using pino.
* Log level configurable via ABE_LOG_LEVEL env var (default: 'info').
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.log = void 0;
const pino_1 = __importDefault(require("pino"));
const level = process.env['ABE_LOG_LEVEL'] ?? 'info';
exports.log = (0, pino_1.default)({ level });

21
dist/server/middleware/auth.js vendored Normal file
View File

@@ -0,0 +1,21 @@
"use strict";
/**
* API Key authentication middleware.
* Reads ABE_API_KEY env var; if not set, dev mode (no auth).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.apiKeyAuth = apiKeyAuth;
function apiKeyAuth(req, res, next) {
const apiKey = process.env['ABE_API_KEY'];
if (!apiKey) {
// Dev mode: no auth required
next();
return;
}
const provided = req.headers['x-abe-api-key'];
if (!provided || provided !== apiKey) {
res.status(401).json({ error: 'Invalid or missing API key' });
return;
}
next();
}

View File

@@ -0,0 +1,121 @@
"use strict";
/**
* NotificationService — orchestrates notifiers.
* Called after every anomaly:detected event.
* Persists notification attempts to the notifications table.
*/
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.NotificationService = void 0;
const crypto = __importStar(require("crypto"));
const SlackNotifier_1 = require("./SlackNotifier");
const WebhookNotifier_1 = require("./WebhookNotifier");
const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
class NotificationService {
constructor(config) {
const slackUrl = config?.slackWebhookUrl ?? process.env['ABE_SLACK_WEBHOOK_URL'];
const webhookUrl = config?.webhookUrl ?? process.env['ABE_WEBHOOK_URL'];
const minSeverity = config?.minSeverity ?? process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high';
const frontendBase = config?.frontendBaseUrl ?? process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
if (slackUrl)
this.slack = new SlackNotifier_1.SlackNotifier(slackUrl, frontendBase);
if (webhookUrl)
this.webhook = new WebhookNotifier_1.WebhookNotifier(webhookUrl);
this.minSeverityRank = SEVERITY_RANK[minSeverity] ?? 2;
this.persister = config?.persister;
}
async notify(anomaly, sessionId, targetUrl) {
const anomalySeverityRank = SEVERITY_RANK[anomaly.severity] ?? 0;
if (anomalySeverityRank < this.minSeverityRank)
return;
const sends = [];
if (this.slack) {
sends.push(this.sendWithRetry('slack', anomaly, sessionId, targetUrl));
}
if (this.webhook) {
sends.push(this.sendWithRetry('webhook', anomaly, sessionId, targetUrl));
}
await Promise.allSettled(sends);
}
async sendWithRetry(channel, anomaly, sessionId, targetUrl) {
const record = {
id: crypto.randomUUID(),
anomalyId: anomaly.id,
channel,
status: 'pending',
};
try {
await this.doSend(channel, anomaly, sessionId, targetUrl);
record.status = 'success';
record.sentAt = Date.now();
this.persister?.(record);
}
catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// Retry once after 60s
setTimeout(async () => {
const retryRecord = {
id: crypto.randomUUID(),
anomalyId: anomaly.id,
channel,
status: 'pending',
};
try {
await this.doSend(channel, anomaly, sessionId, targetUrl);
retryRecord.status = 'success';
retryRecord.sentAt = Date.now();
this.persister?.(retryRecord);
}
catch (retryErr) {
retryRecord.status = 'failed';
retryRecord.error = retryErr instanceof Error ? retryErr.message : String(retryErr);
this.persister?.(retryRecord);
}
}, 60000);
record.status = 'failed';
record.error = errMsg;
this.persister?.(record);
}
}
async doSend(channel, anomaly, sessionId, targetUrl) {
if (channel === 'slack' && this.slack) {
await this.slack.send(anomaly, sessionId, targetUrl);
}
else if (channel === 'webhook' && this.webhook) {
await this.webhook.send(anomaly);
}
}
}
exports.NotificationService = NotificationService;

View File

@@ -0,0 +1,57 @@
"use strict";
/**
* SlackNotifier — sends anomaly notifications to a Slack webhook.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SlackNotifier = void 0;
const SEVERITY_EMOJI = {
low: ':blue_circle:',
medium: ':yellow_circle:',
high: ':red_circle:',
critical: ':rotating_light:',
};
class SlackNotifier {
constructor(webhookUrl, frontendBaseUrl = 'http://localhost:5173') {
this.webhookUrl = webhookUrl;
this.frontendBaseUrl = frontendBaseUrl;
}
async send(anomaly, sessionId, targetUrl) {
const emoji = SEVERITY_EMOJI[anomaly.severity] ?? ':warning:';
const payload = {
text: '🐛 ABE found a bug!',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*ABE Bug Report*\n` +
`*Severity:* ${emoji} ${anomaly.severity.toUpperCase()}\n` +
`*Type:* ${anomaly.type}\n` +
`*Description:* ${anomaly.description}\n` +
`*Session:* ${sessionId}\n` +
`*Target:* ${targetUrl}`,
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'View Report' },
url: `${this.frontendBaseUrl}/anomalies/${anomaly.id}`,
},
],
},
],
};
const res = await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`Slack webhook returned ${res.status}: ${await res.text()}`);
}
}
}
exports.SlackNotifier = SlackNotifier;

View File

@@ -0,0 +1,25 @@
"use strict";
/**
* WebhookNotifier — posts full anomaly JSON to a generic webhook URL.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookNotifier = void 0;
class WebhookNotifier {
constructor(webhookUrl) {
this.webhookUrl = webhookUrl;
}
async send(anomaly) {
const res = await fetch(this.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ABE-Event': 'anomaly.detected',
},
body: JSON.stringify(anomaly),
});
if (!res.ok) {
throw new Error(`Webhook returned ${res.status}: ${await res.text()}`);
}
}
}
exports.WebhookNotifier = WebhookNotifier;

93
dist/server/routes/anomalies.js vendored Normal file
View File

@@ -0,0 +1,93 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAnomalyRouter = createAnomalyRouter;
const express_1 = require("express");
const fs_1 = __importDefault(require("fs"));
function createAnomalyRouter(store, enrichmentService) {
const router = (0, express_1.Router)();
// GET /api/anomalies — list all anomalies (optionally filtered)
router.get('/', (req, res) => {
const sessionId = req.query['sessionId'];
const severity = req.query['severity'];
const anomalies = store.getAllAnomalies(sessionId, severity);
const mapped = anomalies.map((a) => ({
id: a.id,
sessionId: store.findSessionForAnomaly(a.id),
type: a.type,
severity: a.severity,
description: a.description,
timestamp: a.timestamp,
screenshotUrl: a.evidence.screenshotPath
? `/api/anomalies/${a.id}/screenshot`
: undefined,
}));
res.json(mapped);
});
// GET /api/anomalies/:anomalyId — full anomaly detail
router.get('/:anomalyId', (req, res) => {
const anomalyId = req.params['anomalyId'];
const anomaly = store.getAnomaly(anomalyId);
if (!anomaly) {
res.status(404).json({ error: 'Anomaly not found' });
return;
}
res.json({
...anomaly,
sessionId: store.findSessionForAnomaly(anomaly.id),
screenshotUrl: anomaly.evidence.screenshotPath
? `/api/anomalies/${anomaly.id}/screenshot`
: undefined,
});
});
// GET /api/anomalies/:anomalyId/screenshot — serve PNG
router.get('/:anomalyId/screenshot', (req, res) => {
const anomalyId = req.params['anomalyId'];
const filePath = store.screenshotPath(anomalyId);
if (!filePath || !fs_1.default.existsSync(filePath)) {
res.status(404).json({ error: 'Screenshot not found' });
return;
}
res.setHeader('Content-Type', 'image/png');
fs_1.default.createReadStream(filePath).pipe(res);
});
// POST /api/anomalies/:anomalyId/replay — trigger replay
router.post('/:anomalyId/replay', async (req, res) => {
const anomalyId = req.params['anomalyId'];
try {
const replayId = await store.replayAnomaly(anomalyId);
res.json({ replayId, status: 'running' });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
res.status(404).json({ error: msg });
}
});
// POST /api/anomalies/:anomalyId/enrich — AI enrichment
router.post('/:anomalyId/enrich', async (req, res) => {
const anomalyId = req.params['anomalyId'];
const anomaly = store.getAnomaly(anomalyId);
if (!anomaly) {
res.status(404).json({ error: 'Anomaly not found' });
return;
}
if (!enrichmentService?.hasProvider()) {
res.status(503).json({ error: 'No AI provider configured (set ABE_AI_PROVIDER)' });
return;
}
const context = {
domSnapshot: '',
httpLog: anomaly.evidence.httpLog ?? [],
consoleErrors: anomaly.evidence.rawErrors ?? [],
actionTrace: anomaly.actionTrace,
pageTitle: '',
url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '',
};
// Run async — emit WS event when done
void enrichmentService.enrich(anomaly, context);
res.json({ status: 'enriching', anomalyId });
});
return router;
}

48
dist/server/routes/config.js vendored Normal file
View File

@@ -0,0 +1,48 @@
"use strict";
/**
* Config routes — GET /api/config and PATCH /api/config
* Manages server-side configuration for notifications and defaults.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getServerConfig = getServerConfig;
exports.createConfigRouter = createConfigRouter;
const express_1 = require("express");
const defaultConfig = {
slackWebhookUrl: process.env['ABE_SLACK_WEBHOOK_URL'] ?? null,
notifyMinSeverity: process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high',
defaultMaxStates: 50,
defaultMaxDepth: 5,
defaultActionDelayMs: 500,
defaultExcludedPaths: [],
};
let serverConfig = { ...defaultConfig };
function getServerConfig() {
return { ...serverConfig };
}
function createConfigRouter() {
const router = (0, express_1.Router)();
// GET /api/config — returns current config (without API key)
router.get('/', (_req, res) => {
res.json(serverConfig);
});
// PATCH /api/config — updates config fields
router.patch('/', (req, res) => {
const body = req.body;
const validKeys = [
'slackWebhookUrl',
'notifyMinSeverity',
'defaultMaxStates',
'defaultMaxDepth',
'defaultActionDelayMs',
'defaultExcludedPaths',
];
for (const key of validKeys) {
if (key in body) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serverConfig[key] = body[key];
}
}
res.json(serverConfig);
});
return router;
}

122
dist/server/routes/schedules.js vendored Normal file
View File

@@ -0,0 +1,122 @@
"use strict";
/**
* Schedules routes — CRUD for /api/schedules
*/
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.createScheduleRouter = createScheduleRouter;
const express_1 = require("express");
const crypto = __importStar(require("crypto"));
const cron = __importStar(require("node-cron"));
const SchedulerService_1 = require("../scheduler/SchedulerService");
function createScheduleRouter(scheduleRepo, scheduler) {
const router = (0, express_1.Router)();
// GET /api/schedules
router.get('/', (_req, res) => {
const schedules = scheduleRepo.findAll();
res.json(schedules);
});
// POST /api/schedules
router.post('/', (req, res) => {
const { name, url, config, cronExpression, enabled } = req.body;
if (!name || !url || !cronExpression) {
res.status(400).json({ error: 'name, url, and cronExpression are required' });
return;
}
if (!cron.validate(cronExpression)) {
res.status(400).json({ error: 'Invalid cron expression' });
return;
}
const id = crypto.randomUUID();
const nextRunAt = SchedulerService_1.SchedulerService.computeNextRunAt(cronExpression);
scheduleRepo.create({
id,
name,
url,
configJson: JSON.stringify(config ?? {}),
cronExpression,
enabled: enabled !== false,
nextRunAt: nextRunAt ?? undefined,
});
const record = scheduleRepo.findById(id);
if (record.enabled) {
scheduler.register(record);
}
res.status(201).json(record);
});
// PATCH /api/schedules/:id
router.patch('/:id', (req, res) => {
const id = String(req.params['id']);
const existing = scheduleRepo.findById(id);
if (!existing) {
res.status(404).json({ error: 'Schedule not found' });
return;
}
const { name, url, config, cronExpression, enabled } = req.body;
if (cronExpression !== undefined && !cron.validate(cronExpression)) {
res.status(400).json({ error: 'Invalid cron expression' });
return;
}
scheduleRepo.update(id, {
name,
url,
configJson: config !== undefined ? JSON.stringify(config) : undefined,
cronExpression,
enabled,
});
const updated = scheduleRepo.findById(id);
// Re-register/unregister cron job
if (updated.enabled) {
scheduler.register(updated);
}
else {
scheduler.unregister(id);
}
res.json(updated);
});
// DELETE /api/schedules/:id
router.delete('/:id', (req, res) => {
const id = String(req.params['id']);
const existing = scheduleRepo.findById(id);
if (!existing) {
res.status(404).json({ error: 'Schedule not found' });
return;
}
scheduler.unregister(String(id));
scheduleRepo.delete(String(id));
res.status(204).send();
});
return router;
}

104
dist/server/routes/sessions.js vendored Normal file
View File

@@ -0,0 +1,104 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSessionRouter = createSessionRouter;
const express_1 = require("express");
const ExplorationConfig_1 = require("../../core/ExplorationConfig");
function createSessionRouter(store) {
const router = (0, express_1.Router)();
// POST /api/sessions — start a new exploration
router.post('/', async (req, res) => {
const body = req.body;
const { url, seed = 42 } = body;
if (!url || typeof url !== 'string') {
res.status(400).json({ error: 'url is required' });
return;
}
// Enforce concurrent session limit
const stats = store.getStats();
const limit = store.getMaxConcurrent();
if (stats.runningSessions >= limit) {
res.status(429).json({
error: 'Max concurrent sessions reached',
active: stats.runningSessions,
limit,
});
return;
}
const config = {
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
...(body.config ?? {}),
};
// If allowedDomains not specified, derive from the target URL
if (config.allowedDomains.length === 0) {
try {
const hostname = new URL(url).hostname;
config.allowedDomains = [hostname];
}
catch {
// leave empty
}
}
const record = await store.startSession({
url,
seed,
maxStates: config.maxStates,
explorationConfig: config,
});
res.status(201).json({
sessionId: record.sessionId,
status: record.status,
startedAt: record.startedAt,
});
});
// GET /api/sessions — list all sessions
router.get('/', (_req, res) => {
const sessions = store.getAllSessions().map((s) => ({
sessionId: s.sessionId,
url: s.url,
status: s.status,
startedAt: s.startedAt,
anomaliesFound: s.anomaliesFound,
statesVisited: s.statesVisited,
}));
res.json(sessions);
});
// GET /api/sessions/:sessionId — session detail
router.get('/:sessionId', (req, res) => {
const record = store.getSession(req.params['sessionId']);
if (!record) {
res.status(404).json({ error: 'Session not found' });
return;
}
res.json({
sessionId: record.sessionId,
url: record.url,
status: record.status,
startedAt: record.startedAt,
finishedAt: record.finishedAt,
statesVisited: record.statesVisited,
anomaliesFound: record.anomaliesFound,
seed: record.seed,
});
});
// DELETE /api/sessions/:sessionId — stop an active session
router.delete('/:sessionId', (req, res) => {
const stopped = store.stopSession(req.params['sessionId']);
if (!stopped) {
res.status(404).json({ error: 'Session not found or not running' });
return;
}
res.json({ stopped: true });
});
// GET /api/sessions/:sessionId/performance — performance metrics for session
router.get('/:sessionId/performance', (req, res) => {
const sessionId = req.params['sessionId'];
const record = store.getSession(sessionId);
if (!record) {
res.status(404).json({ error: 'Session not found' });
return;
}
const metrics = store.getPerformanceMetrics(sessionId);
res.json(metrics);
});
return router;
}

52
dist/server/routes/visual.js vendored Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
/**
* Visual regression routes — /api/visual
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createVisualRouter = createVisualRouter;
const express_1 = require("express");
function createVisualRouter(repo) {
const router = (0, express_1.Router)();
// GET /api/visual/comparisons
router.get('/comparisons', (req, res) => {
const sessionId = req.query['sessionId'];
const status = req.query['status'];
const comparisons = repo.findComparisons({ sessionId, status });
res.json(comparisons);
});
// POST /api/visual/baselines/:comparisonId/approve
router.post('/baselines/:comparisonId/approve', (req, res) => {
const comparisonId = String(req.params['comparisonId']);
const comparison = repo.findComparisonById(comparisonId);
if (!comparison) {
res.status(404).json({ error: 'Comparison not found' });
return;
}
const baselineId = repo.promoteToBaseline(comparisonId);
res.json({ baselineId, status: 'approved' });
});
// POST /api/visual/baselines/:comparisonId/reject
router.post('/baselines/:comparisonId/reject', (req, res) => {
const comparisonId = String(req.params['comparisonId']);
const comparison = repo.findComparisonById(comparisonId);
if (!comparison) {
res.status(404).json({ error: 'Comparison not found' });
return;
}
repo.updateComparisonStatus(comparisonId, 'failed');
res.json({ status: 'rejected' });
});
// POST /api/visual/baselines/approve-all
router.post('/baselines/approve-all', (req, res) => {
const { sessionId } = req.body;
const pending = repo.findComparisons({ sessionId, status: 'new_state' });
const approved = [];
for (const comp of pending) {
const id = repo.promoteToBaseline(comp.id);
if (id)
approved.push(id);
}
res.json({ approved: approved.length });
});
return router;
}

View File

@@ -0,0 +1,140 @@
"use strict";
/**
* SchedulerService — manages cron-based scheduled explorations.
* Loads schedules from DB on startup, registers cron jobs, and triggers sessions.
*/
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.SchedulerService = void 0;
const cron = __importStar(require("node-cron"));
const logger_1 = require("../logger");
class SchedulerService {
constructor(scheduleRepo, sessionStore) {
this.scheduleRepo = scheduleRepo;
this.sessionStore = sessionStore;
this.jobs = new Map();
}
/** Load all enabled schedules and start cron jobs. */
start() {
const schedules = this.scheduleRepo.findAll(true);
for (const schedule of schedules) {
this.register(schedule);
}
logger_1.log.info({ count: schedules.length }, 'SchedulerService started');
}
/** Stop all cron jobs. */
stop() {
for (const [id, task] of this.jobs) {
task.stop();
logger_1.log.info({ scheduleId: id }, 'Cron job stopped');
}
this.jobs.clear();
}
/** Register (or re-register) a cron job for a schedule. */
register(schedule) {
this.unregister(schedule.id);
if (!schedule.enabled)
return;
if (!cron.validate(schedule.cronExpression)) {
logger_1.log.warn({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Invalid cron expression, skipping');
return;
}
const task = cron.schedule(schedule.cronExpression, () => {
void this.fire(schedule.id);
});
this.jobs.set(schedule.id, task);
logger_1.log.info({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Cron job registered');
}
/** Unregister a cron job. */
unregister(scheduleId) {
const existing = this.jobs.get(scheduleId);
if (existing) {
existing.stop();
this.jobs.delete(scheduleId);
}
}
/** Fire a scheduled run. */
async fire(scheduleId) {
const schedule = this.scheduleRepo.findById(scheduleId);
if (!schedule || !schedule.enabled)
return;
// Check if a session from this schedule is still running
const running = this.sessionStore.getAllSessions().filter((s) => s.status === 'running');
if (running.length > 0) {
// Check if any running session was created from this schedule
const scheduleConfig = JSON.parse(schedule.configJson);
const alreadyRunning = running.some((s) => {
try {
const cfg = JSON.parse(s.config_json ?? '{}');
return cfg.scheduleId === scheduleId;
}
catch {
return false;
}
});
if (alreadyRunning) {
logger_1.log.warn({ scheduleId }, 'Previous session still running, skipping scheduled tick');
return;
}
void scheduleConfig; // suppress unused warning
}
logger_1.log.info({ scheduleId, url: schedule.url }, 'Firing scheduled exploration');
const now = Date.now();
this.scheduleRepo.update(scheduleId, { lastRunAt: now });
try {
const config = JSON.parse(schedule.configJson);
// Inject scheduleId into config for tracking
config.scheduleId = scheduleId;
await this.sessionStore.startSession({
url: schedule.url,
seed: Math.floor(Math.random() * 0x7fffffff),
maxStates: config.maxStates ?? 50,
explorationConfig: config,
});
}
catch (err) {
logger_1.log.error({ scheduleId, err: err instanceof Error ? err.message : String(err) }, 'Scheduled session failed to start');
}
}
/** Compute approximate next run time for a cron expression. */
static computeNextRunAt(cronExpression) {
if (!cron.validate(cronExpression))
return null;
// Simple heuristic: use current time + 60s as a placeholder
// A proper implementation would parse the cron and compute the next trigger
return Date.now() + 60000;
}
}
exports.SchedulerService = SchedulerService;