docs: enterprise refactor plan with ralph specs
This commit is contained in:
243
src/cli.ts
Normal file
243
src/cli.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* ABE CLI — command-line interface for running explorations.
|
||||
* Usage: abe run --url http://localhost:3000
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { ExplorationEngine } from './core/ExplorationEngine';
|
||||
import { StateGraph } from './core/StateGraph';
|
||||
import { PlaywrightAgent } from './plugins/agents/PlaywrightAgent';
|
||||
import { ScreenshotCollector } from './plugins/collectors/ScreenshotCollector';
|
||||
import { NetworkCollector } from './plugins/collectors/NetworkCollector';
|
||||
import { DOMSnapshotCollector } from './plugins/collectors/DOMSnapshotCollector';
|
||||
import { MarkdownExporter } from './plugins/exporters/MarkdownExporter';
|
||||
import { JSONExporter } from './plugins/exporters/JSONExporter';
|
||||
import { PlaywrightReproducer } from './plugins/reproducers/PlaywrightReproducer';
|
||||
import { ExplorationConfig, DEFAULT_EXPLORATION_CONFIG, AuthConfig } from './core/ExplorationConfig';
|
||||
import { IAnomaly } from './core/interfaces';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const program = new 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: AuthConfig | null = 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 = {
|
||||
...DEFAULT_EXPLORATION_CONFIG,
|
||||
maxStates: opts.maxStates,
|
||||
maxDepth: opts.maxDepth,
|
||||
actionDelayMs: opts.actionDelay,
|
||||
sessionTimeoutMs: opts.sessionTimeout,
|
||||
allowedDomains: opts.allowedDomains
|
||||
? opts.allowedDomains.split(',').map((d: string) => d.trim())
|
||||
: [new URL(opts.url).hostname],
|
||||
excludedPaths: opts.excludedPaths
|
||||
? opts.excludedPaths.split(',').map((p: string) => p.trim())
|
||||
: [],
|
||||
auth,
|
||||
};
|
||||
|
||||
// If remote server mode
|
||||
if (opts.server) {
|
||||
await runRemote(opts, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const anomalies: IAnomaly[] = [];
|
||||
let exitCode = 0;
|
||||
let explorationError: string | undefined;
|
||||
|
||||
try {
|
||||
const graph = new StateGraph();
|
||||
const agent = new PlaywrightAgent({ seed: opts.seed, explorationConfig: config });
|
||||
|
||||
const engine = new ExplorationEngine({
|
||||
graph,
|
||||
agent,
|
||||
seed: opts.seed,
|
||||
url: opts.url,
|
||||
maxSteps: opts.maxStates,
|
||||
outputDir: opts.reportsDir,
|
||||
explorationConfig: config,
|
||||
collectors: [
|
||||
new ScreenshotCollector(opts.reportsDir),
|
||||
new NetworkCollector(),
|
||||
new DOMSnapshotCollector(opts.reportsDir),
|
||||
],
|
||||
exporters: [new MarkdownExporter(), new JSONExporter()],
|
||||
reproducer: new PlaywrightReproducer(),
|
||||
events: {
|
||||
onAnomalyDetected: (_, anomaly) => {
|
||||
anomalies.push(anomaly);
|
||||
},
|
||||
onSessionError: (_, error) => {
|
||||
explorationError = error;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await engine.run();
|
||||
} catch (err: unknown) {
|
||||
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: Record<string, number> = { 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: Record<string, unknown>,
|
||||
_config: ExplorationConfig
|
||||
): Promise<void> {
|
||||
const serverUrl = opts['server'] as string;
|
||||
const apiKey = opts['apiKey'] as string | undefined;
|
||||
const url = opts['url'] as string;
|
||||
|
||||
const headers: Record<string, string> = { '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() as { sessionId: string };
|
||||
console.log(`Session started: ${session.sessionId}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function buildJunit(
|
||||
summary: { url: string; anomalies: Array<{ id: string; type: string; severity: string; description: string }> },
|
||||
url: string
|
||||
): string {
|
||||
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: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
168
src/core/AnomalyDetector.ts
Normal file
168
src/core/AnomalyDetector.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* AnomalyDetector — heuristic rules to detect anomalies from observations.
|
||||
* Each rule is independent and testable in isolation.
|
||||
*/
|
||||
|
||||
import { IObservation, IAnomaly, IAction, AnomalyType, IAnomalyEvidence } from './interfaces';
|
||||
|
||||
let anomalyCounter = 0;
|
||||
|
||||
function makeId(): string {
|
||||
anomalyCounter += 1;
|
||||
return `anom_${Date.now()}_${anomalyCounter.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
detected: boolean;
|
||||
anomaly?: IAnomaly;
|
||||
}
|
||||
|
||||
export class AnomalyDetector {
|
||||
detect(observation: IObservation, actionTrace: IAction[]): IAnomaly[] {
|
||||
const anomalies: IAnomaly[] = [];
|
||||
|
||||
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: IObservation, actionTrace: IAction[]): IAnomaly | null {
|
||||
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: IObservation, actionTrace: IAction[]): IAnomaly | null {
|
||||
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: IObservation, actionTrace: IAction[]): IAnomaly | null {
|
||||
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: IObservation,
|
||||
actionTrace: IAction[],
|
||||
fuzzedValue: string
|
||||
): IAnomaly | null {
|
||||
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: IObservation, actionTrace: IAction[]): IAnomaly | null {
|
||||
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: IObservation,
|
||||
actionTrace: IAction[],
|
||||
domSnapshot: string
|
||||
): IAnomaly | null {
|
||||
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'] },
|
||||
});
|
||||
}
|
||||
|
||||
private buildAnomaly(params: {
|
||||
type: AnomalyType;
|
||||
severity: IAnomaly['severity'];
|
||||
observationId: string;
|
||||
actionTrace: IAction[];
|
||||
description: string;
|
||||
evidence: IAnomalyEvidence;
|
||||
}): IAnomaly {
|
||||
return {
|
||||
id: makeId(),
|
||||
type: params.type,
|
||||
severity: params.severity,
|
||||
observationId: params.observationId,
|
||||
actionTrace: params.actionTrace,
|
||||
description: params.description,
|
||||
evidence: params.evidence,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
153
src/core/ExplorationConfig.ts
Normal file
153
src/core/ExplorationConfig.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* ExplorationConfig — defines scope, auth, fuzzing, multi-browser, a11y,
|
||||
* performance, visual regression, and network chaos settings for a session.
|
||||
*/
|
||||
|
||||
export type AuthConfigCookies = {
|
||||
type: 'cookies';
|
||||
cookies: Array<{ name: string; value: string; domain: string }>;
|
||||
};
|
||||
|
||||
export type AuthConfigHeaders = {
|
||||
type: 'headers';
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AuthConfigLoginFlow = {
|
||||
type: 'login_flow';
|
||||
loginUrl: string;
|
||||
usernameSelector: string;
|
||||
passwordSelector: string;
|
||||
submitSelector: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type AuthConfig = AuthConfigCookies | AuthConfigHeaders | AuthConfigLoginFlow;
|
||||
|
||||
export type MobileDevice =
|
||||
| 'iPhone 14'
|
||||
| 'iPhone 14 Pro Max'
|
||||
| 'Pixel 7'
|
||||
| 'Galaxy S23'
|
||||
| 'iPad Pro'
|
||||
| 'none';
|
||||
|
||||
export type NetworkProfile = 'fast-3g' | 'slow-3g' | '2g' | 'offline' | 'none';
|
||||
|
||||
export interface NetworkCondition {
|
||||
downloadKbps: number;
|
||||
uploadKbps: number;
|
||||
latencyMs: number;
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
export const NETWORK_PROFILES: Record<NetworkProfile, NetworkCondition | null> = {
|
||||
'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,
|
||||
};
|
||||
|
||||
export interface ExplorationConfig {
|
||||
// Scope
|
||||
allowedDomains: string[];
|
||||
maxStates: number;
|
||||
maxDepth: number;
|
||||
actionDelayMs: number;
|
||||
sessionTimeoutMs: number;
|
||||
|
||||
// Exclusions
|
||||
excludedPaths: string[];
|
||||
excludedSelectors: string[];
|
||||
|
||||
// Target authentication
|
||||
auth: AuthConfig | null;
|
||||
|
||||
// Fuzzing
|
||||
fuzzingEnabled: boolean;
|
||||
fuzzingIntensity: 'low' | 'medium' | 'high';
|
||||
|
||||
// Multi-browser
|
||||
browsers: Array<'chromium' | 'firefox' | 'webkit'>;
|
||||
|
||||
// Mobile emulation
|
||||
mobileDevice: MobileDevice;
|
||||
viewport: { width: number; height: number } | null;
|
||||
|
||||
// Accessibility
|
||||
accessibility: {
|
||||
enabled: boolean;
|
||||
minImpact: 'minor' | 'moderate' | 'serious' | 'critical';
|
||||
wcagLevel: 'A' | 'AA' | 'AAA';
|
||||
};
|
||||
|
||||
// Performance
|
||||
performance: {
|
||||
enabled: boolean;
|
||||
lcpThresholdMs: number;
|
||||
clsThreshold: number;
|
||||
inpThresholdMs: number;
|
||||
ttfbThresholdMs: number;
|
||||
};
|
||||
|
||||
// Visual regression
|
||||
visualRegression: {
|
||||
enabled: boolean;
|
||||
threshold: number;
|
||||
screenshotFullPage: boolean;
|
||||
ignoreSelectors: string[];
|
||||
};
|
||||
|
||||
// Network chaos
|
||||
networkChaos: {
|
||||
enabled: boolean;
|
||||
profile: NetworkProfile;
|
||||
blockedEndpoints: string[];
|
||||
slowEndpoints: Array<{ pattern: string; delayMs: number }>;
|
||||
};
|
||||
|
||||
// Internal: used by SchedulerService to track which schedule triggered this session
|
||||
scheduleId?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPLORATION_CONFIG: ExplorationConfig = {
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
296
src/core/ExplorationEngine.ts
Normal file
296
src/core/ExplorationEngine.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* ExplorationEngine — the core loop of ABE.
|
||||
* Selects states, executes actions, records observations, and detects anomalies.
|
||||
* Depends only on core interfaces — never imports concrete plugins.
|
||||
*/
|
||||
|
||||
import { IState, IAction, IAnomaly, ILogger, IInteractionAgent, ICollector, IExporter, IReproducer, IFuzzingPlugin } from './interfaces';
|
||||
import { StateGraph } from './StateGraph';
|
||||
import { AnomalyDetector } from './AnomalyDetector';
|
||||
import { NullLogger } from './Logger';
|
||||
import { ExplorationConfig } from './ExplorationConfig';
|
||||
|
||||
export interface EngineEventCallbacks {
|
||||
onSessionStarted?: (sessionId: string, url: string) => void;
|
||||
onStateDiscovered?: (sessionId: string, stateId: string, url: string, title: string) => void;
|
||||
onActionExecuted?: (sessionId: string, actionType: string, selector: string | undefined, timestamp: number) => void;
|
||||
onAnomalyDetected?: (sessionId: string, anomaly: IAnomaly) => void;
|
||||
onSessionCompleted?: (sessionId: string, statesVisited: number, anomaliesFound: number) => void;
|
||||
onSessionError?: (sessionId: string, error: string) => void;
|
||||
}
|
||||
|
||||
/** Hook called after each state is discovered. Returns any new anomalies to add. */
|
||||
export type StateHook = (
|
||||
state: IState,
|
||||
agent: IInteractionAgent,
|
||||
sessionId: string,
|
||||
actionTrace: IAction[]
|
||||
) => Promise<IAnomaly[]>;
|
||||
|
||||
export interface EngineConfig {
|
||||
graph: StateGraph;
|
||||
agent: IInteractionAgent;
|
||||
detector?: AnomalyDetector;
|
||||
collectors?: ICollector[];
|
||||
exporters?: IExporter[];
|
||||
reproducer?: IReproducer;
|
||||
logger?: ILogger;
|
||||
/** Deterministic seed; logged at session start */
|
||||
seed: number;
|
||||
/** URL to begin exploration */
|
||||
url: string;
|
||||
/** Maximum number of exploration steps (default: 100) */
|
||||
maxSteps?: number;
|
||||
/** Output dir for reports (default: ./reports) */
|
||||
outputDir?: string;
|
||||
/** Optional event callbacks for real-time notification */
|
||||
events?: EngineEventCallbacks;
|
||||
/** Optional session ID (for server integration) */
|
||||
sessionId?: string;
|
||||
/** Exploration scope config */
|
||||
explorationConfig?: Partial<ExplorationConfig>;
|
||||
/** Optional fuzzing plugin */
|
||||
fuzzingPlugin?: IFuzzingPlugin;
|
||||
/** Hooks called after each state discovery — used for visual/a11y/perf collectors */
|
||||
stateHooks?: StateHook[];
|
||||
}
|
||||
|
||||
export interface EngineResult {
|
||||
statesVisited: number;
|
||||
anomaliesFound: number;
|
||||
anomalies: IAnomaly[];
|
||||
}
|
||||
|
||||
export class ExplorationEngine {
|
||||
private graph: StateGraph;
|
||||
private agent: IInteractionAgent;
|
||||
private detector: AnomalyDetector;
|
||||
private collectors: ICollector[];
|
||||
private exporters: IExporter[];
|
||||
private reproducer?: IReproducer;
|
||||
private logger: ILogger;
|
||||
private seed: number;
|
||||
private url: string;
|
||||
private maxSteps: number;
|
||||
private outputDir: string;
|
||||
private events: EngineEventCallbacks;
|
||||
private sessionId: string;
|
||||
private explorationConfig: Partial<ExplorationConfig>;
|
||||
private fuzzingPlugin?: IFuzzingPlugin;
|
||||
private stateHooks: StateHook[];
|
||||
/** Accumulated action trace for the current session */
|
||||
private actionTrace: IAction[] = [];
|
||||
/** Set to true to abort the running loop */
|
||||
private aborted = false;
|
||||
|
||||
constructor(config: EngineConfig) {
|
||||
this.graph = config.graph;
|
||||
this.agent = config.agent;
|
||||
this.detector = config.detector ?? new AnomalyDetector();
|
||||
this.collectors = config.collectors ?? [];
|
||||
this.exporters = config.exporters ?? [];
|
||||
this.reproducer = config.reproducer;
|
||||
this.logger = config.logger ?? new 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(): void {
|
||||
this.aborted = true;
|
||||
}
|
||||
|
||||
async run(): Promise<EngineResult> {
|
||||
const anomalies: IAnomaly[] = [];
|
||||
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 = (): boolean =>
|
||||
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: unknown) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
35
src/core/Logger.ts
Normal file
35
src/core/Logger.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Logger — writes structured JSON log events to a .jsonl file.
|
||||
* One JSON object per line.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ILogger, LogEvent } from './interfaces';
|
||||
|
||||
export class FileLogger implements ILogger {
|
||||
private stream: fs.WriteStream;
|
||||
|
||||
constructor(logDir: string, sessionId: string) {
|
||||
const logPath = path.join(logDir, `session_${sessionId}.jsonl`);
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
this.stream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||
}
|
||||
|
||||
log(event: LogEvent): void {
|
||||
this.stream.write(JSON.stringify(event) + '\n');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stream.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** No-op logger for testing */
|
||||
export class NullLogger implements ILogger {
|
||||
readonly events: LogEvent[] = [];
|
||||
|
||||
log(event: LogEvent): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
}
|
||||
96
src/core/StateGraph.ts
Normal file
96
src/core/StateGraph.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* StateGraph — manages known states and transitions between them.
|
||||
* Uses BFS ordering by default for exploration scheduling.
|
||||
*/
|
||||
|
||||
import { IState, IAction } from './interfaces';
|
||||
|
||||
export interface ITransition {
|
||||
fromId: string;
|
||||
action: IAction;
|
||||
toId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class StateGraph {
|
||||
private states: Map<string, IState> = new Map();
|
||||
private transitions: ITransition[] = [];
|
||||
/** Insertion order for BFS */
|
||||
private insertionOrder: string[] = [];
|
||||
|
||||
addState(state: IState): void {
|
||||
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: string): boolean {
|
||||
return this.states.has(stateId);
|
||||
}
|
||||
|
||||
getState(stateId: string): IState | undefined {
|
||||
return this.states.get(stateId);
|
||||
}
|
||||
|
||||
incrementVisit(stateId: string): void {
|
||||
const state = this.states.get(stateId);
|
||||
if (state) {
|
||||
this.states.set(stateId, { ...state, visitCount: state.visitCount + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
recordTransition(fromId: string, action: IAction, toId: string): void {
|
||||
this.transitions.push({
|
||||
fromId,
|
||||
action,
|
||||
toId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns all states that have never been visited (visitCount === 0) */
|
||||
getUnvisited(): IState[] {
|
||||
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(): IState | null {
|
||||
const unvisited = this.getUnvisited();
|
||||
return unvisited.length > 0 ? unvisited[0] : null;
|
||||
}
|
||||
|
||||
getAllStates(): IState[] {
|
||||
return this.insertionOrder.map((id) => this.states.get(id)!);
|
||||
}
|
||||
|
||||
getTransitions(): ITransition[] {
|
||||
return [...this.transitions];
|
||||
}
|
||||
|
||||
toJSON(): object {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
185
src/core/interfaces.ts
Normal file
185
src/core/interfaces.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* ABE Core Interfaces
|
||||
* Core data types only. Must NOT import from src/plugins/.
|
||||
*/
|
||||
|
||||
// ─── Core Data Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface IState {
|
||||
/** SHA1 hash of DOM snapshot + URL */
|
||||
id: string;
|
||||
/** Full URL at this state */
|
||||
url: string;
|
||||
/** document.title */
|
||||
title: string;
|
||||
/** Date.now() when captured */
|
||||
timestamp: number;
|
||||
/** Serialized outerHTML of body */
|
||||
domSnapshot: string;
|
||||
/** Number of times this state has been visited */
|
||||
visitCount: number;
|
||||
}
|
||||
|
||||
export interface IAction {
|
||||
/** UUID v4 generated when action is created */
|
||||
id: string;
|
||||
type: 'click' | 'fill' | 'navigate' | 'select' | 'submit';
|
||||
/** CSS selector (for click/fill/select/submit) */
|
||||
selector?: string;
|
||||
/** Value to input (for fill/select) */
|
||||
value?: string;
|
||||
/** Destination URL (for navigate) */
|
||||
url?: string;
|
||||
/** When the action was executed */
|
||||
timestamp: number;
|
||||
/** Seed used for random selection */
|
||||
seed: number;
|
||||
/** ID of the state from which this action was executed */
|
||||
stateId: string;
|
||||
}
|
||||
|
||||
export interface IHttpResponse {
|
||||
url: string;
|
||||
status: number;
|
||||
method: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface IObservation {
|
||||
/** UUID v4 */
|
||||
id: string;
|
||||
/** ID of the action that triggered this observation */
|
||||
actionId: string;
|
||||
/** ID of the new state resulting from the action */
|
||||
newStateId: string;
|
||||
/** All HTTP responses captured during the action */
|
||||
httpResponses: IHttpResponse[];
|
||||
/** console.error messages captured */
|
||||
consoleErrors: string[];
|
||||
/** Uncaught JS exceptions */
|
||||
jsExceptions: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type AnomalyType =
|
||||
| 'http_error'
|
||||
| 'js_exception'
|
||||
| 'console_error'
|
||||
| 'navigation_fail'
|
||||
| 'element_missing'
|
||||
| 'timeout'
|
||||
| 'validation_bypass'
|
||||
| 'server_error_on_fuzz'
|
||||
| 'xss_reflection'
|
||||
| 'visual_regression'
|
||||
| 'accessibility_violation'
|
||||
| 'mobile_layout_issue'
|
||||
| 'performance_degradation'
|
||||
| 'offline_handling_missing'
|
||||
| 'slow_network_no_feedback'
|
||||
| 'external_service_crash';
|
||||
|
||||
export interface IAnomalyEvidence {
|
||||
/** Relative path to screenshot */
|
||||
screenshotPath?: string;
|
||||
/** Relative path to serialized DOM */
|
||||
domSnapshotPath?: string;
|
||||
/** Relevant HTTP requests */
|
||||
httpLog?: IHttpResponse[];
|
||||
/** Raw error strings */
|
||||
rawErrors?: string[];
|
||||
}
|
||||
|
||||
export interface IAIEnrichment {
|
||||
rootCause: string;
|
||||
userImpact: string;
|
||||
suggestedFix: string;
|
||||
debugPrompt: string;
|
||||
confidence: 'low' | 'medium' | 'high';
|
||||
generatedAt: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface IEnrichmentContext {
|
||||
domSnapshot: string;
|
||||
httpLog: IHttpResponse[];
|
||||
consoleErrors: string[];
|
||||
actionTrace: IAction[];
|
||||
pageTitle: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface IAIProvider {
|
||||
name: string;
|
||||
enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise<IAIEnrichment>;
|
||||
}
|
||||
|
||||
export interface IAnomaly {
|
||||
/** UUID v4 */
|
||||
id: string;
|
||||
type: AnomalyType;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
/** ID of the observation that triggered this anomaly */
|
||||
observationId: string;
|
||||
/** Exact action sequence that led here */
|
||||
actionTrace: IAction[];
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
evidence: IAnomalyEvidence;
|
||||
timestamp: number;
|
||||
/** Browser that detected this anomaly (for multi-browser runs) */
|
||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||
browserVersion?: string;
|
||||
/** AI-generated enrichment (optional, async) */
|
||||
aiEnrichment?: IAIEnrichment;
|
||||
}
|
||||
|
||||
// ─── Plugin Interfaces (defined here so core never imports from plugins/) ─────
|
||||
|
||||
export interface IInteractionAgent {
|
||||
launch(url: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
discoverActions(state: IState): Promise<IAction[]>;
|
||||
executeAction(action: IAction): Promise<IObservation>;
|
||||
captureState(): Promise<IState>;
|
||||
}
|
||||
|
||||
export interface ICollector {
|
||||
name: string;
|
||||
collect(anomaly: IAnomaly, agent: IInteractionAgent): Promise<IAnomalyEvidence>;
|
||||
}
|
||||
|
||||
export interface IReproducer {
|
||||
/** Serialize trace to JSON string */
|
||||
serialize(trace: IAction[]): string;
|
||||
/** Reconstruct trace from JSON string */
|
||||
deserialize(raw: string): IAction[];
|
||||
/** Generate an executable Playwright script from trace */
|
||||
generateScript(trace: IAction[]): string;
|
||||
}
|
||||
|
||||
export interface IExporter {
|
||||
format: 'markdown' | 'json';
|
||||
/** Export anomaly to outputDir, returns path to generated file */
|
||||
export(anomaly: IAnomaly, outputDir: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface IFuzzingPlugin {
|
||||
/** Generate fuzz fill actions for a given DOM snapshot */
|
||||
generateFuzzActions(domSnapshot: string, state: IState): IAction[];
|
||||
}
|
||||
|
||||
// ─── Logger Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type LogEvent =
|
||||
| { event: 'session_start'; timestamp: number; seed: number; target: string }
|
||||
| { event: 'session_end'; timestamp: number; statesVisited: number; anomaliesFound: number }
|
||||
| { event: 'state_discovered'; timestamp: number; stateId: string; url: string; title: string }
|
||||
| { event: 'action_executed'; timestamp: number; actionId: string; type: string; selector?: string; value?: string; url?: string }
|
||||
| { event: 'anomaly_detected'; timestamp: number; anomalyId: string; type: AnomalyType; severity: string }
|
||||
| { event: 'exploration_step'; timestamp: number; stateId: string; actionId: string };
|
||||
|
||||
export interface ILogger {
|
||||
log(event: LogEvent): void;
|
||||
}
|
||||
111
src/db/AnomalyRepository.ts
Normal file
111
src/db/AnomalyRepository.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* AnomalyRepository — CRUD for anomalies table with filters.
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { IAnomaly } from '../core/interfaces';
|
||||
|
||||
export interface AnomalyRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
type: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
action_trace_json: string;
|
||||
evidence_json: string;
|
||||
screenshot_path: string | null;
|
||||
dom_snapshot_path: string | null;
|
||||
detected_at: number;
|
||||
}
|
||||
|
||||
function rowToAnomaly(row: AnomalyRow): IAnomaly & { sessionId: string } {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
type: row.type as IAnomaly['type'],
|
||||
severity: row.severity as IAnomaly['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,
|
||||
};
|
||||
}
|
||||
|
||||
export class AnomalyRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
create(anomaly: IAnomaly, sessionId: string): void {
|
||||
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: string): (IAnomaly & { sessionId: string }) | undefined {
|
||||
const row = this.db
|
||||
.prepare('SELECT * FROM anomalies WHERE id = ?')
|
||||
.get(id) as AnomalyRow | undefined;
|
||||
return row ? rowToAnomaly(row) : undefined;
|
||||
}
|
||||
|
||||
findAll(filters?: {
|
||||
sessionId?: string;
|
||||
severity?: string;
|
||||
type?: string;
|
||||
}): (IAnomaly & { sessionId: string })[] {
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
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) as AnomalyRow[];
|
||||
return rows.map(rowToAnomaly);
|
||||
}
|
||||
|
||||
countBySeverity(severities: string[]): number {
|
||||
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) as { cnt: number };
|
||||
return result.cnt;
|
||||
}
|
||||
|
||||
count(): number {
|
||||
const result = this.db.prepare('SELECT COUNT(*) as cnt FROM anomalies').get() as { cnt: number };
|
||||
return result.cnt;
|
||||
}
|
||||
}
|
||||
116
src/db/ScheduleRepository.ts
Normal file
116
src/db/ScheduleRepository.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* ScheduleRepository — CRUD for schedules table.
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export interface ScheduleRow {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
config_json: string;
|
||||
cron_expression: string;
|
||||
enabled: number;
|
||||
last_run_at: number | null;
|
||||
next_run_at: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ScheduleRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
configJson: string;
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
lastRunAt: number | null;
|
||||
nextRunAt: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function rowToRecord(row: ScheduleRow): ScheduleRecord {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export class ScheduleRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
create(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
configJson: string;
|
||||
cronExpression: string;
|
||||
enabled?: boolean;
|
||||
nextRunAt?: number;
|
||||
}): void {
|
||||
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: string): ScheduleRecord | undefined {
|
||||
const row = this.db
|
||||
.prepare('SELECT * FROM schedules WHERE id = ?')
|
||||
.get(id) as ScheduleRow | undefined;
|
||||
return row ? rowToRecord(row) : undefined;
|
||||
}
|
||||
|
||||
findAll(enabledOnly = false): ScheduleRecord[] {
|
||||
const rows = enabledOnly
|
||||
? this.db.prepare('SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC').all() as ScheduleRow[]
|
||||
: this.db.prepare('SELECT * FROM schedules ORDER BY created_at DESC').all() as ScheduleRow[];
|
||||
return rows.map(rowToRecord);
|
||||
}
|
||||
|
||||
update(id: string, fields: Partial<{
|
||||
name: string;
|
||||
url: string;
|
||||
configJson: string;
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
lastRunAt: number;
|
||||
nextRunAt: number;
|
||||
}>): void {
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
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: string): void {
|
||||
this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
|
||||
}
|
||||
}
|
||||
96
src/db/SessionRepository.ts
Normal file
96
src/db/SessionRepository.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* SessionRepository — CRUD for sessions table.
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export type SessionStatus = 'running' | 'completed' | 'stopped' | 'error';
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
url: string;
|
||||
status: SessionStatus;
|
||||
seed: number;
|
||||
max_states: number;
|
||||
states_visited: number;
|
||||
anomalies_found: number;
|
||||
started_at: number;
|
||||
finished_at: number | null;
|
||||
config_json: string;
|
||||
}
|
||||
|
||||
export class SessionRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
create(params: {
|
||||
id: string;
|
||||
url: string;
|
||||
seed: number;
|
||||
maxStates: number;
|
||||
startedAt: number;
|
||||
configJson?: string;
|
||||
}): void {
|
||||
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: string): SessionRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM sessions WHERE id = ?')
|
||||
.get(id) as SessionRow | undefined;
|
||||
}
|
||||
|
||||
findAll(): SessionRow[] {
|
||||
return this.db.prepare('SELECT * FROM sessions ORDER BY started_at DESC').all() as SessionRow[];
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
fields: Partial<{
|
||||
status: SessionStatus;
|
||||
statesVisited: number;
|
||||
anomaliesFound: number;
|
||||
finishedAt: number;
|
||||
}>
|
||||
): void {
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
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: string): void {
|
||||
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
||||
}
|
||||
}
|
||||
144
src/db/VisualBaselineRepository.ts
Normal file
144
src/db/VisualBaselineRepository.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* VisualBaselineRepository — CRUD for visual_baselines and visual_comparisons tables.
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export interface VisualBaselineRow {
|
||||
id: string;
|
||||
state_id: string;
|
||||
url: string;
|
||||
screenshot_path: string;
|
||||
approved_at: number;
|
||||
approved_by: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface VisualComparisonRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
state_id: string;
|
||||
baseline_id: string | null;
|
||||
current_screenshot_path: string;
|
||||
diff_screenshot_path: string | null;
|
||||
diff_pixels: number | null;
|
||||
diff_percent: number | null;
|
||||
status: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type ComparisonStatus = 'passed' | 'failed' | 'new_state' | 'pending';
|
||||
|
||||
export class VisualBaselineRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
// ─── Baselines ────────────────────────────────────────────────────────────
|
||||
|
||||
createBaseline(params: {
|
||||
id: string;
|
||||
stateId: string;
|
||||
url: string;
|
||||
screenshotPath: string;
|
||||
width: number;
|
||||
height: number;
|
||||
approvedBy?: string;
|
||||
}): void {
|
||||
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: string): VisualBaselineRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM visual_baselines WHERE state_id = ? ORDER BY approved_at DESC LIMIT 1')
|
||||
.get(stateId) as VisualBaselineRow | undefined;
|
||||
}
|
||||
|
||||
findBaselineById(id: string): VisualBaselineRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM visual_baselines WHERE id = ?')
|
||||
.get(id) as VisualBaselineRow | undefined;
|
||||
}
|
||||
|
||||
// ─── Comparisons ──────────────────────────────────────────────────────────
|
||||
|
||||
createComparison(params: {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
stateId: string;
|
||||
baselineId?: string;
|
||||
currentScreenshotPath: string;
|
||||
diffScreenshotPath?: string;
|
||||
diffPixels?: number;
|
||||
diffPercent?: number;
|
||||
status: ComparisonStatus;
|
||||
}): void {
|
||||
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: string): VisualComparisonRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM visual_comparisons WHERE id = ?')
|
||||
.get(id) as VisualComparisonRow | undefined;
|
||||
}
|
||||
|
||||
findComparisons(filters?: { sessionId?: string; status?: string }): VisualComparisonRow[] {
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
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) as VisualComparisonRow[];
|
||||
}
|
||||
|
||||
updateComparisonStatus(id: string, status: ComparisonStatus): void {
|
||||
this.db.prepare('UPDATE visual_comparisons SET status = ? WHERE id = ?').run(status, id);
|
||||
}
|
||||
|
||||
promoteToBaseline(comparisonId: string): string | null {
|
||||
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;
|
||||
}
|
||||
}
|
||||
41
src/db/connection.ts
Normal file
41
src/db/connection.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* ABE Database Connection
|
||||
* Singleton SQLite connection using better-sqlite3.
|
||||
* Runs migrations on first access.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { runMigrations } from './migrations';
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (_db) return _db;
|
||||
|
||||
const dbPath = process.env['ABE_DB_PATH'] ?? path.join(process.cwd(), 'data', 'abe.db');
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
_db = new Database(dbPath);
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
runMigrations(_db);
|
||||
return _db;
|
||||
}
|
||||
|
||||
/** For testing — inject a custom (in-memory) database instance. */
|
||||
export function setDb(db: Database.Database): void {
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/** Close and reset. Used in tests. */
|
||||
export function closeDb(): void {
|
||||
if (_db) {
|
||||
_db.close();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
126
src/db/migrations.ts
Normal file
126
src/db/migrations.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* ABE Database Migrations
|
||||
* Creates all tables if they do not exist.
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
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
|
||||
);
|
||||
`);
|
||||
}
|
||||
94
src/index.ts
Normal file
94
src/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { ExplorationEngine } from './core/ExplorationEngine';
|
||||
import { StateGraph } from './core/StateGraph';
|
||||
import { FileLogger } from './core/Logger';
|
||||
import { PlaywrightAgent } from './plugins/agents/PlaywrightAgent';
|
||||
import { ScreenshotCollector } from './plugins/collectors/ScreenshotCollector';
|
||||
import { NetworkCollector } from './plugins/collectors/NetworkCollector';
|
||||
import { DOMSnapshotCollector } from './plugins/collectors/DOMSnapshotCollector';
|
||||
import { JSONExporter } from './plugins/exporters/JSONExporter';
|
||||
import { MarkdownExporter } from './plugins/exporters/MarkdownExporter';
|
||||
import { PlaywrightReproducer } from './plugins/reproducers/PlaywrightReproducer';
|
||||
|
||||
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(): { url: string; outputDir: string; seed: number; maxSteps: number } {
|
||||
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(): Promise<void> {
|
||||
const { url, outputDir, seed, maxSteps } = parseArgs();
|
||||
|
||||
const sessionId = `${new Date().toISOString().replace(/[:.]/g, '-')}_seed${seed}`;
|
||||
const logger = new FileLogger('./logs', sessionId);
|
||||
|
||||
const graph = new StateGraph();
|
||||
const agent = new PlaywrightAgent({ seed, headless: true, logger });
|
||||
|
||||
const collectors = [
|
||||
new ScreenshotCollector(outputDir),
|
||||
new NetworkCollector(),
|
||||
new DOMSnapshotCollector(outputDir),
|
||||
];
|
||||
|
||||
const exporters = [
|
||||
new JSONExporter(url),
|
||||
new MarkdownExporter(),
|
||||
];
|
||||
|
||||
const reproducer = new PlaywrightReproducer();
|
||||
|
||||
const engine = new 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();
|
||||
542
src/plugins/agents/PlaywrightAgent.ts
Normal file
542
src/plugins/agents/PlaywrightAgent.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* PlaywrightAgent — implements IInteractionAgent using Playwright.
|
||||
* All random choices use a deterministic seed and are logged.
|
||||
* Supports scope enforcement, auth injection, and action delay.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { chromium, firefox, webkit, devices, Browser, BrowserContext, Page, Request, Response, ConsoleMessage } from 'playwright';
|
||||
import { IState, IAction, IObservation, IHttpResponse, ILogger, IAnomaly } from '../../core/interfaces';
|
||||
import { IInteractionAgent } from '../interfaces';
|
||||
import { NullLogger } from '../../core/Logger';
|
||||
import { ExplorationConfig, AuthConfigLoginFlow, NETWORK_PROFILES } from '../../core/ExplorationConfig';
|
||||
|
||||
export interface PlaywrightAgentConfig {
|
||||
seed?: number;
|
||||
headless?: boolean;
|
||||
timeoutMs?: number;
|
||||
logger?: ILogger;
|
||||
explorationConfig?: Partial<ExplorationConfig>;
|
||||
}
|
||||
|
||||
/** Simple deterministic pseudo-random number generator (LCG) */
|
||||
class SeededRandom {
|
||||
private state: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.state = seed;
|
||||
}
|
||||
|
||||
/** Returns a float in [0, 1) */
|
||||
next(): number {
|
||||
this.state = (this.state * 1664525 + 1013904223) & 0xffffffff;
|
||||
return (this.state >>> 0) / 0x100000000;
|
||||
}
|
||||
|
||||
/** Returns an integer in [0, max) */
|
||||
nextInt(max: number): number {
|
||||
return Math.floor(this.next() * max);
|
||||
}
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function domHash(url: string, domSnapshot: string): string {
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(url + domSnapshot)
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
}
|
||||
|
||||
export class PlaywrightAgent implements IInteractionAgent {
|
||||
private browser?: Browser;
|
||||
private context?: BrowserContext;
|
||||
private page?: Page;
|
||||
private rng: SeededRandom;
|
||||
private seed: number;
|
||||
private headless: boolean;
|
||||
private timeoutMs: number;
|
||||
private logger: ILogger;
|
||||
private explorationConfig: Partial<ExplorationConfig>;
|
||||
|
||||
/** Captured HTTP responses for the current action */
|
||||
private pendingResponses: IHttpResponse[] = [];
|
||||
private pendingConsoleErrors: string[] = [];
|
||||
private pendingJsExceptions: string[] = [];
|
||||
|
||||
constructor(config: PlaywrightAgentConfig = {}) {
|
||||
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 NullLogger();
|
||||
this.explorationConfig = config.explorationConfig ?? {};
|
||||
}
|
||||
|
||||
async launch(url: string): Promise<void> {
|
||||
// Select browser type
|
||||
const browserType = this.explorationConfig.browsers?.[0] ?? 'chromium';
|
||||
const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
||||
this.browser = await launcher.launch({ headless: this.headless });
|
||||
|
||||
// Apply auth headers if configured
|
||||
const auth = this.explorationConfig.auth;
|
||||
let contextOptions: Parameters<Browser['newContext']>[0] = {};
|
||||
|
||||
// Mobile device emulation
|
||||
const mobileDevice = this.explorationConfig.mobileDevice;
|
||||
if (mobileDevice && mobileDevice !== 'none') {
|
||||
const device = 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(): Promise<void> {
|
||||
await this.browser?.close();
|
||||
this.browser = undefined;
|
||||
this.context = undefined;
|
||||
this.page = undefined;
|
||||
}
|
||||
|
||||
async captureState(): Promise<IState> {
|
||||
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: IState): Promise<IAction[]> {
|
||||
const page = this.requirePage();
|
||||
const actions: IAction[] = [];
|
||||
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: IAction): Promise<IObservation> {
|
||||
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: unknown) {
|
||||
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(): Page {
|
||||
return this.requirePage();
|
||||
}
|
||||
|
||||
private requirePage(): Page {
|
||||
if (!this.page) throw new Error('PlaywrightAgent: not launched. Call launch() first.');
|
||||
return this.page;
|
||||
}
|
||||
|
||||
private resetPending(): void {
|
||||
this.pendingResponses = [];
|
||||
this.pendingConsoleErrors = [];
|
||||
this.pendingJsExceptions = [];
|
||||
}
|
||||
|
||||
private buildObservation(
|
||||
observationId: string,
|
||||
actionId: string,
|
||||
newStateId: string
|
||||
): IObservation {
|
||||
return {
|
||||
id: observationId,
|
||||
actionId,
|
||||
newStateId,
|
||||
httpResponses: [...this.pendingResponses],
|
||||
consoleErrors: [...this.pendingConsoleErrors],
|
||||
jsExceptions: [...this.pendingJsExceptions],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
private setupListeners(page: Page): void {
|
||||
const requestTimestamps = new Map<string, number>();
|
||||
|
||||
page.on('request', (req: Request) => {
|
||||
requestTimestamps.set(req.url(), Date.now());
|
||||
});
|
||||
|
||||
page.on('response', (res: Response) => {
|
||||
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: ConsoleMessage) => {
|
||||
if (msg.type() === 'error') {
|
||||
this.pendingConsoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (err: Error) => {
|
||||
this.pendingJsExceptions.push(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
private async buildSelector(el: import('playwright').Locator, fallback: string): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
private isExcludedPath(urlOrPath: string): boolean {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private isExcludedSelector(selector: string): boolean {
|
||||
const excludedSelectors = this.explorationConfig.excludedSelectors ?? [];
|
||||
return excludedSelectors.includes(selector);
|
||||
}
|
||||
|
||||
private isExternalLink(href: string, currentUrl: string): boolean {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private isAllowedUrl(url: string): boolean {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private async performLoginFlow(auth: AuthConfigLoginFlow): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyNetworkChaos(page: Page): Promise<void> {
|
||||
const chaos = this.explorationConfig.networkChaos;
|
||||
if (!chaos?.enabled) return;
|
||||
|
||||
// Apply network condition via CDP (Chromium only)
|
||||
const profile = chaos.profile ?? 'none';
|
||||
const condition = 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private matchGlob(url: string, pattern: string): boolean {
|
||||
// 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: string,
|
||||
sessionId: string,
|
||||
actionTrace: IAnomaly['actionTrace']
|
||||
): Promise<IAnomaly[]> {
|
||||
const page = this.requirePage();
|
||||
const anomalies: IAnomaly[] = [];
|
||||
|
||||
try {
|
||||
const issues = await page.evaluate((): string[] => {
|
||||
const findings: string[] = [];
|
||||
|
||||
// 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<Element>();
|
||||
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((): string[] => []);
|
||||
|
||||
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: IAnomaly['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;
|
||||
}
|
||||
}
|
||||
116
src/plugins/collectors/AccessibilityCollector.ts
Normal file
116
src/plugins/collectors/AccessibilityCollector.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* AccessibilityCollector — runs axe-core after state changes to detect WCAG violations.
|
||||
* Converts axe violations to IAnomaly with severity mapped from impact level.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import type { Page } from 'playwright';
|
||||
import { IAnomaly } from '../../core/interfaces';
|
||||
|
||||
export interface AccessibilityConfig {
|
||||
enabled: boolean;
|
||||
minImpact: 'minor' | 'moderate' | 'serious' | 'critical';
|
||||
wcagLevel: 'A' | 'AA' | 'AAA';
|
||||
}
|
||||
|
||||
export const DEFAULT_A11Y_CONFIG: AccessibilityConfig = {
|
||||
enabled: true,
|
||||
minImpact: 'serious',
|
||||
wcagLevel: 'AA',
|
||||
};
|
||||
|
||||
const IMPACT_TO_SEVERITY: Record<string, IAnomaly['severity']> = {
|
||||
minor: 'low',
|
||||
moderate: 'medium',
|
||||
serious: 'high',
|
||||
critical: 'critical',
|
||||
};
|
||||
|
||||
const IMPACT_RANK: Record<string, number> = {
|
||||
minor: 0,
|
||||
moderate: 1,
|
||||
serious: 2,
|
||||
critical: 3,
|
||||
};
|
||||
|
||||
interface AxeViolation {
|
||||
id: string;
|
||||
impact?: string;
|
||||
description: string;
|
||||
helpUrl: string;
|
||||
nodes: unknown[];
|
||||
}
|
||||
|
||||
export class AccessibilityCollector {
|
||||
private config: AccessibilityConfig;
|
||||
|
||||
constructor(config: Partial<AccessibilityConfig> = {}) {
|
||||
this.config = { ...DEFAULT_A11Y_CONFIG, ...config };
|
||||
}
|
||||
|
||||
async collect(
|
||||
page: Page,
|
||||
stateId: string,
|
||||
sessionId: string,
|
||||
actionTrace: IAnomaly['actionTrace']
|
||||
): Promise<IAnomaly[]> {
|
||||
if (!this.config.enabled) return [];
|
||||
|
||||
try {
|
||||
const violations = await this.runAxe(page);
|
||||
const minRank = IMPACT_RANK[this.config.minImpact] ?? 2;
|
||||
const anomalies: IAnomaly[] = [];
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
private async runAxe(page: Page): Promise<AxeViolation[]> {
|
||||
try {
|
||||
const { AxeBuilder } = await import('@axe-core/playwright');
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze();
|
||||
return results.violations as unknown as AxeViolation[];
|
||||
} 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 as any;
|
||||
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 as AxeViolation[] : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/plugins/collectors/DOMSnapshotCollector.ts
Normal file
30
src/plugins/collectors/DOMSnapshotCollector.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* DOMSnapshotCollector — writes the DOM snapshot at anomaly moment to disk.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IAnomaly, IAnomalyEvidence } from '../../core/interfaces';
|
||||
import { ICollector, IInteractionAgent } from '../interfaces';
|
||||
|
||||
interface HasCaptureState {
|
||||
captureState(): Promise<{ domSnapshot: string }>;
|
||||
}
|
||||
|
||||
export class DOMSnapshotCollector implements ICollector {
|
||||
readonly name = 'DOMSnapshotCollector';
|
||||
|
||||
constructor(private readonly outputDir: string = './reports') {}
|
||||
|
||||
async collect(anomaly: IAnomaly, agent: IInteractionAgent): Promise<IAnomalyEvidence> {
|
||||
const state = await (agent as HasCaptureState).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) };
|
||||
}
|
||||
}
|
||||
17
src/plugins/collectors/NetworkCollector.ts
Normal file
17
src/plugins/collectors/NetworkCollector.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* NetworkCollector — logs all HTTP responses from the current observation.
|
||||
* The data is already captured in the observation; this collector formats it.
|
||||
*/
|
||||
|
||||
import { IAnomaly, IAnomalyEvidence, IHttpResponse } from '../../core/interfaces';
|
||||
import { ICollector, IInteractionAgent } from '../interfaces';
|
||||
|
||||
export class NetworkCollector implements ICollector {
|
||||
readonly name = 'NetworkCollector';
|
||||
|
||||
async collect(anomaly: IAnomaly, _agent: IInteractionAgent): Promise<IAnomalyEvidence> {
|
||||
// HTTP responses are captured in the observation → anomaly evidence
|
||||
const httpLog: IHttpResponse[] = anomaly.evidence.httpLog ?? [];
|
||||
return { httpLog };
|
||||
}
|
||||
}
|
||||
190
src/plugins/collectors/PerformanceCollector.ts
Normal file
190
src/plugins/collectors/PerformanceCollector.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* PerformanceCollector — captures Navigation Timing and Core Web Vitals after each navigation.
|
||||
* Detects performance_degradation anomalies based on configurable thresholds.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import type { Page } from 'playwright';
|
||||
import { IAnomaly } from '../../core/interfaces';
|
||||
|
||||
export interface IPerformanceMetrics {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
stateId: string;
|
||||
url: string;
|
||||
ttfb: number;
|
||||
domContentLoaded: number;
|
||||
loadComplete: number;
|
||||
lcp: number | null;
|
||||
cls: number | null;
|
||||
fid: number | null;
|
||||
inp: number | null;
|
||||
totalRequests: number;
|
||||
failedRequests: number;
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
export interface PerformanceConfig {
|
||||
enabled: boolean;
|
||||
lcpThresholdMs: number;
|
||||
clsThreshold: number;
|
||||
inpThresholdMs: number;
|
||||
ttfbThresholdMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PERF_CONFIG: PerformanceConfig = {
|
||||
enabled: true,
|
||||
lcpThresholdMs: 4000,
|
||||
clsThreshold: 0.25,
|
||||
inpThresholdMs: 500,
|
||||
ttfbThresholdMs: 1800,
|
||||
};
|
||||
|
||||
export class PerformanceCollector {
|
||||
private config: PerformanceConfig;
|
||||
private metricsStore: IPerformanceMetrics[] = [];
|
||||
|
||||
constructor(config: Partial<PerformanceConfig> = {}) {
|
||||
this.config = { ...DEFAULT_PERF_CONFIG, ...config };
|
||||
}
|
||||
|
||||
async collect(
|
||||
page: Page,
|
||||
stateId: string,
|
||||
sessionId: string,
|
||||
actionTrace: IAnomaly['actionTrace']
|
||||
): Promise<{ metrics: IPerformanceMetrics; anomalies: IAnomaly[] }> {
|
||||
if (!this.config.enabled) {
|
||||
const empty: IPerformanceMetrics = {
|
||||
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<{ lcp: number | null; cls: number | null; inp: number | null }>((resolve) => {
|
||||
const result: { lcp: number | null; cls: number | null; inp: number | null } = {
|
||||
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 as any).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: IPerformanceMetrics = {
|
||||
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(): IPerformanceMetrics[] {
|
||||
return this.metricsStore;
|
||||
}
|
||||
|
||||
private detectAnomalies(
|
||||
metrics: IPerformanceMetrics,
|
||||
stateId: string,
|
||||
actionTrace: IAnomaly['actionTrace']
|
||||
): IAnomaly[] {
|
||||
const anomalies: IAnomaly[] = [];
|
||||
|
||||
const issues: string[] = [];
|
||||
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: IAnomaly['severity'][] = ['low', 'medium', 'high', 'critical'];
|
||||
const maxSeverity: IAnomaly['severity'] = 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;
|
||||
}
|
||||
}
|
||||
38
src/plugins/collectors/ScreenshotCollector.ts
Normal file
38
src/plugins/collectors/ScreenshotCollector.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* ScreenshotCollector — captures a PNG screenshot at anomaly moment.
|
||||
* Requires the agent to be a PlaywrightAgent (duck-typing check).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IAnomaly, IAnomalyEvidence } from '../../core/interfaces';
|
||||
import { ICollector, IInteractionAgent } from '../interfaces';
|
||||
|
||||
interface HasPage {
|
||||
getPage(): import('playwright').Page;
|
||||
}
|
||||
|
||||
function isPlaywrightAgent(agent: IInteractionAgent): agent is IInteractionAgent & HasPage {
|
||||
return typeof (agent as unknown as HasPage).getPage === 'function';
|
||||
}
|
||||
|
||||
export class ScreenshotCollector implements ICollector {
|
||||
readonly name = 'ScreenshotCollector';
|
||||
|
||||
constructor(private readonly outputDir: string = './reports') {}
|
||||
|
||||
async collect(anomaly: IAnomaly, agent: IInteractionAgent): Promise<IAnomalyEvidence> {
|
||||
if (!isPlaywrightAgent(agent)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const page = (agent as unknown as HasPage).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) };
|
||||
}
|
||||
}
|
||||
171
src/plugins/collectors/VisualRegressionCollector.ts
Normal file
171
src/plugins/collectors/VisualRegressionCollector.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* VisualRegressionCollector — captures screenshots and compares against baselines.
|
||||
* Uses pixelmatch for pixel-level comparison and sharp for image normalization.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { IState, IAnomaly } from '../../core/interfaces';
|
||||
import { VisualBaselineRepository } from '../../db/VisualBaselineRepository';
|
||||
|
||||
export interface VisualRegressionConfig {
|
||||
enabled: boolean;
|
||||
threshold: number; // default 0.001 (0.1%)
|
||||
screenshotFullPage: boolean;
|
||||
ignoreSelectors: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_VISUAL_CONFIG: VisualRegressionConfig = {
|
||||
enabled: true,
|
||||
threshold: 0.001,
|
||||
screenshotFullPage: false,
|
||||
ignoreSelectors: [],
|
||||
};
|
||||
|
||||
export async function compareScreenshots(
|
||||
baselinePath: string,
|
||||
currentPath: string,
|
||||
diffOutputPath: string,
|
||||
threshold = 0.1
|
||||
): Promise<{ diffPixels: number; diffPercent: number; hasDiff: boolean }> {
|
||||
// Dynamic imports to avoid loading heavy deps at startup
|
||||
const sharp = (await import('sharp')).default;
|
||||
const pixelmatch = (await import('pixelmatch')).default;
|
||||
|
||||
const [baselineRaw, currentRaw] = await Promise.all([
|
||||
sharp(baselinePath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
|
||||
sharp(currentPath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
|
||||
]);
|
||||
|
||||
const { width, height } = baselineRaw.info;
|
||||
const diffBuffer = Buffer.alloc(width * height * 4);
|
||||
|
||||
const diffPixels = pixelmatch(
|
||||
baselineRaw.data,
|
||||
currentRaw.data,
|
||||
diffBuffer,
|
||||
width,
|
||||
height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const totalPixels = width * height;
|
||||
const diffPercent = totalPixels > 0 ? diffPixels / totalPixels : 0;
|
||||
|
||||
// Write diff image
|
||||
await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
|
||||
.png()
|
||||
.toFile(diffOutputPath);
|
||||
|
||||
return { diffPixels, diffPercent, hasDiff: diffPixels > 0 };
|
||||
}
|
||||
|
||||
export class VisualRegressionCollector {
|
||||
private config: VisualRegressionConfig;
|
||||
private outputDir: string;
|
||||
private repo: VisualBaselineRepository;
|
||||
|
||||
constructor(
|
||||
outputDir: string,
|
||||
repo: VisualBaselineRepository,
|
||||
config: Partial<VisualRegressionConfig> = {}
|
||||
) {
|
||||
this.outputDir = outputDir;
|
||||
this.repo = repo;
|
||||
this.config = { ...DEFAULT_VISUAL_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a screenshot for visual regression.
|
||||
* Returns an anomaly if a regression is detected, otherwise null.
|
||||
*/
|
||||
async processScreenshot(
|
||||
screenshotPath: string,
|
||||
state: IState,
|
||||
sessionId: string,
|
||||
actionTrace: IAnomaly['actionTrace']
|
||||
): Promise<IAnomaly | null> {
|
||||
if (!this.config.enabled) return null;
|
||||
|
||||
const comparisonId = crypto.randomUUID();
|
||||
const baseline = 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: IAnomaly['severity'];
|
||||
if (pct > 15) severity = 'critical';
|
||||
else if (pct > 5) severity = 'high';
|
||||
else if (pct > 1) severity = 'medium';
|
||||
else severity = 'low';
|
||||
|
||||
const anomaly: IAnomaly = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'visual_regression',
|
||||
severity,
|
||||
observationId: state.id,
|
||||
actionTrace,
|
||||
description: `Visual regression detected: ${(pct).toFixed(2)}% of pixels changed`,
|
||||
evidence: {
|
||||
screenshotPath: diffPath,
|
||||
rawErrors: [`Diff: ${diffPixels} pixels (${(pct).toFixed(2)}%)`],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return anomaly;
|
||||
}
|
||||
}
|
||||
68
src/plugins/exporters/JSONExporter.ts
Normal file
68
src/plugins/exporters/JSONExporter.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* JSONExporter — produces a structured JSON report for AI debugging workflows.
|
||||
* Output: reports/{anomaly-id}/report.json
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { IAnomaly } from '../../core/interfaces';
|
||||
import { IExporter } from '../interfaces';
|
||||
|
||||
export class JSONExporter implements IExporter {
|
||||
readonly format = 'json' as const;
|
||||
|
||||
constructor(
|
||||
private readonly targetUrl: string = '',
|
||||
private readonly abeVersion: string = '0.1.0',
|
||||
) {}
|
||||
|
||||
async export(anomaly: IAnomaly, outputDir: string): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
88
src/plugins/exporters/MarkdownExporter.ts
Normal file
88
src/plugins/exporters/MarkdownExporter.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* MarkdownExporter — produces a human-readable bug report.
|
||||
* Output: reports/{anomaly-id}/report.md
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IAnomaly } from '../../core/interfaces';
|
||||
import { IExporter } from '../interfaces';
|
||||
|
||||
export class MarkdownExporter implements IExporter {
|
||||
readonly format = 'markdown' as const;
|
||||
|
||||
async export(anomaly: IAnomaly, outputDir: string): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
140
src/plugins/fuzzers/FuzzingEngine.ts
Normal file
140
src/plugins/fuzzers/FuzzingEngine.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* FuzzingEngine — orchestrates fuzzing strategies for form inputs.
|
||||
* Implements IFuzzingPlugin so ExplorationEngine doesn't need to import it directly.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { IAction, IState, IFuzzingPlugin } from '../../core/interfaces';
|
||||
import { detectInputType, DetectedInputType } from './InputTypeDetector';
|
||||
import { EmptyValueStrategy } from './strategies/EmptyValueStrategy';
|
||||
import { OversizedStringStrategy } from './strategies/OversizedStringStrategy';
|
||||
import { SpecialCharsStrategy } from './strategies/SpecialCharsStrategy';
|
||||
import { TypeMismatchStrategy } from './strategies/TypeMismatchStrategy';
|
||||
import { BoundaryValueStrategy } from './strategies/BoundaryValueStrategy';
|
||||
|
||||
export interface FormField {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
inputType?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface FuzzingEngineConfig {
|
||||
intensity: 'low' | 'medium' | 'high';
|
||||
seed: number;
|
||||
}
|
||||
|
||||
/** Regex to match basic input elements in an HTML string */
|
||||
const INPUT_RE = /<(input|textarea|select)[^>]*>/gi;
|
||||
const ATTR_RE = (name: string): RegExp => new RegExp(`${name}="([^"]*)"`, 'i');
|
||||
|
||||
function extractFields(domSnapshot: string): FormField[] {
|
||||
const fields: FormField[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
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;
|
||||
}
|
||||
|
||||
export class FuzzingEngine implements IFuzzingPlugin {
|
||||
private readonly intensity: 'low' | 'medium' | 'high';
|
||||
private readonly seed: number;
|
||||
|
||||
constructor(config: FuzzingEngineConfig) {
|
||||
this.intensity = config.intensity;
|
||||
this.seed = config.seed;
|
||||
}
|
||||
|
||||
/** IFuzzingPlugin implementation — parses fields from DOM snapshot */
|
||||
generateFuzzActions(domSnapshot: string, state: IState): IAction[] {
|
||||
const fields = extractFields(domSnapshot);
|
||||
return this.generateFuzzActionsForFields(fields, state);
|
||||
}
|
||||
|
||||
/** Generate fuzz actions from explicit field descriptors */
|
||||
generateFuzzActionsForFields(fields: FormField[], state: IState): IAction[] {
|
||||
const actions: IAction[] = [];
|
||||
const now = Date.now();
|
||||
const strategies = this.selectStrategies();
|
||||
|
||||
for (const field of fields) {
|
||||
const detectedType = 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;
|
||||
}
|
||||
|
||||
private selectStrategies(): Array<{
|
||||
appliesTo(type: DetectedInputType): boolean;
|
||||
values(type?: DetectedInputType): string[];
|
||||
name: string;
|
||||
}> {
|
||||
const empty = new EmptyValueStrategy();
|
||||
const typeMismatch = new TypeMismatchStrategy();
|
||||
const oversized = new OversizedStringStrategy(this.intensity);
|
||||
const boundary = new BoundaryValueStrategy();
|
||||
const special = new SpecialCharsStrategy();
|
||||
|
||||
switch (this.intensity) {
|
||||
case 'low':
|
||||
return [empty, typeMismatch];
|
||||
case 'medium':
|
||||
return [empty, typeMismatch, oversized, boundary];
|
||||
case 'high':
|
||||
return [empty, typeMismatch, oversized, boundary, special];
|
||||
}
|
||||
}
|
||||
|
||||
private getValuesFromStrategy(
|
||||
strategy: { appliesTo(type: DetectedInputType): boolean; values(type?: DetectedInputType): string[] },
|
||||
type: DetectedInputType
|
||||
): string[] {
|
||||
if (!strategy.appliesTo(type)) return [];
|
||||
return strategy.values(type);
|
||||
}
|
||||
}
|
||||
64
src/plugins/fuzzers/InputTypeDetector.ts
Normal file
64
src/plugins/fuzzers/InputTypeDetector.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* InputTypeDetector — detects field type from DOM attributes.
|
||||
*/
|
||||
|
||||
export type DetectedInputType =
|
||||
| 'email'
|
||||
| 'password'
|
||||
| 'number'
|
||||
| 'date'
|
||||
| 'phone'
|
||||
| 'url'
|
||||
| 'search'
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'file';
|
||||
|
||||
export interface FieldDescriptor {
|
||||
selector: string;
|
||||
type: DetectedInputType;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** Detect type from input[type], name, placeholder, aria-label */
|
||||
export function detectInputType(attrs: {
|
||||
tagName?: string;
|
||||
inputType?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
}): DetectedInputType {
|
||||
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';
|
||||
}
|
||||
25
src/plugins/fuzzers/strategies/BoundaryValueStrategy.ts
Normal file
25
src/plugins/fuzzers/strategies/BoundaryValueStrategy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* BoundaryValueStrategy — tests values at the edges of expected ranges.
|
||||
* Applies to: number, date.
|
||||
*/
|
||||
|
||||
import { DetectedInputType } from '../InputTypeDetector';
|
||||
|
||||
export class BoundaryValueStrategy {
|
||||
readonly name = 'BoundaryValueStrategy';
|
||||
|
||||
appliesTo(type: DetectedInputType): boolean {
|
||||
return type === 'number' || type === 'date';
|
||||
}
|
||||
|
||||
values(type: DetectedInputType): string[] {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return ['0', '-1', '2147483647', '2147483648', '-2147483648'];
|
||||
case 'date':
|
||||
return ['1900-01-01', '2099-12-31', '1970-01-01'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/plugins/fuzzers/strategies/EmptyValueStrategy.ts
Normal file
18
src/plugins/fuzzers/strategies/EmptyValueStrategy.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* EmptyValueStrategy — submits empty/whitespace values to catch missing server-side validation.
|
||||
* Applies to: all input types.
|
||||
*/
|
||||
|
||||
import { DetectedInputType } from '../InputTypeDetector';
|
||||
|
||||
export class EmptyValueStrategy {
|
||||
readonly name = 'EmptyValueStrategy';
|
||||
|
||||
appliesTo(_type: DetectedInputType): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
values(): string[] {
|
||||
return ['', ' ', '\t'];
|
||||
}
|
||||
}
|
||||
29
src/plugins/fuzzers/strategies/OversizedStringStrategy.ts
Normal file
29
src/plugins/fuzzers/strategies/OversizedStringStrategy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* OversizedStringStrategy — submits strings far beyond expected length.
|
||||
* Applies to: text, email, password, textarea.
|
||||
*/
|
||||
|
||||
import { DetectedInputType } from '../InputTypeDetector';
|
||||
|
||||
const APPLICABLE_TYPES: DetectedInputType[] = ['text', 'email', 'password', 'textarea'];
|
||||
|
||||
export class OversizedStringStrategy {
|
||||
readonly name = 'OversizedStringStrategy';
|
||||
|
||||
constructor(private readonly intensity: 'low' | 'medium' | 'high') {}
|
||||
|
||||
appliesTo(type: DetectedInputType): boolean {
|
||||
return APPLICABLE_TYPES.includes(type);
|
||||
}
|
||||
|
||||
values(): string[] {
|
||||
switch (this.intensity) {
|
||||
case 'low':
|
||||
return ['A'.repeat(256)];
|
||||
case 'medium':
|
||||
return ['A'.repeat(1024)];
|
||||
case 'high':
|
||||
return ['A'.repeat(10000) + '日本語テスト𠮷野家'];
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/plugins/fuzzers/strategies/SpecialCharsStrategy.ts
Normal file
26
src/plugins/fuzzers/strategies/SpecialCharsStrategy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* SpecialCharsStrategy — injects characters that break SQL, HTML, and shell contexts.
|
||||
* Applies to: text, email, search, textarea.
|
||||
*/
|
||||
|
||||
import { DetectedInputType } from '../InputTypeDetector';
|
||||
|
||||
const APPLICABLE_TYPES: DetectedInputType[] = ['text', 'email', 'search', 'textarea'];
|
||||
|
||||
export class SpecialCharsStrategy {
|
||||
readonly name = 'SpecialCharsStrategy';
|
||||
|
||||
appliesTo(type: DetectedInputType): boolean {
|
||||
return APPLICABLE_TYPES.includes(type);
|
||||
}
|
||||
|
||||
values(): string[] {
|
||||
return [
|
||||
"' OR 1=1 --",
|
||||
'<script>alert(1)</script>',
|
||||
'../../etc/passwd',
|
||||
'${7*7}',
|
||||
'\x00\x01\x02',
|
||||
];
|
||||
}
|
||||
}
|
||||
30
src/plugins/fuzzers/strategies/TypeMismatchStrategy.ts
Normal file
30
src/plugins/fuzzers/strategies/TypeMismatchStrategy.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* TypeMismatchStrategy — submits wrong data types for the detected field type.
|
||||
*/
|
||||
|
||||
import { DetectedInputType } from '../InputTypeDetector';
|
||||
|
||||
export class TypeMismatchStrategy {
|
||||
readonly name = 'TypeMismatchStrategy';
|
||||
|
||||
appliesTo(type: DetectedInputType): boolean {
|
||||
return ['email', 'number', 'date', 'url', 'phone'].includes(type);
|
||||
}
|
||||
|
||||
values(type: DetectedInputType): string[] {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/plugins/interfaces.ts
Normal file
12
src/plugins/interfaces.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
IInteractionAgent,
|
||||
ICollector,
|
||||
IReproducer,
|
||||
IExporter,
|
||||
} from '../core/interfaces';
|
||||
69
src/plugins/reproducers/PlaywrightReproducer.ts
Normal file
69
src/plugins/reproducers/PlaywrightReproducer.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* PlaywrightReproducer — serializes an action trace and generates a
|
||||
* deterministic Playwright script for replay.
|
||||
*/
|
||||
|
||||
import { IAction } from '../../core/interfaces';
|
||||
import { IReproducer } from '../interfaces';
|
||||
|
||||
export class PlaywrightReproducer implements IReproducer {
|
||||
serialize(trace: IAction[]): string {
|
||||
return JSON.stringify(trace, null, 2);
|
||||
}
|
||||
|
||||
deserialize(raw: string): IAction[] {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('PlaywrightReproducer.deserialize: expected a JSON array');
|
||||
}
|
||||
return parsed as IAction[];
|
||||
}
|
||||
|
||||
generateScript(trace: IAction[]): string {
|
||||
const lines: string[] = [
|
||||
'// 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');
|
||||
}
|
||||
}
|
||||
66
src/replay.ts
Normal file
66
src/replay.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PlaywrightReproducer } from './plugins/reproducers/PlaywrightReproducer';
|
||||
import { IAction } from './core/interfaces';
|
||||
|
||||
function parseArgs(): { reportPath: string } {
|
||||
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(): Promise<void> {
|
||||
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: IAction[] = report.reproduction.steps.map((step: {
|
||||
step: number;
|
||||
action_type: string;
|
||||
selector?: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
timestamp: number;
|
||||
}) => ({
|
||||
id: `replay_step_${step.step}`,
|
||||
type: step.action_type as IAction['type'],
|
||||
selector: step.selector,
|
||||
value: step.value,
|
||||
url: step.url,
|
||||
timestamp: step.timestamp,
|
||||
seed: report.reproduction.seed ?? 42,
|
||||
stateId: 'replay',
|
||||
}));
|
||||
|
||||
const reproducer = new 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();
|
||||
430
src/server/SessionStore.ts
Normal file
430
src/server/SessionStore.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* SessionStore — manages active sessions and persists to SQLite.
|
||||
* In-memory map for running engines; DB for durable storage.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { IAnomaly } from '../core/interfaces';
|
||||
import { ExplorationEngine, EngineConfig, StateHook } from '../core/ExplorationEngine';
|
||||
import { StateGraph } from '../core/StateGraph';
|
||||
import { PlaywrightAgent } from '../plugins/agents/PlaywrightAgent';
|
||||
import { ScreenshotCollector } from '../plugins/collectors/ScreenshotCollector';
|
||||
import { NetworkCollector } from '../plugins/collectors/NetworkCollector';
|
||||
import { DOMSnapshotCollector } from '../plugins/collectors/DOMSnapshotCollector';
|
||||
import { MarkdownExporter } from '../plugins/exporters/MarkdownExporter';
|
||||
import { JSONExporter } from '../plugins/exporters/JSONExporter';
|
||||
import { PlaywrightReproducer } from '../plugins/reproducers/PlaywrightReproducer';
|
||||
import { SessionRepository } from '../db/SessionRepository';
|
||||
import { AnomalyRepository } from '../db/AnomalyRepository';
|
||||
import { ExplorationConfig } from '../core/ExplorationConfig';
|
||||
import { NotificationService } from './notifications/NotificationService';
|
||||
import { FuzzingEngine } from '../plugins/fuzzers/FuzzingEngine';
|
||||
import { VisualRegressionCollector } from '../plugins/collectors/VisualRegressionCollector';
|
||||
import { AccessibilityCollector } from '../plugins/collectors/AccessibilityCollector';
|
||||
import { PerformanceCollector, IPerformanceMetrics } from '../plugins/collectors/PerformanceCollector';
|
||||
import { VisualBaselineRepository } from '../db/VisualBaselineRepository';
|
||||
|
||||
export type SessionStatus = 'running' | 'completed' | 'stopped' | 'error';
|
||||
|
||||
export interface SessionRecord {
|
||||
sessionId: string;
|
||||
url: string;
|
||||
seed: number;
|
||||
maxStates: number;
|
||||
status: SessionStatus;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
statesVisited: number;
|
||||
anomaliesFound: number;
|
||||
anomalies: IAnomaly[];
|
||||
engine?: ExplorationEngine;
|
||||
}
|
||||
|
||||
export type SocketEmitter = (event: string, payload: unknown) => void;
|
||||
|
||||
export class SessionStore {
|
||||
private sessions = new Map<string, SessionRecord>();
|
||||
private emitter: SocketEmitter = () => undefined;
|
||||
private outputDir: string;
|
||||
private sessionRepo: SessionRepository | null;
|
||||
private anomalyRepo: AnomalyRepository | null;
|
||||
private maxConcurrentSessions: number;
|
||||
private notificationService: NotificationService | null;
|
||||
private visualRepo: VisualBaselineRepository | null;
|
||||
/** In-memory performance metrics keyed by sessionId */
|
||||
private performanceMetrics = new Map<string, IPerformanceMetrics[]>();
|
||||
|
||||
constructor(
|
||||
outputDir = './reports',
|
||||
sessionRepo?: SessionRepository,
|
||||
anomalyRepo?: AnomalyRepository,
|
||||
maxConcurrentSessions = 3,
|
||||
notificationService?: NotificationService,
|
||||
visualRepo?: VisualBaselineRepository
|
||||
) {
|
||||
this.outputDir = outputDir;
|
||||
this.sessionRepo = sessionRepo ?? null;
|
||||
this.anomalyRepo = anomalyRepo ?? null;
|
||||
this.maxConcurrentSessions = maxConcurrentSessions;
|
||||
this.notificationService = notificationService ?? null;
|
||||
this.visualRepo = visualRepo ?? null;
|
||||
}
|
||||
|
||||
getPerformanceMetrics(sessionId: string): IPerformanceMetrics[] {
|
||||
return this.performanceMetrics.get(sessionId) ?? [];
|
||||
}
|
||||
|
||||
getMaxConcurrent(): number {
|
||||
return this.maxConcurrentSessions;
|
||||
}
|
||||
|
||||
setEmitter(emitter: SocketEmitter): void {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
getAllSessions(): SessionRecord[] {
|
||||
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: string): SessionRecord | undefined {
|
||||
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?: string, severity?: string): IAnomaly[] {
|
||||
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: string): IAnomaly | undefined {
|
||||
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: string): string | undefined {
|
||||
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: string): string | undefined {
|
||||
const anomaly = this.getAnomaly(anomalyId);
|
||||
if (!anomaly?.evidence.screenshotPath) return undefined;
|
||||
const sessionId = this.findSessionForAnomaly(anomalyId);
|
||||
if (!sessionId) return undefined;
|
||||
return path.resolve(this.outputDir, anomalyId, anomaly.evidence.screenshotPath);
|
||||
}
|
||||
|
||||
stopSession(sessionId: string): boolean {
|
||||
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(): {
|
||||
totalSessions: number;
|
||||
totalAnomalies: number;
|
||||
criticalHighCount: number;
|
||||
runningSessions: number;
|
||||
} {
|
||||
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: {
|
||||
url: string;
|
||||
seed: number;
|
||||
maxStates: number;
|
||||
explorationConfig?: ExplorationConfig;
|
||||
}): Promise<SessionRecord> {
|
||||
const sessionId = `sess_${Date.now()}_${params.seed}`;
|
||||
const startedAt = new Date().toISOString();
|
||||
const startedAtMs = Date.now();
|
||||
|
||||
const record: SessionRecord = {
|
||||
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();
|
||||
const agent = new PlaywrightAgent({
|
||||
seed: params.seed,
|
||||
explorationConfig: params.explorationConfig,
|
||||
});
|
||||
|
||||
const fuzzingEnabled = params.explorationConfig?.fuzzingEnabled !== false;
|
||||
const fuzzingIntensity = params.explorationConfig?.fuzzingIntensity ?? 'medium';
|
||||
const fuzzingPlugin = fuzzingEnabled
|
||||
? new FuzzingEngine({ intensity: fuzzingIntensity, seed: params.seed })
|
||||
: undefined;
|
||||
|
||||
// Build state hooks for visual regression, accessibility, and performance
|
||||
const stateHooks: StateHook[] = [];
|
||||
|
||||
// Visual regression hook
|
||||
if (params.explorationConfig?.visualRegression?.enabled && this.visualRepo) {
|
||||
const visualCollector = new VisualRegressionCollector(
|
||||
this.outputDir,
|
||||
this.visualRepo,
|
||||
params.explorationConfig.visualRegression
|
||||
);
|
||||
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
|
||||
const pw = agentInstance as PlaywrightAgent;
|
||||
if (!pw.getPage) return [];
|
||||
// Take screenshot for visual comparison
|
||||
const screenshotPath = path.join(this.outputDir, sid, `visual_${state.id}.png`);
|
||||
try {
|
||||
const fs_mod = await import('fs/promises');
|
||||
await fs_mod.mkdir(path.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(params.explorationConfig?.accessibility);
|
||||
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
|
||||
const pw = agentInstance as PlaywrightAgent;
|
||||
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(params.explorationConfig?.performance);
|
||||
this.performanceMetrics.set(sessionId, []);
|
||||
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
|
||||
const pw = agentInstance as PlaywrightAgent;
|
||||
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 as PlaywrightAgent;
|
||||
if (!pw.detectMobileLayoutIssues) return [];
|
||||
return pw.detectMobileLayoutIssues(state.id, sid, actionTrace);
|
||||
});
|
||||
}
|
||||
|
||||
const engineConfig: EngineConfig = {
|
||||
graph,
|
||||
agent,
|
||||
seed: params.seed,
|
||||
url: params.url,
|
||||
maxSteps: params.maxStates,
|
||||
explorationConfig: params.explorationConfig,
|
||||
outputDir: this.outputDir,
|
||||
sessionId,
|
||||
fuzzingPlugin,
|
||||
stateHooks,
|
||||
collectors: [
|
||||
new ScreenshotCollector(this.outputDir),
|
||||
new NetworkCollector(),
|
||||
new DOMSnapshotCollector(this.outputDir),
|
||||
],
|
||||
exporters: [new MarkdownExporter(), new JSONExporter()],
|
||||
reproducer: new 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(engineConfig);
|
||||
record.engine = engine;
|
||||
|
||||
// Run in background — do not await
|
||||
engine.run().catch((err: unknown) => {
|
||||
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: string): Promise<string> {
|
||||
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();
|
||||
const script = reproducer.generateScript(anomaly.actionTrace);
|
||||
const replayDir = path.join(this.outputDir, anomalyId, 'replays');
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const fs_mod = await import('fs/promises');
|
||||
await fs_mod.mkdir(replayDir, { recursive: true });
|
||||
const scriptPath = path.join(replayDir, `${replayId}.ts`);
|
||||
await fs_mod.writeFile(scriptPath, script, 'utf8');
|
||||
|
||||
const agent = new 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;
|
||||
}
|
||||
}
|
||||
80
src/server/enrichment/AIEnrichmentService.ts
Normal file
80
src/server/enrichment/AIEnrichmentService.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* AIEnrichmentService — selects AI provider and runs enrichment asynchronously.
|
||||
* Triggered manually (POST /api/anomalies/:id/enrich) or automatically for high/critical.
|
||||
*/
|
||||
|
||||
import { IAnomaly, IEnrichmentContext, IAIProvider } from '../../core/interfaces';
|
||||
import { ClaudeProvider } from './ClaudeProvider';
|
||||
import { OpenAIProvider } from './OpenAIProvider';
|
||||
import { OllamaProvider } from './OllamaProvider';
|
||||
import { log } from '../logger';
|
||||
|
||||
export type SocketEmitter = (event: string, payload: unknown) => void;
|
||||
|
||||
export class AIEnrichmentService {
|
||||
private provider: IAIProvider | null;
|
||||
private autoEnrich: boolean;
|
||||
private minSeverityRank: number;
|
||||
private emitter: SocketEmitter;
|
||||
|
||||
private static SEVERITY_RANK: Record<string, number> = {
|
||||
low: 0, medium: 1, high: 2, critical: 3,
|
||||
};
|
||||
|
||||
constructor(emitter: SocketEmitter) {
|
||||
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();
|
||||
}
|
||||
|
||||
private createProvider(): IAIProvider | null {
|
||||
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(key, model);
|
||||
}
|
||||
if (providerName === 'openai') {
|
||||
const key = process.env['ABE_OPENAI_API_KEY'];
|
||||
if (!key) return null;
|
||||
return new OpenAIProvider(key, model);
|
||||
}
|
||||
if (providerName === 'ollama') {
|
||||
const url = process.env['ABE_OLLAMA_URL'] ?? 'http://localhost:11434';
|
||||
return new OllamaProvider(url, model);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if auto-enrichment should run for this anomaly. */
|
||||
shouldAutoEnrich(anomaly: IAnomaly): boolean {
|
||||
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: IAnomaly, context: IEnrichmentContext): Promise<void> {
|
||||
if (!this.provider) {
|
||||
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 });
|
||||
log.info({ anomalyId: anomaly.id, provider: this.provider.name }, 'Anomaly enriched');
|
||||
} catch (err: unknown) {
|
||||
log.error({ anomalyId: anomaly.id, err: err instanceof Error ? err.message : String(err) }, 'AI enrichment failed');
|
||||
}
|
||||
}
|
||||
|
||||
hasProvider(): boolean {
|
||||
return this.provider !== null;
|
||||
}
|
||||
}
|
||||
106
src/server/enrichment/ClaudeProvider.ts
Normal file
106
src/server/enrichment/ClaudeProvider.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* ClaudeProvider — AI enrichment using Anthropic API.
|
||||
*/
|
||||
|
||||
import { IAIProvider, IAIEnrichment, IAnomaly, IEnrichmentContext } from '../../core/interfaces';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
||||
|
||||
export class ClaudeProvider implements IAIProvider {
|
||||
readonly name = 'claude';
|
||||
private readonly apiKey: string;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(apiKey: string, model = DEFAULT_MODEL) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise<IAIEnrichment> {
|
||||
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() as {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
};
|
||||
|
||||
const text = data.content.find((c) => c.type === 'text')?.text ?? '';
|
||||
return parseEnrichment(text, this.name, this.model);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt(anomaly: IAnomaly, context: IEnrichmentContext): string {
|
||||
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: string, provider: string, model: string): IAIEnrichment {
|
||||
const debugPrompt = `Bug analysis:\n${text}`;
|
||||
|
||||
try {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const parsed = JSON.parse(match[0]) as {
|
||||
rootCause?: string;
|
||||
userImpact?: string;
|
||||
suggestedFix?: string;
|
||||
confidence?: string;
|
||||
};
|
||||
return {
|
||||
rootCause: parsed.rootCause ?? 'Unknown root cause',
|
||||
userImpact: parsed.userImpact ?? 'Unknown impact',
|
||||
suggestedFix: parsed.suggestedFix ?? 'No fix suggested',
|
||||
debugPrompt,
|
||||
confidence: (parsed.confidence as IAIEnrichment['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,
|
||||
};
|
||||
}
|
||||
72
src/server/enrichment/OllamaProvider.ts
Normal file
72
src/server/enrichment/OllamaProvider.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* OllamaProvider — AI enrichment using local Ollama API.
|
||||
*/
|
||||
|
||||
import { IAIProvider, IAIEnrichment, IAnomaly, IEnrichmentContext } from '../../core/interfaces';
|
||||
|
||||
const DEFAULT_MODEL = 'llama3.2';
|
||||
const DEFAULT_URL = 'http://localhost:11434';
|
||||
|
||||
function buildPrompt(anomaly: IAnomaly, context: IEnrichmentContext): string {
|
||||
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(' → ')}`;
|
||||
}
|
||||
|
||||
export class OllamaProvider implements IAIProvider {
|
||||
readonly name = 'ollama';
|
||||
private readonly baseUrl: string;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(baseUrl = DEFAULT_URL, model = DEFAULT_MODEL) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise<IAIEnrichment> {
|
||||
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() as { response?: string };
|
||||
const text = data.response ?? '';
|
||||
|
||||
try {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const p = JSON.parse(match[0]) as Record<string, string>;
|
||||
return {
|
||||
rootCause: p['rootCause'] ?? 'Unknown',
|
||||
userImpact: p['userImpact'] ?? 'Unknown',
|
||||
suggestedFix: p['suggestedFix'] ?? 'None',
|
||||
debugPrompt: text,
|
||||
confidence: (p['confidence'] as IAIEnrichment['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,
|
||||
};
|
||||
}
|
||||
}
|
||||
91
src/server/enrichment/OpenAIProvider.ts
Normal file
91
src/server/enrichment/OpenAIProvider.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* OpenAIProvider — AI enrichment using OpenAI API.
|
||||
*/
|
||||
|
||||
import { IAIProvider, IAIEnrichment, IAnomaly, IEnrichmentContext } from '../../core/interfaces';
|
||||
|
||||
const DEFAULT_MODEL = 'gpt-4o-mini';
|
||||
|
||||
function buildPrompt(anomaly: IAnomaly, context: IEnrichmentContext): string {
|
||||
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: string, model: string): IAIEnrichment {
|
||||
try {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const p = JSON.parse(match[0]) as Record<string, string>;
|
||||
return {
|
||||
rootCause: p['rootCause'] ?? 'Unknown',
|
||||
userImpact: p['userImpact'] ?? 'Unknown',
|
||||
suggestedFix: p['suggestedFix'] ?? 'None',
|
||||
debugPrompt: text,
|
||||
confidence: (p['confidence'] as IAIEnrichment['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,
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenAIProvider implements IAIProvider {
|
||||
readonly name = 'openai';
|
||||
private readonly apiKey: string;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(apiKey: string, model = DEFAULT_MODEL) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise<IAIEnrichment> {
|
||||
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() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
const text = data.choices[0]?.message?.content ?? '';
|
||||
return parseResponse(text, this.model);
|
||||
}
|
||||
}
|
||||
237
src/server/index.ts
Normal file
237
src/server/index.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* ABE API Server
|
||||
* Express + socket.io on port 3001.
|
||||
* Manages exploration sessions and serves REST + WebSocket API.
|
||||
*/
|
||||
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'http';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { createSessionRouter } from './routes/sessions';
|
||||
import { createAnomalyRouter } from './routes/anomalies';
|
||||
import { createConfigRouter } from './routes/config';
|
||||
import { createScheduleRouter } from './routes/schedules';
|
||||
import { createVisualRouter } from './routes/visual';
|
||||
import { SessionStore } from './SessionStore';
|
||||
import { apiKeyAuth } from './middleware/auth';
|
||||
import { log } from './logger';
|
||||
import { SchedulerService } from './scheduler/SchedulerService';
|
||||
import { ScheduleRepository } from '../db/ScheduleRepository';
|
||||
import { VisualBaselineRepository } from '../db/VisualBaselineRepository';
|
||||
import { AIEnrichmentService } from './enrichment/AIEnrichmentService';
|
||||
|
||||
const PORT = process.env['ABE_PORT']
|
||||
? parseInt(process.env['ABE_PORT'], 10)
|
||||
: process.env['PORT']
|
||||
? parseInt(process.env['PORT'], 10)
|
||||
: 3001;
|
||||
|
||||
export function createApp(
|
||||
store: SessionStore,
|
||||
dbCheck?: () => boolean,
|
||||
scheduleRepo?: ScheduleRepository,
|
||||
scheduler?: SchedulerService,
|
||||
visualRepo?: VisualBaselineRepository,
|
||||
enrichmentService?: AIEnrichmentService
|
||||
) {
|
||||
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: corsOrigin }));
|
||||
app.use(express.json());
|
||||
|
||||
// Health endpoints — no auth required
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
const uptime = Math.floor(process.uptime());
|
||||
res.json({ status: 'ok', version: '0.1.0', uptime_seconds: uptime });
|
||||
});
|
||||
|
||||
app.get('/ready', (_req: Request, res: Response) => {
|
||||
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', apiKeyAuth);
|
||||
|
||||
// Global rate limit: 200 req/min
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 200,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.use('/api', globalLimiter);
|
||||
|
||||
// POST /api/sessions rate limit: 20/hour
|
||||
const sessionCreateLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.post('/api/sessions', sessionCreateLimiter);
|
||||
|
||||
app.get('/api/stats', (_req: Request, res: Response) => {
|
||||
res.json(store.getStats());
|
||||
});
|
||||
|
||||
app.use('/api/sessions', createSessionRouter(store));
|
||||
app.use('/api/anomalies', createAnomalyRouter(store, enrichmentService));
|
||||
app.use('/api/config', createConfigRouter());
|
||||
if (scheduleRepo && scheduler) {
|
||||
app.use('/api/schedules', createScheduleRouter(scheduleRepo, scheduler));
|
||||
}
|
||||
if (visualRepo) {
|
||||
app.use('/api/visual', createVisualRouter(visualRepo));
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
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;
|
||||
}
|
||||
|
||||
export function createServer(
|
||||
store: SessionStore,
|
||||
dbCheck?: () => boolean,
|
||||
scheduleRepo?: ScheduleRepository,
|
||||
scheduler?: SchedulerService,
|
||||
visualRepo?: VisualBaselineRepository
|
||||
) {
|
||||
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
|
||||
|
||||
// Deferred emitter: AIEnrichmentService is created before io, using a closure
|
||||
let ioEmit: (event: string, payload: unknown) => void = () => undefined;
|
||||
const enrichmentService = new AIEnrichmentService((event, payload) => ioEmit(event, payload));
|
||||
|
||||
const app = createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService);
|
||||
const httpServer = http.createServer(app);
|
||||
const io = new SocketIOServer(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: { sessionId: string }) => {
|
||||
store.stopSession(data.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
store.setEmitter((event, payload) => {
|
||||
io.emit(event, payload);
|
||||
// Auto-enrich high/critical anomalies
|
||||
if (event === 'anomaly:detected') {
|
||||
const p = payload as { anomalyId?: string };
|
||||
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') as typeof import('../db/connection');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { SessionRepository } = require('../db/SessionRepository') as typeof import('../db/SessionRepository');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { AnomalyRepository } = require('../db/AnomalyRepository') as typeof import('../db/AnomalyRepository');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { NotificationService } = require('./notifications/NotificationService') as typeof import('./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') as typeof import('../db/VisualBaselineRepository');
|
||||
const visualRepo = new VisualRepo(db);
|
||||
const store = new SessionStore(outputDir, sessionRepo, anomalyRepo, maxConcurrent, notificationService, visualRepo);
|
||||
const dbCheck = (): boolean => { 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') as typeof import('../db/ScheduleRepository');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { SchedulerService: SchedSvc } = require('./scheduler/SchedulerService') as typeof import('./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: string): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log.info({ signal }, 'Graceful shutdown initiated');
|
||||
scheduler.stop();
|
||||
server.close(() => {
|
||||
try { db.close(); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
log.error('Forced shutdown after 30s');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
server.listen(PORT, () => {
|
||||
log.info({ port: PORT }, 'ABE API server listening');
|
||||
});
|
||||
}
|
||||
10
src/server/logger.ts
Normal file
10
src/server/logger.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Structured logger using pino.
|
||||
* Log level configurable via ABE_LOG_LEVEL env var (default: 'info').
|
||||
*/
|
||||
|
||||
import pino from 'pino';
|
||||
|
||||
const level = process.env['ABE_LOG_LEVEL'] ?? 'info';
|
||||
|
||||
export const log = pino({ level });
|
||||
21
src/server/middleware/auth.ts
Normal file
21
src/server/middleware/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* API Key authentication middleware.
|
||||
* Reads ABE_API_KEY env var; if not set, dev mode (no auth).
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function apiKeyAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
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();
|
||||
}
|
||||
125
src/server/notifications/NotificationService.ts
Normal file
125
src/server/notifications/NotificationService.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* NotificationService — orchestrates notifiers.
|
||||
* Called after every anomaly:detected event.
|
||||
* Persists notification attempts to the notifications table.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { IAnomaly } from '../../core/interfaces';
|
||||
import { SlackNotifier } from './SlackNotifier';
|
||||
import { WebhookNotifier } from './WebhookNotifier';
|
||||
|
||||
export type NotificationChannel = 'slack' | 'webhook';
|
||||
|
||||
export interface NotificationRecord {
|
||||
id: string;
|
||||
anomalyId: string;
|
||||
channel: NotificationChannel;
|
||||
status: 'pending' | 'success' | 'failed';
|
||||
sentAt?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type NotificationPersister = (record: NotificationRecord) => void;
|
||||
|
||||
const SEVERITY_RANK: Record<string, number> = { low: 0, medium: 1, high: 2, critical: 3 };
|
||||
|
||||
export class NotificationService {
|
||||
private slack?: SlackNotifier;
|
||||
private webhook?: WebhookNotifier;
|
||||
private minSeverityRank: number;
|
||||
private persister?: NotificationPersister;
|
||||
|
||||
constructor(config?: {
|
||||
slackWebhookUrl?: string;
|
||||
webhookUrl?: string;
|
||||
minSeverity?: string;
|
||||
frontendBaseUrl?: string;
|
||||
persister?: NotificationPersister;
|
||||
}) {
|
||||
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(slackUrl, frontendBase);
|
||||
if (webhookUrl) this.webhook = new WebhookNotifier(webhookUrl);
|
||||
this.minSeverityRank = SEVERITY_RANK[minSeverity] ?? 2;
|
||||
this.persister = config?.persister;
|
||||
}
|
||||
|
||||
async notify(anomaly: IAnomaly, sessionId: string, targetUrl: string): Promise<void> {
|
||||
const anomalySeverityRank = SEVERITY_RANK[anomaly.severity] ?? 0;
|
||||
if (anomalySeverityRank < this.minSeverityRank) return;
|
||||
|
||||
const sends: Array<Promise<void>> = [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private async sendWithRetry(
|
||||
channel: NotificationChannel,
|
||||
anomaly: IAnomaly,
|
||||
sessionId: string,
|
||||
targetUrl: string
|
||||
): Promise<void> {
|
||||
const record: NotificationRecord = {
|
||||
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: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
// Retry once after 60s
|
||||
setTimeout(async () => {
|
||||
const retryRecord: NotificationRecord = {
|
||||
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: unknown) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private async doSend(
|
||||
channel: NotificationChannel,
|
||||
anomaly: IAnomaly,
|
||||
sessionId: string,
|
||||
targetUrl: string
|
||||
): Promise<void> {
|
||||
if (channel === 'slack' && this.slack) {
|
||||
await this.slack.send(anomaly, sessionId, targetUrl);
|
||||
} else if (channel === 'webhook' && this.webhook) {
|
||||
await this.webhook.send(anomaly);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/server/notifications/SlackNotifier.ts
Normal file
61
src/server/notifications/SlackNotifier.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* SlackNotifier — sends anomaly notifications to a Slack webhook.
|
||||
*/
|
||||
|
||||
import { IAnomaly } from '../../core/interfaces';
|
||||
|
||||
const SEVERITY_EMOJI: Record<string, string> = {
|
||||
low: ':blue_circle:',
|
||||
medium: ':yellow_circle:',
|
||||
high: ':red_circle:',
|
||||
critical: ':rotating_light:',
|
||||
};
|
||||
|
||||
export class SlackNotifier {
|
||||
constructor(
|
||||
private readonly webhookUrl: string,
|
||||
private readonly frontendBaseUrl = 'http://localhost:5173'
|
||||
) {}
|
||||
|
||||
async send(anomaly: IAnomaly, sessionId: string, targetUrl: string): Promise<void> {
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/server/notifications/WebhookNotifier.ts
Normal file
24
src/server/notifications/WebhookNotifier.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* WebhookNotifier — posts full anomaly JSON to a generic webhook URL.
|
||||
*/
|
||||
|
||||
import { IAnomaly } from '../../core/interfaces';
|
||||
|
||||
export class WebhookNotifier {
|
||||
constructor(private readonly webhookUrl: string) {}
|
||||
|
||||
async send(anomaly: IAnomaly): Promise<void> {
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/server/routes/anomalies.ts
Normal file
96
src/server/routes/anomalies.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { SessionStore } from '../SessionStore';
|
||||
import { AIEnrichmentService } from '../enrichment/AIEnrichmentService';
|
||||
import type { IEnrichmentContext } from '../../core/interfaces';
|
||||
|
||||
export function createAnomalyRouter(store: SessionStore, enrichmentService?: AIEnrichmentService): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/anomalies — list all anomalies (optionally filtered)
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const sessionId = req.query['sessionId'] as string | undefined;
|
||||
const severity = req.query['severity'] as string | undefined;
|
||||
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: Request, res: Response) => {
|
||||
const anomalyId = req.params['anomalyId'] as string;
|
||||
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: Request, res: Response) => {
|
||||
const anomalyId = req.params['anomalyId'] as string;
|
||||
const filePath = store.screenshotPath(anomalyId);
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'Screenshot not found' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// POST /api/anomalies/:anomalyId/replay — trigger replay
|
||||
router.post('/:anomalyId/replay', async (req: Request, res: Response) => {
|
||||
const anomalyId = req.params['anomalyId'] as string;
|
||||
try {
|
||||
const replayId = await store.replayAnomaly(anomalyId);
|
||||
res.json({ replayId, status: 'running' });
|
||||
} catch (err: unknown) {
|
||||
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: Request, res: Response) => {
|
||||
const anomalyId = req.params['anomalyId'] as string;
|
||||
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: IEnrichmentContext = {
|
||||
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;
|
||||
}
|
||||
64
src/server/routes/config.ts
Normal file
64
src/server/routes/config.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Config routes — GET /api/config and PATCH /api/config
|
||||
* Manages server-side configuration for notifications and defaults.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
|
||||
export interface ServerConfig {
|
||||
slackWebhookUrl: string | null;
|
||||
notifyMinSeverity: 'low' | 'medium' | 'high' | 'critical';
|
||||
defaultMaxStates: number;
|
||||
defaultMaxDepth: number;
|
||||
defaultActionDelayMs: number;
|
||||
defaultExcludedPaths: string[];
|
||||
}
|
||||
|
||||
const defaultConfig: ServerConfig = {
|
||||
slackWebhookUrl: process.env['ABE_SLACK_WEBHOOK_URL'] ?? null,
|
||||
notifyMinSeverity: (process.env['ABE_NOTIFY_MIN_SEVERITY'] as ServerConfig['notifyMinSeverity']) ?? 'high',
|
||||
defaultMaxStates: 50,
|
||||
defaultMaxDepth: 5,
|
||||
defaultActionDelayMs: 500,
|
||||
defaultExcludedPaths: [],
|
||||
};
|
||||
|
||||
let serverConfig: ServerConfig = { ...defaultConfig };
|
||||
|
||||
export function getServerConfig(): ServerConfig {
|
||||
return { ...serverConfig };
|
||||
}
|
||||
|
||||
export function createConfigRouter(): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/config — returns current config (without API key)
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json(serverConfig);
|
||||
});
|
||||
|
||||
// PATCH /api/config — updates config fields
|
||||
router.patch('/', (req: Request, res: Response) => {
|
||||
const body = req.body as Partial<ServerConfig>;
|
||||
|
||||
const validKeys: (keyof ServerConfig)[] = [
|
||||
'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 as any)[key] = body[key];
|
||||
}
|
||||
}
|
||||
|
||||
res.json(serverConfig);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
122
src/server/routes/schedules.ts
Normal file
122
src/server/routes/schedules.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Schedules routes — CRUD for /api/schedules
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as crypto from 'crypto';
|
||||
import * as cron from 'node-cron';
|
||||
import { ScheduleRepository } from '../../db/ScheduleRepository';
|
||||
import { SchedulerService } from '../scheduler/SchedulerService';
|
||||
|
||||
export function createScheduleRouter(
|
||||
scheduleRepo: ScheduleRepository,
|
||||
scheduler: SchedulerService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/schedules
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const schedules = scheduleRepo.findAll();
|
||||
res.json(schedules);
|
||||
});
|
||||
|
||||
// POST /api/schedules
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const { name, url, config, cronExpression, enabled } = req.body as {
|
||||
name?: string;
|
||||
url?: string;
|
||||
config?: unknown;
|
||||
cronExpression?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
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.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: Request, res: Response) => {
|
||||
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 as {
|
||||
name?: string;
|
||||
url?: string;
|
||||
config?: unknown;
|
||||
cronExpression?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
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: Request, res: Response) => {
|
||||
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;
|
||||
}
|
||||
118
src/server/routes/sessions.ts
Normal file
118
src/server/routes/sessions.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { SessionStore } from '../SessionStore';
|
||||
import { ExplorationConfig, DEFAULT_EXPLORATION_CONFIG } from '../../core/ExplorationConfig';
|
||||
|
||||
export function createSessionRouter(store: SessionStore): Router {
|
||||
const router = Router();
|
||||
|
||||
// POST /api/sessions — start a new exploration
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const body = req.body as {
|
||||
url?: string;
|
||||
seed?: number;
|
||||
config?: Partial<ExplorationConfig>;
|
||||
};
|
||||
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 = {
|
||||
...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: Request, res: Response) => {
|
||||
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: Request, res: Response) => {
|
||||
const record = store.getSession(req.params['sessionId'] as string);
|
||||
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: Request, res: Response) => {
|
||||
const stopped = store.stopSession(req.params['sessionId'] as string);
|
||||
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: Request, res: Response) => {
|
||||
const sessionId = req.params['sessionId'] as string;
|
||||
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;
|
||||
}
|
||||
56
src/server/routes/visual.ts
Normal file
56
src/server/routes/visual.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Visual regression routes — /api/visual
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { VisualBaselineRepository } from '../../db/VisualBaselineRepository';
|
||||
|
||||
export function createVisualRouter(repo: VisualBaselineRepository): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/visual/comparisons
|
||||
router.get('/comparisons', (req: Request, res: Response) => {
|
||||
const sessionId = req.query['sessionId'] as string | undefined;
|
||||
const status = req.query['status'] as string | undefined;
|
||||
const comparisons = repo.findComparisons({ sessionId, status });
|
||||
res.json(comparisons);
|
||||
});
|
||||
|
||||
// POST /api/visual/baselines/:comparisonId/approve
|
||||
router.post('/baselines/:comparisonId/approve', (req: Request, res: Response) => {
|
||||
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: Request, res: Response) => {
|
||||
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: Request, res: Response) => {
|
||||
const { sessionId } = req.body as { sessionId?: string };
|
||||
const pending = repo.findComparisons({ sessionId, status: 'new_state' });
|
||||
const approved: string[] = [];
|
||||
for (const comp of pending) {
|
||||
const id = repo.promoteToBaseline(comp.id);
|
||||
if (id) approved.push(id);
|
||||
}
|
||||
res.json({ approved: approved.length });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
121
src/server/scheduler/SchedulerService.ts
Normal file
121
src/server/scheduler/SchedulerService.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* SchedulerService — manages cron-based scheduled explorations.
|
||||
* Loads schedules from DB on startup, registers cron jobs, and triggers sessions.
|
||||
*/
|
||||
|
||||
import * as cron from 'node-cron';
|
||||
import * as crypto from 'crypto';
|
||||
import { ScheduleRepository, ScheduleRecord } from '../../db/ScheduleRepository';
|
||||
import { SessionStore } from '../SessionStore';
|
||||
import { log } from '../logger';
|
||||
|
||||
export class SchedulerService {
|
||||
private jobs = new Map<string, cron.ScheduledTask>();
|
||||
|
||||
constructor(
|
||||
private readonly scheduleRepo: ScheduleRepository,
|
||||
private readonly sessionStore: SessionStore
|
||||
) {}
|
||||
|
||||
/** Load all enabled schedules and start cron jobs. */
|
||||
start(): void {
|
||||
const schedules = this.scheduleRepo.findAll(true);
|
||||
for (const schedule of schedules) {
|
||||
this.register(schedule);
|
||||
}
|
||||
log.info({ count: schedules.length }, 'SchedulerService started');
|
||||
}
|
||||
|
||||
/** Stop all cron jobs. */
|
||||
stop(): void {
|
||||
for (const [id, task] of this.jobs) {
|
||||
task.stop();
|
||||
log.info({ scheduleId: id }, 'Cron job stopped');
|
||||
}
|
||||
this.jobs.clear();
|
||||
}
|
||||
|
||||
/** Register (or re-register) a cron job for a schedule. */
|
||||
register(schedule: ScheduleRecord): void {
|
||||
this.unregister(schedule.id);
|
||||
|
||||
if (!schedule.enabled) return;
|
||||
if (!cron.validate(schedule.cronExpression)) {
|
||||
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);
|
||||
log.info({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Cron job registered');
|
||||
}
|
||||
|
||||
/** Unregister a cron job. */
|
||||
unregister(scheduleId: string): void {
|
||||
const existing = this.jobs.get(scheduleId);
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
this.jobs.delete(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire a scheduled run. */
|
||||
private async fire(scheduleId: string): Promise<void> {
|
||||
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) as { scheduleId?: string };
|
||||
const alreadyRunning = running.some((s) => {
|
||||
try {
|
||||
const cfg = JSON.parse((s as unknown as { config_json?: string }).config_json ?? '{}') as { scheduleId?: string };
|
||||
return cfg.scheduleId === scheduleId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (alreadyRunning) {
|
||||
log.warn({ scheduleId }, 'Previous session still running, skipping scheduled tick');
|
||||
return;
|
||||
}
|
||||
|
||||
void scheduleConfig; // suppress unused warning
|
||||
}
|
||||
|
||||
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: unknown) {
|
||||
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: string): number | null {
|
||||
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() + 60_000;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user