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