docs: enterprise refactor plan with ralph specs
This commit is contained in:
252
dist/cli.js
vendored
Normal file
252
dist/cli.js
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
program.parse(process.argv);
|
||||
137
dist/core/AnomalyDetector.js
vendored
Normal file
137
dist/core/AnomalyDetector.js
vendored
Normal 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
53
dist/core/ExplorationConfig.js
vendored
Normal 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
197
dist/core/ExplorationEngine.js
vendored
Normal 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
66
dist/core/Logger.js
vendored
Normal 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
83
dist/core/StateGraph.js
vendored
Normal 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
6
dist/core/interfaces.js
vendored
Normal 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
76
dist/db/AnomalyRepository.js
vendored
Normal 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
82
dist/db/ScheduleRepository.js
vendored
Normal 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
53
dist/db/SessionRepository.js
vendored
Normal 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
77
dist/db/VisualBaselineRepository.js
vendored
Normal 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
43
dist/db/connection.js
vendored
Normal 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
126
dist/db/migrations.js
vendored
Normal 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
86
dist/index.js
vendored
Normal 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
501
dist/plugins/agents/PlaywrightAgent.js
vendored
Normal 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;
|
||||
124
dist/plugins/collectors/AccessibilityCollector.js
vendored
Normal file
124
dist/plugins/collectors/AccessibilityCollector.js
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
"use strict";
|
||||
/**
|
||||
* AccessibilityCollector — runs axe-core after state changes to detect WCAG violations.
|
||||
* Converts axe violations to IAnomaly with severity mapped from impact level.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AccessibilityCollector = exports.DEFAULT_A11Y_CONFIG = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
exports.DEFAULT_A11Y_CONFIG = {
|
||||
enabled: true,
|
||||
minImpact: 'serious',
|
||||
wcagLevel: 'AA',
|
||||
};
|
||||
const IMPACT_TO_SEVERITY = {
|
||||
minor: 'low',
|
||||
moderate: 'medium',
|
||||
serious: 'high',
|
||||
critical: 'critical',
|
||||
};
|
||||
const IMPACT_RANK = {
|
||||
minor: 0,
|
||||
moderate: 1,
|
||||
serious: 2,
|
||||
critical: 3,
|
||||
};
|
||||
class AccessibilityCollector {
|
||||
constructor(config = {}) {
|
||||
this.config = { ...exports.DEFAULT_A11Y_CONFIG, ...config };
|
||||
}
|
||||
async collect(page, stateId, sessionId, actionTrace) {
|
||||
if (!this.config.enabled)
|
||||
return [];
|
||||
try {
|
||||
const violations = await this.runAxe(page);
|
||||
const minRank = IMPACT_RANK[this.config.minImpact] ?? 2;
|
||||
const anomalies = [];
|
||||
for (const violation of violations) {
|
||||
const impact = violation.impact ?? 'minor';
|
||||
if ((IMPACT_RANK[impact] ?? 0) < minRank)
|
||||
continue;
|
||||
const severity = IMPACT_TO_SEVERITY[impact] ?? 'medium';
|
||||
anomalies.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'accessibility_violation',
|
||||
severity,
|
||||
observationId: stateId,
|
||||
actionTrace,
|
||||
description: `[axe] ${violation.description}`,
|
||||
evidence: {
|
||||
rawErrors: [
|
||||
`Rule: ${violation.id}`,
|
||||
`Impact: ${impact}`,
|
||||
`Affected nodes: ${violation.nodes.length}`,
|
||||
`Help: ${violation.helpUrl}`,
|
||||
],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
return anomalies;
|
||||
}
|
||||
catch {
|
||||
// axe might fail if page is not in a valid state
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async runAxe(page) {
|
||||
try {
|
||||
const { AxeBuilder } = await Promise.resolve().then(() => __importStar(require('@axe-core/playwright')));
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze();
|
||||
return results.violations;
|
||||
}
|
||||
catch {
|
||||
// Fallback: try via page.evaluate if AxeBuilder fails
|
||||
const results = await page.evaluate(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const win = window;
|
||||
if (typeof win.axe === 'undefined')
|
||||
return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
const r = await win.axe.run();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return r.violations;
|
||||
}).catch(() => []);
|
||||
return Array.isArray(results) ? results : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AccessibilityCollector = AccessibilityCollector;
|
||||
56
dist/plugins/collectors/DOMSnapshotCollector.js
vendored
Normal file
56
dist/plugins/collectors/DOMSnapshotCollector.js
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
/**
|
||||
* DOMSnapshotCollector — writes the DOM snapshot at anomaly moment to disk.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DOMSnapshotCollector = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
class DOMSnapshotCollector {
|
||||
constructor(outputDir = './reports') {
|
||||
this.outputDir = outputDir;
|
||||
this.name = 'DOMSnapshotCollector';
|
||||
}
|
||||
async collect(anomaly, agent) {
|
||||
const state = await agent.captureState();
|
||||
const dir = path.join(this.outputDir, anomaly.id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const domPath = path.join(dir, 'dom.html');
|
||||
fs.writeFileSync(domPath, state.domSnapshot, 'utf8');
|
||||
return { domSnapshotPath: path.relative(this.outputDir, domPath) };
|
||||
}
|
||||
}
|
||||
exports.DOMSnapshotCollector = DOMSnapshotCollector;
|
||||
18
dist/plugins/collectors/NetworkCollector.js
vendored
Normal file
18
dist/plugins/collectors/NetworkCollector.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
/**
|
||||
* NetworkCollector — logs all HTTP responses from the current observation.
|
||||
* The data is already captured in the observation; this collector formats it.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NetworkCollector = void 0;
|
||||
class NetworkCollector {
|
||||
constructor() {
|
||||
this.name = 'NetworkCollector';
|
||||
}
|
||||
async collect(anomaly, _agent) {
|
||||
// HTTP responses are captured in the observation → anomaly evidence
|
||||
const httpLog = anomaly.evidence.httpLog ?? [];
|
||||
return { httpLog };
|
||||
}
|
||||
}
|
||||
exports.NetworkCollector = NetworkCollector;
|
||||
177
dist/plugins/collectors/PerformanceCollector.js
vendored
Normal file
177
dist/plugins/collectors/PerformanceCollector.js
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
/**
|
||||
* PerformanceCollector — captures Navigation Timing and Core Web Vitals after each navigation.
|
||||
* Detects performance_degradation anomalies based on configurable thresholds.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PerformanceCollector = exports.DEFAULT_PERF_CONFIG = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
exports.DEFAULT_PERF_CONFIG = {
|
||||
enabled: true,
|
||||
lcpThresholdMs: 4000,
|
||||
clsThreshold: 0.25,
|
||||
inpThresholdMs: 500,
|
||||
ttfbThresholdMs: 1800,
|
||||
};
|
||||
class PerformanceCollector {
|
||||
constructor(config = {}) {
|
||||
this.metricsStore = [];
|
||||
this.config = { ...exports.DEFAULT_PERF_CONFIG, ...config };
|
||||
}
|
||||
async collect(page, stateId, sessionId, actionTrace) {
|
||||
if (!this.config.enabled) {
|
||||
const empty = {
|
||||
id: crypto.randomUUID(), sessionId, stateId, url: page.url(),
|
||||
ttfb: 0, domContentLoaded: 0, loadComplete: 0,
|
||||
lcp: null, cls: null, fid: null, inp: null,
|
||||
totalRequests: 0, failedRequests: 0, capturedAt: Date.now(),
|
||||
};
|
||||
return { metrics: empty, anomalies: [] };
|
||||
}
|
||||
// Capture Navigation Timing
|
||||
const timing = await page.evaluate(() => {
|
||||
const t = performance.timing;
|
||||
return {
|
||||
ttfb: t.responseStart - t.requestStart,
|
||||
domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
|
||||
loadComplete: t.loadEventEnd - t.navigationStart,
|
||||
};
|
||||
}).catch(() => ({ ttfb: 0, domContentLoaded: 0, loadComplete: 0 }));
|
||||
// Capture Core Web Vitals via PerformanceObserver
|
||||
const vitals = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const result = {
|
||||
lcp: null, cls: null, inp: null,
|
||||
};
|
||||
try {
|
||||
// Try to observe LCP
|
||||
if ('PerformanceObserver' in window) {
|
||||
try {
|
||||
const lcpObs = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
result.lcp = entries[entries.length - 1].startTime;
|
||||
}
|
||||
});
|
||||
lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
}
|
||||
catch { /* not supported */ }
|
||||
try {
|
||||
const clsObs = new PerformanceObserver((list) => {
|
||||
let clsScore = 0;
|
||||
for (const entry of list.getEntries()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
clsScore += entry.value ?? 0;
|
||||
}
|
||||
result.cls = clsScore;
|
||||
});
|
||||
clsObs.observe({ type: 'layout-shift', buffered: true });
|
||||
}
|
||||
catch { /* not supported */ }
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
// Resolve after short wait
|
||||
setTimeout(() => resolve(result), 500);
|
||||
});
|
||||
}).catch(() => ({ lcp: null, cls: null, inp: null }));
|
||||
const metrics = {
|
||||
id: crypto.randomUUID(),
|
||||
sessionId,
|
||||
stateId,
|
||||
url: page.url(),
|
||||
ttfb: timing.ttfb,
|
||||
domContentLoaded: timing.domContentLoaded,
|
||||
loadComplete: timing.loadComplete,
|
||||
lcp: vitals.lcp,
|
||||
cls: vitals.cls,
|
||||
fid: null,
|
||||
inp: vitals.inp,
|
||||
totalRequests: 0,
|
||||
failedRequests: 0,
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
this.metricsStore.push(metrics);
|
||||
const anomalies = this.detectAnomalies(metrics, stateId, actionTrace);
|
||||
return { metrics, anomalies };
|
||||
}
|
||||
getMetrics() {
|
||||
return this.metricsStore;
|
||||
}
|
||||
detectAnomalies(metrics, stateId, actionTrace) {
|
||||
const anomalies = [];
|
||||
const issues = [];
|
||||
let severityRank = 0; // 0=low,1=medium,2=high,3=critical
|
||||
if (metrics.lcp !== null && metrics.lcp > this.config.lcpThresholdMs) {
|
||||
issues.push(`LCP: ${metrics.lcp}ms (threshold: ${this.config.lcpThresholdMs}ms)`);
|
||||
if (severityRank < 2)
|
||||
severityRank = 2; // high
|
||||
}
|
||||
if (metrics.cls !== null && metrics.cls > this.config.clsThreshold) {
|
||||
issues.push(`CLS: ${metrics.cls.toFixed(3)} (threshold: ${this.config.clsThreshold})`);
|
||||
if (severityRank < 1)
|
||||
severityRank = 1; // medium
|
||||
}
|
||||
if (metrics.inp !== null && metrics.inp > this.config.inpThresholdMs) {
|
||||
issues.push(`INP: ${metrics.inp}ms (threshold: ${this.config.inpThresholdMs}ms)`);
|
||||
if (severityRank < 2)
|
||||
severityRank = 2; // high
|
||||
}
|
||||
if (metrics.ttfb > this.config.ttfbThresholdMs) {
|
||||
issues.push(`TTFB: ${metrics.ttfb}ms (threshold: ${this.config.ttfbThresholdMs}ms)`);
|
||||
if (severityRank < 1)
|
||||
severityRank = 1; // medium
|
||||
}
|
||||
const RANK_TO_SEVERITY = ['low', 'medium', 'high', 'critical'];
|
||||
const maxSeverity = RANK_TO_SEVERITY[severityRank] ?? 'low';
|
||||
if (issues.length === 0)
|
||||
return anomalies;
|
||||
anomalies.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'performance_degradation',
|
||||
severity: maxSeverity,
|
||||
observationId: stateId,
|
||||
actionTrace,
|
||||
description: `Performance degradation at ${metrics.url}: ${issues[0]}`,
|
||||
evidence: {
|
||||
rawErrors: issues,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return anomalies;
|
||||
}
|
||||
}
|
||||
exports.PerformanceCollector = PerformanceCollector;
|
||||
63
dist/plugins/collectors/ScreenshotCollector.js
vendored
Normal file
63
dist/plugins/collectors/ScreenshotCollector.js
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
/**
|
||||
* ScreenshotCollector — captures a PNG screenshot at anomaly moment.
|
||||
* Requires the agent to be a PlaywrightAgent (duck-typing check).
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ScreenshotCollector = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
function isPlaywrightAgent(agent) {
|
||||
return typeof agent.getPage === 'function';
|
||||
}
|
||||
class ScreenshotCollector {
|
||||
constructor(outputDir = './reports') {
|
||||
this.outputDir = outputDir;
|
||||
this.name = 'ScreenshotCollector';
|
||||
}
|
||||
async collect(anomaly, agent) {
|
||||
if (!isPlaywrightAgent(agent)) {
|
||||
return {};
|
||||
}
|
||||
const page = agent.getPage();
|
||||
const dir = path.join(this.outputDir, anomaly.id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const screenshotPath = path.join(dir, 'screenshot.png');
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
return { screenshotPath: path.relative(this.outputDir, screenshotPath) };
|
||||
}
|
||||
}
|
||||
exports.ScreenshotCollector = ScreenshotCollector;
|
||||
155
dist/plugins/collectors/VisualRegressionCollector.js
vendored
Normal file
155
dist/plugins/collectors/VisualRegressionCollector.js
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
"use strict";
|
||||
/**
|
||||
* VisualRegressionCollector — captures screenshots and compares against baselines.
|
||||
* Uses pixelmatch for pixel-level comparison and sharp for image normalization.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.VisualRegressionCollector = exports.DEFAULT_VISUAL_CONFIG = void 0;
|
||||
exports.compareScreenshots = compareScreenshots;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const path = __importStar(require("path"));
|
||||
const fs = __importStar(require("fs"));
|
||||
exports.DEFAULT_VISUAL_CONFIG = {
|
||||
enabled: true,
|
||||
threshold: 0.001,
|
||||
screenshotFullPage: false,
|
||||
ignoreSelectors: [],
|
||||
};
|
||||
async function compareScreenshots(baselinePath, currentPath, diffOutputPath, threshold = 0.1) {
|
||||
// Dynamic imports to avoid loading heavy deps at startup
|
||||
const sharp = (await Promise.resolve().then(() => __importStar(require('sharp')))).default;
|
||||
const pixelmatch = (await Promise.resolve().then(() => __importStar(require('pixelmatch')))).default;
|
||||
const [baselineRaw, currentRaw] = await Promise.all([
|
||||
sharp(baselinePath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
|
||||
sharp(currentPath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
|
||||
]);
|
||||
const { width, height } = baselineRaw.info;
|
||||
const diffBuffer = Buffer.alloc(width * height * 4);
|
||||
const diffPixels = pixelmatch(baselineRaw.data, currentRaw.data, diffBuffer, width, height, { threshold });
|
||||
const totalPixels = width * height;
|
||||
const diffPercent = totalPixels > 0 ? diffPixels / totalPixels : 0;
|
||||
// Write diff image
|
||||
await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
|
||||
.png()
|
||||
.toFile(diffOutputPath);
|
||||
return { diffPixels, diffPercent, hasDiff: diffPixels > 0 };
|
||||
}
|
||||
class VisualRegressionCollector {
|
||||
constructor(outputDir, repo, config = {}) {
|
||||
this.outputDir = outputDir;
|
||||
this.repo = repo;
|
||||
this.config = { ...exports.DEFAULT_VISUAL_CONFIG, ...config };
|
||||
}
|
||||
/**
|
||||
* Process a screenshot for visual regression.
|
||||
* Returns an anomaly if a regression is detected, otherwise null.
|
||||
*/
|
||||
async processScreenshot(screenshotPath, state, sessionId, actionTrace) {
|
||||
if (!this.config.enabled)
|
||||
return null;
|
||||
const comparisonId = crypto.randomUUID();
|
||||
const baseline = this.repo.findBaselineByStateId(state.id);
|
||||
if (!baseline) {
|
||||
// No baseline: create a new_state comparison record
|
||||
this.repo.createComparison({
|
||||
id: comparisonId,
|
||||
sessionId,
|
||||
stateId: state.id,
|
||||
currentScreenshotPath: screenshotPath,
|
||||
status: 'new_state',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Compare against baseline
|
||||
const diffDir = path.join(this.outputDir, 'visual', comparisonId);
|
||||
if (!fs.existsSync(diffDir)) {
|
||||
fs.mkdirSync(diffDir, { recursive: true });
|
||||
}
|
||||
const diffPath = path.join(diffDir, 'diff.png');
|
||||
let diffPixels = 0;
|
||||
let diffPercent = 0;
|
||||
try {
|
||||
const result = await compareScreenshots(baseline.screenshot_path, screenshotPath, diffPath, this.config.threshold);
|
||||
diffPixels = result.diffPixels;
|
||||
diffPercent = result.diffPercent;
|
||||
}
|
||||
catch {
|
||||
// If comparison fails (e.g. image format issues), skip
|
||||
return null;
|
||||
}
|
||||
const thresholdPct = this.config.threshold;
|
||||
const status = diffPercent > thresholdPct ? 'failed' : 'passed';
|
||||
this.repo.createComparison({
|
||||
id: comparisonId,
|
||||
sessionId,
|
||||
stateId: state.id,
|
||||
baselineId: baseline.id,
|
||||
currentScreenshotPath: screenshotPath,
|
||||
diffScreenshotPath: status === 'failed' ? diffPath : undefined,
|
||||
diffPixels,
|
||||
diffPercent,
|
||||
status,
|
||||
});
|
||||
if (status !== 'failed')
|
||||
return null;
|
||||
// Determine severity from diff percent
|
||||
const pct = diffPercent * 100;
|
||||
let severity;
|
||||
if (pct > 15)
|
||||
severity = 'critical';
|
||||
else if (pct > 5)
|
||||
severity = 'high';
|
||||
else if (pct > 1)
|
||||
severity = 'medium';
|
||||
else
|
||||
severity = 'low';
|
||||
const anomaly = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'visual_regression',
|
||||
severity,
|
||||
observationId: state.id,
|
||||
actionTrace,
|
||||
description: `Visual regression detected: ${(pct).toFixed(2)}% of pixels changed`,
|
||||
evidence: {
|
||||
screenshotPath: diffPath,
|
||||
rawErrors: [`Diff: ${diffPixels} pixels (${(pct).toFixed(2)}%)`],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return anomaly;
|
||||
}
|
||||
}
|
||||
exports.VisualRegressionCollector = VisualRegressionCollector;
|
||||
97
dist/plugins/exporters/JSONExporter.js
vendored
Normal file
97
dist/plugins/exporters/JSONExporter.js
vendored
Normal 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;
|
||||
113
dist/plugins/exporters/MarkdownExporter.js
vendored
Normal file
113
dist/plugins/exporters/MarkdownExporter.js
vendored
Normal 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
139
dist/plugins/fuzzers/FuzzingEngine.js
vendored
Normal 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;
|
||||
52
dist/plugins/fuzzers/InputTypeDetector.js
vendored
Normal file
52
dist/plugins/fuzzers/InputTypeDetector.js
vendored
Normal 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';
|
||||
}
|
||||
26
dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js
vendored
Normal file
26
dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js
vendored
Normal 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;
|
||||
19
dist/plugins/fuzzers/strategies/EmptyValueStrategy.js
vendored
Normal file
19
dist/plugins/fuzzers/strategies/EmptyValueStrategy.js
vendored
Normal 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;
|
||||
28
dist/plugins/fuzzers/strategies/OversizedStringStrategy.js
vendored
Normal file
28
dist/plugins/fuzzers/strategies/OversizedStringStrategy.js
vendored
Normal 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;
|
||||
26
dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js
vendored
Normal file
26
dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js
vendored
Normal 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;
|
||||
31
dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js
vendored
Normal file
31
dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js
vendored
Normal 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
7
dist/plugins/interfaces.js
vendored
Normal 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 });
|
||||
59
dist/plugins/reproducers/PlaywrightReproducer.js
vendored
Normal file
59
dist/plugins/reproducers/PlaywrightReproducer.js
vendored
Normal 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
84
dist/replay.js
vendored
Normal 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
398
dist/server/SessionStore.js
vendored
Normal 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;
|
||||
71
dist/server/enrichment/AIEnrichmentService.js
vendored
Normal file
71
dist/server/enrichment/AIEnrichmentService.js
vendored
Normal 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,
|
||||
};
|
||||
88
dist/server/enrichment/ClaudeProvider.js
vendored
Normal file
88
dist/server/enrichment/ClaudeProvider.js
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
63
dist/server/enrichment/OllamaProvider.js
vendored
Normal file
63
dist/server/enrichment/OllamaProvider.js
vendored
Normal 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;
|
||||
81
dist/server/enrichment/OpenAIProvider.js
vendored
Normal file
81
dist/server/enrichment/OpenAIProvider.js
vendored
Normal 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
199
dist/server/index.js
vendored
Normal 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
13
dist/server/logger.js
vendored
Normal 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
21
dist/server/middleware/auth.js
vendored
Normal 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();
|
||||
}
|
||||
121
dist/server/notifications/NotificationService.js
vendored
Normal file
121
dist/server/notifications/NotificationService.js
vendored
Normal 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;
|
||||
57
dist/server/notifications/SlackNotifier.js
vendored
Normal file
57
dist/server/notifications/SlackNotifier.js
vendored
Normal 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;
|
||||
25
dist/server/notifications/WebhookNotifier.js
vendored
Normal file
25
dist/server/notifications/WebhookNotifier.js
vendored
Normal 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
93
dist/server/routes/anomalies.js
vendored
Normal 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
48
dist/server/routes/config.js
vendored
Normal 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
122
dist/server/routes/schedules.js
vendored
Normal 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
104
dist/server/routes/sessions.js
vendored
Normal 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
52
dist/server/routes/visual.js
vendored
Normal 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;
|
||||
}
|
||||
140
dist/server/scheduler/SchedulerService.js
vendored
Normal file
140
dist/server/scheduler/SchedulerService.js
vendored
Normal 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;
|
||||
Reference in New Issue
Block a user