docs: enterprise refactor plan with ralph specs

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

View File

@@ -0,0 +1,147 @@
import { AnomalyDetector } from '../../src/core/AnomalyDetector';
import { IObservation, IAction } from '../../src/core/interfaces';
function makeObservation(overrides: Partial<IObservation> = {}): IObservation {
return {
id: 'obs-1',
actionId: 'act-1',
newStateId: 'state-2',
httpResponses: [],
consoleErrors: [],
jsExceptions: [],
timestamp: Date.now(),
...overrides,
};
}
function makeAction(id = 'a1'): IAction {
return { id, type: 'click', selector: '#btn', timestamp: 1000, seed: 42, stateId: 's1' };
}
describe('AnomalyDetector', () => {
let detector: AnomalyDetector;
const trace = [makeAction()];
beforeEach(() => {
detector = new AnomalyDetector();
});
describe('checkHttpErrors', () => {
it('returns null when no HTTP errors', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 200, method: 'GET', durationMs: 10 }],
});
expect(detector.checkHttpErrors(obs, trace)).toBeNull();
});
it('detects a 4xx error as medium severity', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 404, method: 'GET', durationMs: 10 }],
});
const anomaly = detector.checkHttpErrors(obs, trace);
expect(anomaly).not.toBeNull();
expect(anomaly!.type).toBe('http_error');
expect(anomaly!.severity).toBe('medium');
});
it('detects a 5xx error as high severity', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 100 }],
});
const anomaly = detector.checkHttpErrors(obs, trace);
expect(anomaly).not.toBeNull();
expect(anomaly!.severity).toBe('high');
});
it('includes http log in evidence', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 100 }],
});
const anomaly = detector.checkHttpErrors(obs, trace)!;
expect(anomaly.evidence.httpLog).toHaveLength(1);
expect(anomaly.evidence.httpLog![0].status).toBe(500);
});
it('includes the full action trace', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 503, method: 'GET', durationMs: 50 }],
});
const multiTrace = [makeAction('a1'), makeAction('a2')];
const anomaly = detector.checkHttpErrors(obs, multiTrace)!;
expect(anomaly.actionTrace).toHaveLength(2);
});
});
describe('checkJsExceptions', () => {
it('returns null when no JS exceptions', () => {
expect(detector.checkJsExceptions(makeObservation(), trace)).toBeNull();
});
it('detects a JS exception as high severity', () => {
const obs = makeObservation({ jsExceptions: ['ReferenceError: foo is not defined'] });
const anomaly = detector.checkJsExceptions(obs, trace);
expect(anomaly).not.toBeNull();
expect(anomaly!.type).toBe('js_exception');
expect(anomaly!.severity).toBe('high');
});
it('includes all exceptions in raw errors', () => {
const obs = makeObservation({ jsExceptions: ['Error A', 'Error B'] });
const anomaly = detector.checkJsExceptions(obs, trace)!;
expect(anomaly.evidence.rawErrors).toEqual(['Error A', 'Error B']);
});
});
describe('checkConsoleErrors', () => {
it('returns null when no console errors', () => {
expect(detector.checkConsoleErrors(makeObservation(), trace)).toBeNull();
});
it('detects console errors as low severity', () => {
const obs = makeObservation({ consoleErrors: ['Failed to load resource'] });
const anomaly = detector.checkConsoleErrors(obs, trace);
expect(anomaly).not.toBeNull();
expect(anomaly!.type).toBe('console_error');
expect(anomaly!.severity).toBe('low');
});
});
describe('detect (combined)', () => {
it('returns empty array when no anomalies', () => {
const result = detector.detect(makeObservation(), trace);
expect(result).toHaveLength(0);
});
it('returns multiple anomalies when multiple rules trigger', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 10 }],
jsExceptions: ['TypeError: Cannot read property'],
consoleErrors: ['Network error'],
});
const result = detector.detect(obs, trace);
expect(result).toHaveLength(3);
const types = result.map((a) => a.type);
expect(types).toContain('http_error');
expect(types).toContain('js_exception');
expect(types).toContain('console_error');
});
it('each anomaly has a unique id', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 10 }],
jsExceptions: ['TypeError'],
});
const anomalies = detector.detect(obs, trace);
const ids = anomalies.map((a) => a.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('anomalies are JSON-serializable', () => {
const obs = makeObservation({
httpResponses: [{ url: '/api', status: 404, method: 'GET', durationMs: 5 }],
});
const anomalies = detector.detect(obs, trace);
expect(() => JSON.stringify(anomalies)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,196 @@
import { ExplorationEngine } from '../../src/core/ExplorationEngine';
import { StateGraph } from '../../src/core/StateGraph';
import { NullLogger } from '../../src/core/Logger';
import { IState, IAction, IObservation } from '../../src/core/interfaces';
import { IInteractionAgent } from '../../src/plugins/interfaces';
function makeState(id: string, url: string): IState {
return { id, url, title: `Page ${id}`, timestamp: 1000, domSnapshot: '<body></body>', visitCount: 0 };
}
function makeAction(id: string, stateId: string): IAction {
return { id, type: 'click', selector: '#btn', timestamp: 2000, seed: 42, stateId };
}
function makeObservation(id: string, actionId: string, newStateId: string): IObservation {
return {
id,
actionId,
newStateId,
httpResponses: [],
consoleErrors: [],
jsExceptions: [],
timestamp: 3000,
};
}
/** Creates a minimal mock IInteractionAgent */
function createMockAgent(states: IState[], actions: IAction[], observations: IObservation[]): IInteractionAgent {
let callCount = 0;
return {
launch: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
captureState: jest.fn().mockImplementation(() => {
const state = states[Math.min(callCount, states.length - 1)];
callCount++;
return Promise.resolve(state);
}),
discoverActions: jest.fn().mockResolvedValue(actions),
executeAction: jest.fn().mockImplementation((_action: IAction) => {
return Promise.resolve(observations[0]);
}),
};
}
describe('ExplorationEngine', () => {
it('launches and closes the agent', async () => {
const graph = new StateGraph();
const state = makeState('s1', 'http://localhost/');
const obs = makeObservation('o1', 'a1', 's1');
const agent = createMockAgent([state], [], [obs]);
const engine = new ExplorationEngine({
graph,
agent,
seed: 42,
url: 'http://localhost/',
maxSteps: 0,
logger: new NullLogger(),
});
await engine.run();
expect(agent.launch).toHaveBeenCalledWith('http://localhost/');
expect(agent.close).toHaveBeenCalled();
});
it('captures initial state and adds it to the graph', async () => {
const graph = new StateGraph();
const state = makeState('s1', 'http://localhost/');
const agent = createMockAgent([state], [], []);
const engine = new ExplorationEngine({
graph,
agent,
seed: 42,
url: 'http://localhost/',
maxSteps: 0,
logger: new NullLogger(),
});
await engine.run();
expect(graph.hasState('s1')).toBe(true);
});
it('executes actions and records transitions', async () => {
const graph = new StateGraph();
const s1 = makeState('s1', '/');
const s2 = makeState('s2', '/about');
const action = makeAction('a1', 's1');
const obs = makeObservation('o1', 'a1', 's2');
const agent = createMockAgent([s1, s2], [action], [obs]);
const engine = new ExplorationEngine({
graph,
agent,
seed: 42,
url: 'http://localhost/',
maxSteps: 1,
logger: new NullLogger(),
});
const result = await engine.run();
expect(result.statesVisited).toBeGreaterThanOrEqual(1);
expect(graph.getTransitions()).toHaveLength(1);
});
it('terminates when maxSteps is reached', async () => {
const graph = new StateGraph();
const state = makeState('s1', '/');
const action = makeAction('a1', 's1');
const obs = makeObservation('o1', 'a1', 's1');
const agent = createMockAgent([state], [action], [obs]);
const engine = new ExplorationEngine({
graph,
agent,
seed: 42,
url: 'http://localhost/',
maxSteps: 3,
logger: new NullLogger(),
});
const result = await engine.run();
expect((agent.executeAction as jest.Mock).mock.calls.length).toBeLessThanOrEqual(3);
expect(result).toBeDefined();
});
it('terminates when no more states to explore', async () => {
const graph = new StateGraph();
const state = makeState('s1', '/');
// No actions to discover → loop exits immediately
const agent = createMockAgent([state], [], []);
const engine = new ExplorationEngine({
graph,
agent,
seed: 42,
url: 'http://localhost/',
maxSteps: 100,
logger: new NullLogger(),
});
const result = await engine.run();
expect(result.anomaliesFound).toBe(0);
});
it('detects anomalies from HTTP errors', async () => {
const graph = new StateGraph();
const s1 = makeState('s1', '/');
const action = makeAction('a1', 's1');
const obs: IObservation = {
id: 'o1',
actionId: 'a1',
newStateId: 's1',
httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 100 }],
consoleErrors: [],
jsExceptions: [],
timestamp: 3000,
};
const agent = createMockAgent([s1], [action], [obs]);
const engine = new ExplorationEngine({
graph,
agent,
seed: 42,
url: 'http://localhost/',
maxSteps: 1,
logger: new NullLogger(),
});
const result = await engine.run();
expect(result.anomaliesFound).toBe(1);
expect(result.anomalies[0].type).toBe('http_error');
});
it('logs all key events via the logger', async () => {
const graph = new StateGraph();
const state = makeState('s1', '/');
const logger = new NullLogger();
const agent = createMockAgent([state], [], []);
const engine = new ExplorationEngine({
graph,
agent,
seed: 42,
url: 'http://localhost/',
maxSteps: 0,
logger,
});
await engine.run();
const eventTypes = logger.events.map((e) => e.event);
expect(eventTypes).toContain('session_start');
expect(eventTypes).toContain('state_discovered');
expect(eventTypes).toContain('session_end');
});
});

View File

@@ -0,0 +1,136 @@
import { StateGraph } from '../../src/core/StateGraph';
import { IState, IAction } from '../../src/core/interfaces';
function makeState(id: string, url: string, visitCount = 0): IState {
return {
id,
url,
title: `Page ${id}`,
timestamp: 1000,
domSnapshot: '<body></body>',
visitCount,
};
}
function makeAction(id: string, stateId: string): IAction {
return {
id,
type: 'click',
selector: '#btn',
timestamp: 2000,
seed: 42,
stateId,
};
}
describe('StateGraph', () => {
describe('addState', () => {
it('adds a new state', () => {
const graph = new StateGraph();
const state = makeState('s1', 'http://localhost/');
graph.addState(state);
expect(graph.hasState('s1')).toBe(true);
});
it('does not duplicate states with the same id', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', 'http://localhost/'));
graph.addState(makeState('s1', 'http://localhost/'));
expect(graph.getAllStates()).toHaveLength(1);
});
it('increments visitCount when adding existing state id', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', 'http://localhost/', 0));
graph.addState(makeState('s1', 'http://localhost/', 0));
const state = graph.getState('s1')!;
expect(state.visitCount).toBe(1);
});
});
describe('hasState', () => {
it('returns false for unknown state', () => {
const graph = new StateGraph();
expect(graph.hasState('nonexistent')).toBe(false);
});
});
describe('recordTransition', () => {
it('records a transition between states', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/'));
graph.addState(makeState('s2', '/about'));
const action = makeAction('a1', 's1');
graph.recordTransition('s1', action, 's2');
const transitions = graph.getTransitions();
expect(transitions).toHaveLength(1);
expect(transitions[0].fromId).toBe('s1');
expect(transitions[0].toId).toBe('s2');
expect(transitions[0].action.id).toBe('a1');
});
it('records multiple transitions', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/'));
graph.addState(makeState('s2', '/a'));
graph.addState(makeState('s3', '/b'));
graph.recordTransition('s1', makeAction('a1', 's1'), 's2');
graph.recordTransition('s1', makeAction('a2', 's1'), 's3');
expect(graph.getTransitions()).toHaveLength(2);
});
});
describe('getUnvisited', () => {
it('returns states with visitCount === 0', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/', 0));
graph.addState(makeState('s2', '/a', 1));
graph.addState(makeState('s3', '/b', 0));
const unvisited = graph.getUnvisited();
expect(unvisited.map((s) => s.id)).toEqual(['s1', 's3']);
});
it('returns empty when all states visited', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/', 1));
expect(graph.getUnvisited()).toHaveLength(0);
});
});
describe('getNextToExplore', () => {
it('returns oldest unvisited state (BFS order)', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/'));
graph.addState(makeState('s2', '/a'));
const next = graph.getNextToExplore();
expect(next?.id).toBe('s1');
});
it('returns null when no unvisited states remain', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/', 1));
expect(graph.getNextToExplore()).toBeNull();
});
it('skips visited states', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/'));
graph.addState(makeState('s2', '/a'));
graph.incrementVisit('s1');
const next = graph.getNextToExplore();
expect(next?.id).toBe('s2');
});
});
describe('toJSON', () => {
it('produces a serializable object', () => {
const graph = new StateGraph();
graph.addState(makeState('s1', '/'));
graph.recordTransition('s1', makeAction('a1', 's1'), 's1');
const json = graph.toJSON() as any;
expect(json.stateCount).toBe(1);
expect(json.transitionCount).toBe(1);
expect(JSON.parse(JSON.stringify(json))).toEqual(json);
});
});
});

View File

@@ -0,0 +1,140 @@
/**
* Unit tests for SessionRepository and AnomalyRepository using in-memory SQLite.
*/
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/db/migrations';
import { SessionRepository } from '../../src/db/SessionRepository';
import { AnomalyRepository } from '../../src/db/AnomalyRepository';
import { IAnomaly } from '../../src/core/interfaces';
function makeDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
return db;
}
function makeAnomaly(id: string): IAnomaly {
return {
id,
type: 'http_error',
severity: 'high',
observationId: 'obs1',
actionTrace: [],
description: 'Test anomaly',
evidence: { rawErrors: ['500 error'] },
timestamp: Date.now(),
};
}
describe('SessionRepository', () => {
let db: Database.Database;
let repo: SessionRepository;
beforeEach(() => {
db = makeDb();
repo = new SessionRepository(db);
});
afterEach(() => db.close());
it('creates and retrieves a session', () => {
repo.create({ id: 'sess1', url: 'http://x.com', seed: 42, maxStates: 50, startedAt: 1000 });
const row = repo.findById('sess1');
expect(row).toBeDefined();
expect(row!.url).toBe('http://x.com');
expect(row!.seed).toBe(42);
expect(row!.status).toBe('running');
});
it('returns undefined for unknown id', () => {
expect(repo.findById('nope')).toBeUndefined();
});
it('findAll returns all sessions', () => {
repo.create({ id: 's1', url: 'http://a.com', seed: 1, maxStates: 10, startedAt: 1000 });
repo.create({ id: 's2', url: 'http://b.com', seed: 2, maxStates: 10, startedAt: 2000 });
expect(repo.findAll()).toHaveLength(2);
});
it('updates fields', () => {
repo.create({ id: 's1', url: 'http://a.com', seed: 1, maxStates: 10, startedAt: 1000 });
repo.update('s1', { status: 'completed', statesVisited: 5, anomaliesFound: 2, finishedAt: 9999 });
const row = repo.findById('s1')!;
expect(row.status).toBe('completed');
expect(row.states_visited).toBe(5);
expect(row.anomalies_found).toBe(2);
expect(row.finished_at).toBe(9999);
});
it('deletes a session', () => {
repo.create({ id: 's1', url: 'http://a.com', seed: 1, maxStates: 10, startedAt: 1000 });
repo.delete('s1');
expect(repo.findById('s1')).toBeUndefined();
});
});
describe('AnomalyRepository', () => {
let db: Database.Database;
let sessionRepo: SessionRepository;
let anomalyRepo: AnomalyRepository;
beforeEach(() => {
db = makeDb();
sessionRepo = new SessionRepository(db);
anomalyRepo = new AnomalyRepository(db);
sessionRepo.create({ id: 'sess1', url: 'http://x.com', seed: 42, maxStates: 50, startedAt: 1000 });
sessionRepo.create({ id: 'sess2', url: 'http://y.com', seed: 43, maxStates: 50, startedAt: 2000 });
});
afterEach(() => db.close());
it('creates and retrieves an anomaly', () => {
const a = makeAnomaly('anom1');
anomalyRepo.create(a, 'sess1');
const found = anomalyRepo.findById('anom1');
expect(found).toBeDefined();
expect(found!.id).toBe('anom1');
expect(found!.sessionId).toBe('sess1');
expect(found!.severity).toBe('high');
});
it('returns undefined for unknown id', () => {
expect(anomalyRepo.findById('nope')).toBeUndefined();
});
it('findAll with sessionId filter', () => {
anomalyRepo.create(makeAnomaly('a1'), 'sess1');
anomalyRepo.create(makeAnomaly('a2'), 'sess2');
const result = anomalyRepo.findAll({ sessionId: 'sess1' });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe('a1');
});
it('findAll with severity filter', () => {
const low = { ...makeAnomaly('a1'), severity: 'low' as const };
const high = { ...makeAnomaly('a2'), severity: 'high' as const };
anomalyRepo.create(low, 'sess1');
anomalyRepo.create(high, 'sess1');
const result = anomalyRepo.findAll({ severity: 'low' });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe('a1');
});
it('count returns total', () => {
anomalyRepo.create(makeAnomaly('a1'), 'sess1');
anomalyRepo.create(makeAnomaly('a2'), 'sess2');
expect(anomalyRepo.count()).toBe(2);
});
it('countBySeverity returns correct count', () => {
const high = { ...makeAnomaly('a1'), severity: 'high' as const };
const critical = { ...makeAnomaly('a2'), severity: 'critical' as const };
const low = { ...makeAnomaly('a3'), severity: 'low' as const };
anomalyRepo.create(high, 'sess1');
anomalyRepo.create(critical, 'sess1');
anomalyRepo.create(low, 'sess1');
expect(anomalyRepo.countBySeverity(['high', 'critical'])).toBe(2);
});
});

View File

@@ -0,0 +1,197 @@
/**
* Tests for AccessibilityCollector with mocked AxeBuilder and violations.
*/
import { AccessibilityCollector } from '../../src/plugins/collectors/AccessibilityCollector';
import type { IAction } from '../../src/core/interfaces';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeAction(): IAction {
return { id: 'a1', type: 'click', timestamp: Date.now(), seed: 42, stateId: 'state1' };
}
interface MockViolation {
id: string;
impact?: string;
description: string;
helpUrl: string;
nodes: unknown[];
}
// ─── Mock @axe-core/playwright ────────────────────────────────────────────────
const analyzeMock = jest.fn();
jest.mock('@axe-core/playwright', () => {
return {
AxeBuilder: jest.fn().mockImplementation(() => ({
withTags: jest.fn().mockReturnThis(),
analyze: analyzeMock,
})),
};
});
function makeViolations(violations: MockViolation[]) {
analyzeMock.mockResolvedValue({ violations });
}
// A minimal mock page (AxeBuilder receives the page, evaluate is only used as fallback)
const mockPage = {
evaluate: jest.fn().mockResolvedValue([]),
};
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
analyzeMock.mockReset();
mockPage.evaluate.mockReset();
mockPage.evaluate.mockResolvedValue([]);
});
describe('AccessibilityCollector', () => {
it('returns empty array when disabled', async () => {
const collector = new AccessibilityCollector({ enabled: false });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result).toHaveLength(0);
expect(analyzeMock).not.toHaveBeenCalled();
});
it('returns empty array when no violations found', async () => {
makeViolations([]);
const collector = new AccessibilityCollector({ enabled: true });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', [makeAction()]);
expect(result).toHaveLength(0);
});
it('converts serious violations to high severity anomalies', async () => {
makeViolations([
{
id: 'image-alt',
impact: 'serious',
description: 'Images must have alternate text',
helpUrl: 'https://dequeuniversity.com/rules/axe/image-alt',
nodes: [{ html: '<img src="logo.png">' }],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', [makeAction()]);
expect(result).toHaveLength(1);
expect(result[0]!.type).toBe('accessibility_violation');
expect(result[0]!.severity).toBe('high');
expect(result[0]!.description).toContain('Images must have alternate text');
});
it('converts critical violations to critical severity', async () => {
makeViolations([
{
id: 'color-contrast',
impact: 'critical',
description: 'Elements must have sufficient color contrast',
helpUrl: 'https://dequeuniversity.com/rules/axe/color-contrast',
nodes: [{ html: '<p>text</p>' }],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result[0]!.severity).toBe('critical');
});
it('converts moderate violations to medium severity', async () => {
makeViolations([
{
id: 'label',
impact: 'moderate',
description: 'Form elements must have labels',
helpUrl: 'https://dequeuniversity.com/rules/axe/label',
nodes: [{}],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result[0]!.severity).toBe('medium');
});
it('filters out violations below minImpact', async () => {
makeViolations([
{
id: 'link-name',
impact: 'minor',
description: 'Links must have discernible text',
helpUrl: 'https://dequeuniversity.com/rules/axe/link-name',
nodes: [{}],
},
]);
// minImpact = 'serious' → minor is ignored
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'serious' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result).toHaveLength(0);
});
it('returns multiple anomalies for multiple violations above threshold', async () => {
makeViolations([
{ id: 'v1', impact: 'serious', description: 'Violation 1', helpUrl: 'h1', nodes: [{}] },
{ id: 'v2', impact: 'critical', description: 'Violation 2', helpUrl: 'h2', nodes: [{}, {}] },
{ id: 'v3', impact: 'moderate', description: 'Violation 3', helpUrl: 'h3', nodes: [{}] },
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'moderate' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result).toHaveLength(3);
const severities = result.map((a) => a.severity);
expect(severities).toContain('high');
expect(severities).toContain('critical');
expect(severities).toContain('medium');
});
it('handles AxeBuilder failure gracefully (returns empty)', async () => {
analyzeMock.mockRejectedValue(new Error('axe internal error'));
// Also make the fallback page.evaluate fail
mockPage.evaluate.mockRejectedValue(new Error('page not available'));
const collector = new AccessibilityCollector({ enabled: true });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
it('includes rule id, node count, and helpUrl in evidence rawErrors', async () => {
makeViolations([
{
id: 'button-name',
impact: 'critical',
description: 'Buttons must have discernible text',
helpUrl: 'https://dequeuniversity.com/rules/axe/button-name',
nodes: [{}, {}, {}],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
const evidence = result[0]!.evidence.rawErrors ?? [];
expect(evidence.some((e) => e.includes('button-name'))).toBe(true);
expect(evidence.some((e) => e.includes('3'))).toBe(true);
expect(evidence.some((e) => e.includes('dequeuniversity'))).toBe(true);
});
it('sets correct observationId and actionTrace on anomaly', async () => {
makeViolations([
{ id: 'v', impact: 'serious', description: 'Desc', helpUrl: 'h', nodes: [{}] },
]);
const actions = [makeAction()];
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'my-state-id', 'sess1', actions);
expect(result[0]!.observationId).toBe('my-state-id');
expect(result[0]!.actionTrace).toBe(actions);
});
});

View File

@@ -0,0 +1,143 @@
/**
* PlaywrightAgent integration test.
* Uses a base64 data: URL so no external server is needed.
*/
import { PlaywrightAgent } from '../../../src/plugins/agents/PlaywrightAgent';
import { NullLogger } from '../../../src/core/Logger';
const HTML_CONTENT = `<!DOCTYPE html>
<html>
<head><title>Test Page</title></head>
<body>
<a href="#section" id="nav-link">Go to section</a>
<button id="submit-btn">Submit</button>
<input type="text" id="name-input" name="name" />
<input type="email" id="email-input" name="email" />
</body>
</html>`;
const TEST_URL = `data:text/html;base64,${Buffer.from(HTML_CONTENT).toString('base64')}`;
describe('PlaywrightAgent', () => {
jest.setTimeout(30000);
let agent: PlaywrightAgent;
beforeEach(() => {
agent = new PlaywrightAgent({ seed: 42, headless: true, logger: new NullLogger() });
});
afterEach(async () => {
await agent.close();
});
it('launches and captures initial state', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
expect(state.id).toBeTruthy();
expect(state.title).toBe('Test Page');
expect(state.domSnapshot).toContain('submit-btn');
expect(state.visitCount).toBe(0);
expect(typeof state.timestamp).toBe('number');
});
it('discovers clickable and fillable actions', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions = await agent.discoverActions(state);
expect(actions.length).toBeGreaterThan(0);
const clicks = actions.filter((a) => a.type === 'click');
const fills = actions.filter((a) => a.type === 'fill');
expect(clicks.length).toBeGreaterThan(0);
expect(fills.length).toBeGreaterThan(0);
// All actions must have required fields
for (const action of actions) {
expect(action.id).toBeTruthy();
expect(action.seed).toBeDefined();
expect(action.stateId).toBe(state.id);
expect(action.timestamp).toBeGreaterThan(0);
}
});
it('executes a click action and returns an observation', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions = await agent.discoverActions(state);
const clickAction = actions.find((a) => a.type === 'click');
expect(clickAction).toBeDefined();
const observation = await agent.executeAction(clickAction!);
expect(observation.id).toBeTruthy();
expect(observation.actionId).toBe(clickAction!.id);
expect(observation.newStateId).toBeTruthy();
expect(Array.isArray(observation.httpResponses)).toBe(true);
expect(Array.isArray(observation.consoleErrors)).toBe(true);
expect(Array.isArray(observation.jsExceptions)).toBe(true);
});
it('executes a fill action and returns an observation', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions = await agent.discoverActions(state);
const fillAction = actions.find((a) => a.type === 'fill');
expect(fillAction).toBeDefined();
const observation = await agent.executeAction(fillAction!);
expect(observation.actionId).toBe(fillAction!.id);
expect(observation.jsExceptions).toHaveLength(0);
});
it('uses deterministic seed for action discovery (same seed = same order)', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions1 = await agent.discoverActions(state);
// Skip if no actions found (page didn't load elements)
if (actions1.length === 0) return;
await agent.close();
const agent2 = new PlaywrightAgent({ seed: 42, headless: true, logger: new NullLogger() });
await agent2.launch(TEST_URL);
const state2 = await agent2.captureState();
const actions2 = await agent2.discoverActions(state2);
await agent2.close();
// Same seed → same seeds on actions in same order
const seeds1 = actions1.map((a) => a.seed);
const seeds2 = actions2.map((a) => a.seed);
expect(seeds1).toEqual(seeds2);
});
it('two instances with different seeds produce different action seeds', async () => {
const agent2 = new PlaywrightAgent({ seed: 99, headless: true, logger: new NullLogger() });
await agent.launch(TEST_URL);
await agent2.launch(TEST_URL);
const state1 = await agent.captureState();
const state2 = await agent2.captureState();
const actions1 = await agent.discoverActions(state1);
const actions2 = await agent2.discoverActions(state2);
await agent2.close();
// Only test if actions were found
if (actions1.length === 0) {
// If no actions, pass — the test validates seed differences when actions exist
return;
}
const seeds1 = actions1.map((a) => a.seed);
const seeds2 = actions2.map((a) => a.seed);
expect(seeds1).not.toEqual(seeds2);
});
});

View File

@@ -0,0 +1,102 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { NetworkCollector } from '../../src/plugins/collectors/NetworkCollector';
import { DOMSnapshotCollector } from '../../src/plugins/collectors/DOMSnapshotCollector';
import { IAnomaly, IAction } from '../../src/core/interfaces';
import { IInteractionAgent } from '../../src/plugins/interfaces';
function makeAnomaly(id = 'anom-001'): IAnomaly {
return {
id,
type: 'http_error',
severity: 'high',
observationId: 'obs-1',
actionTrace: [] as IAction[],
description: 'Test anomaly',
evidence: {
httpLog: [{ url: '/api', status: 500, method: 'POST', durationMs: 100 }],
},
timestamp: Date.now(),
};
}
function makeMockAgent(domSnapshot = '<body><p>hello</p></body>'): IInteractionAgent {
return {
launch: jest.fn(),
close: jest.fn(),
discoverActions: jest.fn(),
executeAction: jest.fn(),
captureState: jest.fn().mockResolvedValue({
id: 'state-1',
url: 'http://localhost/',
title: 'Test',
timestamp: Date.now(),
domSnapshot,
visitCount: 0,
}),
};
}
describe('NetworkCollector', () => {
it('returns httpLog from anomaly evidence', async () => {
const collector = new NetworkCollector();
const anomaly = makeAnomaly();
const agent = makeMockAgent();
const evidence = await collector.collect(anomaly, agent);
expect(evidence.httpLog).toHaveLength(1);
expect(evidence.httpLog![0].status).toBe(500);
});
it('returns empty httpLog when none present', async () => {
const collector = new NetworkCollector();
const anomaly: IAnomaly = { ...makeAnomaly(), evidence: {} };
const agent = makeMockAgent();
const evidence = await collector.collect(anomaly, agent);
expect(evidence.httpLog).toEqual([]);
});
it('has correct name', () => {
expect(new NetworkCollector().name).toBe('NetworkCollector');
});
});
describe('DOMSnapshotCollector', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('writes DOM snapshot to disk', async () => {
const collector = new DOMSnapshotCollector(tmpDir);
const anomaly = makeAnomaly('anom-dom');
const agent = makeMockAgent('<body><h1>Snapshot</h1></body>');
const evidence = await collector.collect(anomaly, agent);
expect(evidence.domSnapshotPath).toBeTruthy();
const fullPath = path.join(tmpDir, evidence.domSnapshotPath!);
expect(fs.existsSync(fullPath)).toBe(true);
const content = fs.readFileSync(fullPath, 'utf8');
expect(content).toContain('<h1>Snapshot</h1>');
});
it('creates output directory if it does not exist', async () => {
const nestedDir = path.join(tmpDir, 'nested', 'output');
const collector = new DOMSnapshotCollector(nestedDir);
const anomaly = makeAnomaly('anom-nested');
const agent = makeMockAgent();
await collector.collect(anomaly, agent);
expect(fs.existsSync(path.join(nestedDir, 'anom-nested'))).toBe(true);
});
it('has correct name', () => {
expect(new DOMSnapshotCollector().name).toBe('DOMSnapshotCollector');
});
});

View File

@@ -0,0 +1,108 @@
/**
* Unit tests for scope enforcement and auth in PlaywrightAgent.
* These tests use the private helper methods indirectly via the agent's behavior
* by testing the isExcludedPath, isExternalLink, and isAllowedUrl logic
* through subclassing or direct method exposure.
*
* Since these methods are private, we test observable behavior via
* discoverActions and executeAction with mock pages or just verify config logic.
*/
import { ExplorationConfig, DEFAULT_EXPLORATION_CONFIG } from '../../src/core/ExplorationConfig';
describe('ExplorationConfig', () => {
it('has sensible defaults', () => {
const cfg = { ...DEFAULT_EXPLORATION_CONFIG };
expect(cfg.maxStates).toBe(50);
expect(cfg.maxDepth).toBe(5);
expect(cfg.actionDelayMs).toBe(500);
expect(cfg.sessionTimeoutMs).toBe(300000);
expect(cfg.fuzzingEnabled).toBe(true);
expect(cfg.fuzzingIntensity).toBe('medium');
expect(cfg.auth).toBeNull();
expect(cfg.excludedPaths).toEqual([]);
expect(cfg.excludedSelectors).toEqual([]);
});
it('accepts cookies auth config', () => {
const config: ExplorationConfig = {
...DEFAULT_EXPLORATION_CONFIG,
auth: {
type: 'cookies',
cookies: [{ name: 'session', value: 'abc', domain: 'localhost' }],
},
};
expect(config.auth?.type).toBe('cookies');
});
it('accepts headers auth config', () => {
const config: ExplorationConfig = {
...DEFAULT_EXPLORATION_CONFIG,
auth: {
type: 'headers',
headers: { Authorization: 'Bearer token123' },
},
};
expect(config.auth?.type).toBe('headers');
});
it('accepts login_flow auth config', () => {
const config: ExplorationConfig = {
...DEFAULT_EXPLORATION_CONFIG,
auth: {
type: 'login_flow',
loginUrl: 'http://app.com/login',
usernameSelector: 'input[name="email"]',
passwordSelector: 'input[name="password"]',
submitSelector: 'button[type="submit"]',
username: 'user@test.com',
password: 'secret',
},
};
expect(config.auth?.type).toBe('login_flow');
});
});
// Helper to test URL-based scope rules (extracted for testability)
describe('Scope URL rules', () => {
function isExcludedPath(urlOrPath: string, excludedPaths: string[]): boolean {
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;
}
}
function isExternalLink(href: string, currentUrl: string, allowedDomains: string[]): boolean {
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;
}
}
it('excludes paths correctly', () => {
expect(isExcludedPath('http://app.com/logout', ['/logout'])).toBe(true);
expect(isExcludedPath('http://app.com/home', ['/logout'])).toBe(false);
expect(isExcludedPath('http://app.com/admin/users', ['/admin'])).toBe(true);
});
it('allows paths when no exclusions', () => {
expect(isExcludedPath('http://app.com/logout', [])).toBe(false);
});
it('detects external links', () => {
expect(isExternalLink('http://external.com/page', 'http://myapp.com', ['myapp.com'])).toBe(true);
expect(isExternalLink('/page', 'http://myapp.com', ['myapp.com'])).toBe(false);
expect(isExternalLink('http://myapp.com/page', 'http://myapp.com', ['myapp.com'])).toBe(false);
});
it('allows all links when no allowedDomains', () => {
expect(isExternalLink('http://external.com/page', 'http://myapp.com', [])).toBe(false);
});
});

View File

@@ -0,0 +1,111 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { JSONExporter } from '../../../src/plugins/exporters/JSONExporter';
import { IAnomaly, IAction } from '../../../src/core/interfaces';
function makeAnomaly(id = 'anom-001'): IAnomaly {
const action: IAction = {
id: 'act-1',
type: 'click',
selector: '#submit',
timestamp: 1700000000000,
seed: 42,
stateId: 's1',
};
return {
id,
type: 'http_error',
severity: 'high',
observationId: 'obs-1',
actionTrace: [action],
description: 'HTTP 500 on form submit',
evidence: {
httpLog: [{ url: '/api/register', status: 500, method: 'POST', durationMs: 234 }],
rawErrors: ['POST /api/register → 500'],
},
timestamp: 1700000000000,
};
}
describe('JSONExporter', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-json-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('creates report.json in the output directory', async () => {
const exporter = new JSONExporter('http://localhost:3000');
const outputDir = path.join(tmpDir, 'anom-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
expect(fs.existsSync(filePath)).toBe(true);
expect(filePath.endsWith('report.json')).toBe(true);
});
it('produces valid JSON', async () => {
const exporter = new JSONExporter('http://localhost:3000');
const outputDir = path.join(tmpDir, 'anom-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(() => JSON.parse(content)).not.toThrow();
});
it('includes all required top-level fields', async () => {
const exporter = new JSONExporter('http://localhost:3000');
const outputDir = path.join(tmpDir, 'anom-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.version).toBe('1.0');
expect(report.generated_at).toBeTruthy();
expect(report.environment).toBeDefined();
expect(report.anomaly).toBeDefined();
expect(report.reproduction).toBeDefined();
expect(report.evidence).toBeDefined();
});
it('includes anomaly details', async () => {
const exporter = new JSONExporter();
const anomaly = makeAnomaly('test-id');
const outputDir = path.join(tmpDir, 'test-id');
const filePath = await exporter.export(anomaly, outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.anomaly.id).toBe('test-id');
expect(report.anomaly.type).toBe('http_error');
expect(report.anomaly.severity).toBe('high');
});
it('includes reproduction steps', async () => {
const exporter = new JSONExporter();
const outputDir = path.join(tmpDir, 'steps-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.reproduction.steps).toHaveLength(1);
expect(report.reproduction.steps[0].step).toBe(1);
expect(report.reproduction.steps[0].action_type).toBe('click');
});
it('includes HTTP log in evidence', async () => {
const exporter = new JSONExporter();
const outputDir = path.join(tmpDir, 'http-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.evidence.http_log).toHaveLength(1);
expect(report.evidence.http_log[0].status).toBe(500);
});
it('has format = json', () => {
expect(new JSONExporter().format).toBe('json');
});
it('creates output directory if it does not exist', async () => {
const exporter = new JSONExporter();
const nestedDir = path.join(tmpDir, 'nested', 'deep', 'anom-001');
await exporter.export(makeAnomaly(), nestedDir);
expect(fs.existsSync(nestedDir)).toBe(true);
});
});

View File

@@ -0,0 +1,133 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { MarkdownExporter } from '../../../src/plugins/exporters/MarkdownExporter';
import { IAnomaly, IAction } from '../../../src/core/interfaces';
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
const action: IAction = {
id: 'act-1',
type: 'navigate',
url: 'http://localhost:3000/register',
timestamp: 1700000000000,
seed: 42,
stateId: 's1',
};
return {
id: 'anom-md-001',
type: 'http_error',
severity: 'high',
observationId: 'obs-1',
actionTrace: [action],
description: 'Form submission returns HTTP 500 on empty email field',
evidence: {
screenshotPath: 'anom-md-001/screenshot.png',
domSnapshotPath: 'anom-md-001/dom.html',
httpLog: [{ url: '/api/register', status: 500, method: 'POST', durationMs: 234 }],
rawErrors: ['POST /api/register → 500 (234ms)'],
},
timestamp: 1700000000000,
...overrides,
};
}
describe('MarkdownExporter', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-md-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('creates report.md in the output directory', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'anom-md-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
expect(fs.existsSync(filePath)).toBe(true);
expect(filePath.endsWith('report.md')).toBe(true);
});
it('includes anomaly type and date in title', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'title-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('# Bug Report');
expect(content).toContain('http_error');
});
it('includes severity section', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'severity-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Severity');
expect(content).toContain('high');
});
it('includes reproduction steps with navigate action', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'steps-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Reproduction Steps');
expect(content).toContain('Navigate to');
expect(content).toContain('http://localhost:3000/register');
});
it('includes seed and replay command', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'seed-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('Seed used');
expect(content).toContain('42');
expect(content).toContain('Replay command');
expect(content).toContain('npm run replay');
});
it('includes evidence section with screenshot and dom paths', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'evidence-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Evidence');
expect(content).toContain('screenshot.png');
expect(content).toContain('dom.html');
});
it('includes HTTP table when responses present', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'http-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('500');
expect(content).toContain('/api/register');
expect(content).toContain('POST');
});
it('includes raw errors section', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'errors-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Raw Errors');
expect(content).toContain('POST /api/register');
});
it('has format = markdown', () => {
expect(new MarkdownExporter().format).toBe('markdown');
});
it('handles anomaly with no action trace', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'no-trace-test');
const anomaly = makeAnomaly({ actionTrace: [] });
const filePath = await exporter.export(anomaly, outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('No steps recorded');
});
});

View File

@@ -0,0 +1,246 @@
/**
* Unit tests for fuzzing strategies and FuzzingEngine.
*/
import { detectInputType } from '../../src/plugins/fuzzers/InputTypeDetector';
import { EmptyValueStrategy } from '../../src/plugins/fuzzers/strategies/EmptyValueStrategy';
import { OversizedStringStrategy } from '../../src/plugins/fuzzers/strategies/OversizedStringStrategy';
import { SpecialCharsStrategy } from '../../src/plugins/fuzzers/strategies/SpecialCharsStrategy';
import { TypeMismatchStrategy } from '../../src/plugins/fuzzers/strategies/TypeMismatchStrategy';
import { BoundaryValueStrategy } from '../../src/plugins/fuzzers/strategies/BoundaryValueStrategy';
import { FuzzingEngine } from '../../src/plugins/fuzzers/FuzzingEngine';
import { IState } from '../../src/core/interfaces';
function makeState(domSnapshot = ''): IState {
return {
id: 'state1',
url: 'http://test.com',
title: 'Test',
timestamp: Date.now(),
domSnapshot,
visitCount: 1,
};
}
// ─── InputTypeDetector ────────────────────────────────────────────────────────
describe('detectInputType', () => {
it('detects email from inputType', () => {
expect(detectInputType({ inputType: 'email' })).toBe('email');
});
it('detects password from inputType', () => {
expect(detectInputType({ inputType: 'password' })).toBe('password');
});
it('detects number from inputType', () => {
expect(detectInputType({ inputType: 'number' })).toBe('number');
});
it('detects email from name attribute', () => {
expect(detectInputType({ name: 'email_address' })).toBe('email');
});
it('detects phone from placeholder', () => {
expect(detectInputType({ placeholder: 'Enter phone number' })).toBe('phone');
});
it('detects textarea from tagName', () => {
expect(detectInputType({ tagName: 'textarea' })).toBe('textarea');
});
it('falls back to text for unknown', () => {
expect(detectInputType({})).toBe('text');
});
});
// ─── EmptyValueStrategy ───────────────────────────────────────────────────────
describe('EmptyValueStrategy', () => {
const strategy = new EmptyValueStrategy();
it('applies to all types', () => {
expect(strategy.appliesTo('email')).toBe(true);
expect(strategy.appliesTo('number')).toBe(true);
expect(strategy.appliesTo('text')).toBe(true);
});
it('returns empty/whitespace values', () => {
expect(strategy.values()).toContain('');
expect(strategy.values()).toContain(' ');
expect(strategy.values()).toContain('\t');
});
});
// ─── OversizedStringStrategy ──────────────────────────────────────────────────
describe('OversizedStringStrategy', () => {
it('applies to text types', () => {
const s = new OversizedStringStrategy('medium');
expect(s.appliesTo('text')).toBe(true);
expect(s.appliesTo('email')).toBe(true);
expect(s.appliesTo('number')).toBe(false);
});
it('returns low-intensity 256 chars', () => {
const s = new OversizedStringStrategy('low');
expect(s.values()[0]?.length).toBe(256);
});
it('returns medium-intensity 1024 chars', () => {
const s = new OversizedStringStrategy('medium');
expect(s.values()[0]?.length).toBe(1024);
});
it('returns high-intensity 10000+ chars', () => {
const s = new OversizedStringStrategy('high');
expect(s.values()[0]!.length).toBeGreaterThan(10000);
});
});
// ─── SpecialCharsStrategy ─────────────────────────────────────────────────────
describe('SpecialCharsStrategy', () => {
const s = new SpecialCharsStrategy();
it('applies to text, email, search, textarea', () => {
expect(s.appliesTo('text')).toBe(true);
expect(s.appliesTo('email')).toBe(true);
expect(s.appliesTo('number')).toBe(false);
});
it('includes SQL injection payload', () => {
expect(s.values()).toContain("' OR 1=1 --");
});
it('includes XSS payload', () => {
expect(s.values()).toContain('<script>alert(1)</script>');
});
});
// ─── TypeMismatchStrategy ─────────────────────────────────────────────────────
describe('TypeMismatchStrategy', () => {
const s = new TypeMismatchStrategy();
it('applies to typed fields', () => {
expect(s.appliesTo('email')).toBe(true);
expect(s.appliesTo('number')).toBe(true);
expect(s.appliesTo('text')).toBe(false);
});
it('returns mismatched values for email', () => {
expect(s.values('email')).toContain('not-an-email');
});
it('returns mismatched values for number', () => {
expect(s.values('number')).toContain('abc');
});
it('returns empty for unhandled type', () => {
expect(s.values('text')).toEqual([]);
});
});
// ─── BoundaryValueStrategy ────────────────────────────────────────────────────
describe('BoundaryValueStrategy', () => {
const s = new BoundaryValueStrategy();
it('applies to number and date', () => {
expect(s.appliesTo('number')).toBe(true);
expect(s.appliesTo('date')).toBe(true);
expect(s.appliesTo('text')).toBe(false);
});
it('returns boundary numbers', () => {
expect(s.values('number')).toContain('0');
expect(s.values('number')).toContain('2147483647');
});
it('returns boundary dates', () => {
expect(s.values('date')).toContain('1900-01-01');
});
});
// ─── FuzzingEngine ────────────────────────────────────────────────────────────
describe('FuzzingEngine', () => {
it('generates actions from DOM snapshot with input fields', () => {
const engine = new FuzzingEngine({ intensity: 'low', seed: 42 });
const dom = `<form><input type="email" name="email" /><input type="password" name="pass" /></form>`;
const state = makeState(dom);
const actions = engine.generateFuzzActions(dom, state);
expect(actions.length).toBeGreaterThan(0);
expect(actions.every((a) => a.type === 'fill')).toBe(true);
expect(actions.every((a) => a.stateId === 'state1')).toBe(true);
});
it('generates more actions at high intensity', () => {
const low = new FuzzingEngine({ intensity: 'low', seed: 1 });
const high = new FuzzingEngine({ intensity: 'high', seed: 1 });
const dom = `<input type="text" name="q" />`;
const state = makeState(dom);
expect(high.generateFuzzActions(dom, state).length).toBeGreaterThan(
low.generateFuzzActions(dom, state).length
);
});
it('returns empty array for DOM with no inputs', () => {
const engine = new FuzzingEngine({ intensity: 'medium', seed: 1 });
const dom = `<div><p>No forms here</p></div>`;
const state = makeState(dom);
expect(engine.generateFuzzActions(dom, state)).toHaveLength(0);
});
});
// ─── AnomalyDetector fuzzing rules ────────────────────────────────────────────
describe('AnomalyDetector fuzzing anomaly types', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AnomalyDetector } = require('../../src/core/AnomalyDetector');
const detector = new AnomalyDetector();
const baseObs = {
id: 'obs1',
actionId: 'act1',
newStateId: 's1',
httpResponses: [],
consoleErrors: [],
jsExceptions: [],
timestamp: Date.now(),
};
it('detects validation_bypass on 200 response to empty input', () => {
const obs = { ...baseObs, httpResponses: [{ url: '/', status: 200, method: 'POST', durationMs: 10 }] };
const result = detector.checkValidationBypass(obs, [], '');
expect(result).not.toBeNull();
expect(result!.type).toBe('validation_bypass');
});
it('does not detect validation_bypass without 2xx', () => {
const obs = { ...baseObs, httpResponses: [{ url: '/', status: 400, method: 'POST', durationMs: 10 }] };
const result = detector.checkValidationBypass(obs, [], '');
expect(result).toBeNull();
});
it('detects server_error_on_fuzz on 500', () => {
const obs = { ...baseObs, httpResponses: [{ url: '/', status: 500, method: 'POST', durationMs: 10 }] };
const result = detector.checkServerErrorOnFuzz(obs, []);
expect(result).not.toBeNull();
expect(result!.type).toBe('server_error_on_fuzz');
expect(result!.severity).toBe('high');
});
it('detects xss_reflection when script tag in DOM', () => {
const result = detector.checkXssReflection(baseObs, [], '<script>alert(1)</script>');
expect(result).not.toBeNull();
expect(result!.type).toBe('xss_reflection');
expect(result!.severity).toBe('critical');
});
it('does not detect xss_reflection without payload in DOM', () => {
const result = detector.checkXssReflection(baseObs, [], '<div>clean</div>');
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,289 @@
/**
* Tests for network chaos: applyNetworkCondition (via PlaywrightAgent private method)
* and route interception for blocked/slow endpoints.
*
* Since applyNetworkChaos is private, we test it indirectly via PlaywrightAgent
* by mocking the CDP session and route handler.
*/
import { NETWORK_PROFILES } from '../../src/core/ExplorationConfig';
// ─── NETWORK_PROFILES constants ───────────────────────────────────────────────
describe('NETWORK_PROFILES', () => {
it('defines fast-3g profile', () => {
const p = NETWORK_PROFILES['fast-3g'];
expect(p).toBeDefined();
expect(p!.offline).toBe(false);
expect(p!.downloadKbps).toBe(1500);
expect(p!.latencyMs).toBe(40);
});
it('defines slow-3g profile', () => {
const p = NETWORK_PROFILES['slow-3g'];
expect(p).toBeDefined();
expect(p!.downloadKbps).toBe(400);
expect(p!.latencyMs).toBe(400);
});
it('defines 2g profile', () => {
const p = NETWORK_PROFILES['2g'];
expect(p).toBeDefined();
expect(p!.downloadKbps).toBe(50);
expect(p!.latencyMs).toBe(800);
});
it('defines offline profile', () => {
const p = NETWORK_PROFILES['offline'];
expect(p).toBeDefined();
expect(p!.offline).toBe(true);
expect(p!.downloadKbps).toBe(0);
});
it('returns null for "none" profile', () => {
expect(NETWORK_PROFILES['none']).toBeNull();
});
it('covers all expected profile keys', () => {
const expected = ['fast-3g', 'slow-3g', '2g', 'offline', 'none'] as const;
for (const key of expected) {
expect(NETWORK_PROFILES).toHaveProperty(key);
}
});
});
// ─── applyNetworkCondition via mocked CDP ─────────────────────────────────────
describe('PlaywrightAgent network chaos via CDP mock', () => {
const cdpSendMock = jest.fn().mockResolvedValue(undefined);
const routeMock = jest.fn().mockResolvedValue(undefined);
const newCDPSessionMock = jest.fn().mockResolvedValue({ send: cdpSendMock });
function buildMockContext() {
return { newCDPSession: newCDPSessionMock };
}
function buildMockPage(url = 'http://localhost') {
return {
url: () => url,
route: routeMock,
goto: jest.fn().mockResolvedValue(undefined),
waitForTimeout: jest.fn().mockResolvedValue(undefined),
evaluate: jest.fn().mockResolvedValue('<body></body>'),
title: jest.fn().mockResolvedValue('Test'),
on: jest.fn(),
};
}
beforeEach(() => {
cdpSendMock.mockClear();
routeMock.mockClear();
newCDPSessionMock.mockClear();
});
it('calls CDP Network.emulateNetworkConditions for fast-3g', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'fast-3g',
blockedEndpoints: [],
slowEndpoints: [],
},
},
});
// Inject mocked internals
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = buildMockPage();
// Call applyNetworkChaos via a private method trick
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(newCDPSessionMock).toHaveBeenCalledWith(page);
expect(cdpSendMock).toHaveBeenCalledWith('Network.emulateNetworkConditions', expect.objectContaining({
offline: false,
latency: 40,
}));
});
it('calls CDP with offline:true for offline profile', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'offline',
blockedEndpoints: [],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(cdpSendMock).toHaveBeenCalledWith('Network.emulateNetworkConditions', expect.objectContaining({
offline: true,
downloadThroughput: -1,
uploadThroughput: -1,
}));
});
it('sets up route interception for blocked endpoints', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: ['*/api/analytics'],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(routeMock).toHaveBeenCalledWith('**/*', expect.any(Function));
});
it('sets up route interception for slow endpoints', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: [],
slowEndpoints: [{ pattern: '*/api/slow', delayMs: 2000 }],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(routeMock).toHaveBeenCalledWith('*/api/slow', expect.any(Function));
});
it('does nothing when networkChaos is disabled', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: { enabled: false, profile: 'fast-3g', blockedEndpoints: [], slowEndpoints: [] },
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(cdpSendMock).not.toHaveBeenCalled();
expect(routeMock).not.toHaveBeenCalled();
});
it('fulfills with 503 for requests matching blocked endpoint patterns', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: ['*/api/blocked'],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
// Capture the route handler
let capturedHandler: ((route: unknown) => void) | null = null;
const page = {
...buildMockPage(),
route: jest.fn().mockImplementation((_pattern: unknown, handler: (r: unknown) => void) => {
capturedHandler = handler;
}),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(capturedHandler).not.toBeNull();
// Simulate a request to a blocked URL
const fulfillMock = jest.fn();
const continueMock = jest.fn();
const blockedRoute = {
request: () => ({ url: () => 'http://example.com/api/blocked' }),
fulfill: fulfillMock,
continue: continueMock,
};
capturedHandler!(blockedRoute);
expect(fulfillMock).toHaveBeenCalledWith(expect.objectContaining({ status: 503 }));
expect(continueMock).not.toHaveBeenCalled();
});
it('continues requests that do not match blocked patterns', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: ['*/api/blocked'],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
let capturedHandler: ((route: unknown) => void) | null = null;
const page = {
...buildMockPage(),
route: jest.fn().mockImplementation((_pattern: unknown, handler: (r: unknown) => void) => {
capturedHandler = handler;
}),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
const fulfillMock = jest.fn();
const continueMock = jest.fn();
const allowedRoute = {
request: () => ({ url: () => 'http://example.com/api/users' }),
fulfill: fulfillMock,
continue: continueMock,
};
capturedHandler!(allowedRoute);
expect(continueMock).toHaveBeenCalled();
expect(fulfillMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,185 @@
/**
* Tests for PerformanceCollector with mocked page.evaluate.
*/
import { PerformanceCollector, DEFAULT_PERF_CONFIG } from '../../src/plugins/collectors/PerformanceCollector';
import type { IAction } from '../../src/core/interfaces';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeAction(): IAction {
return { id: 'a1', type: 'click', timestamp: Date.now(), seed: 42, stateId: 'state1' };
}
function makePage(timing = { ttfb: 100, domContentLoaded: 500, loadComplete: 1000 }, vitals = { lcp: null as number | null, cls: null as number | null, inp: null as number | null }) {
return {
url: () => 'http://localhost:3000/page',
evaluate: jest.fn()
.mockResolvedValueOnce(timing)
.mockResolvedValueOnce(vitals),
};
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('PerformanceCollector — disabled', () => {
it('returns zeroed metrics and no anomalies when disabled', async () => {
const collector = new PerformanceCollector({ enabled: false });
const page = makePage();
const { metrics, anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(0);
expect(metrics.ttfb).toBe(0);
expect(metrics.lcp).toBeNull();
expect(page.evaluate).not.toHaveBeenCalled();
});
});
describe('PerformanceCollector — enabled', () => {
it('captures timing metrics correctly', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = makePage(
{ ttfb: 200, domContentLoaded: 800, loadComplete: 1500 },
{ lcp: null, cls: null, inp: null }
);
const { metrics } = await collector.collect(page as never, 'state1', 'sess1', [makeAction()]);
expect(metrics.ttfb).toBe(200);
expect(metrics.domContentLoaded).toBe(800);
expect(metrics.loadComplete).toBe(1500);
expect(metrics.sessionId).toBe('sess1');
expect(metrics.stateId).toBe('state1');
expect(metrics.url).toBe('http://localhost:3000/page');
});
it('captures Core Web Vitals when available', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = makePage(
{ ttfb: 100, domContentLoaded: 400, loadComplete: 900 },
{ lcp: 1800, cls: 0.05, inp: 120 }
);
const { metrics } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(metrics.lcp).toBe(1800);
expect(metrics.cls).toBe(0.05);
expect(metrics.inp).toBe(120);
});
it('returns no anomalies when all metrics are within thresholds', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
});
const page = makePage(
{ ttfb: 200, domContentLoaded: 500, loadComplete: 1000 },
{ lcp: 1500, cls: 0.05, inp: 100 }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(0);
});
it('detects LCP violation above threshold', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 2500,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
});
const page = makePage(
{ ttfb: 200, domContentLoaded: 600, loadComplete: 1200 },
{ lcp: 5000, cls: 0.01, inp: 50 }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', [makeAction()]);
expect(anomalies).toHaveLength(1);
expect(anomalies[0]!.type).toBe('performance_degradation');
expect(anomalies[0]!.severity).toBe('high');
expect(anomalies[0]!.description).toContain('LCP');
expect(anomalies[0]!.evidence.rawErrors).toEqual(
expect.arrayContaining([expect.stringContaining('LCP')])
);
});
it('detects TTFB violation above threshold', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 800,
});
const page = makePage(
{ ttfb: 2000, domContentLoaded: 2500, loadComplete: 4000 },
{ lcp: null, cls: null, inp: null }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(1);
expect(anomalies[0]!.type).toBe('performance_degradation');
expect(anomalies[0]!.evidence.rawErrors).toEqual(
expect.arrayContaining([expect.stringContaining('TTFB')])
);
});
it('detects CLS violation', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.1,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
});
const page = makePage(
{ ttfb: 200, domContentLoaded: 500, loadComplete: 1000 },
{ lcp: null, cls: 0.35, inp: null }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(1);
const rawErrors = anomalies[0]!.evidence.rawErrors ?? [];
expect(rawErrors.some((e) => e.includes('CLS'))).toBe(true);
});
it('accumulates metrics in getMetrics()', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = makePage(
{ ttfb: 100, domContentLoaded: 400, loadComplete: 800 },
{ lcp: null, cls: null, inp: null }
);
await collector.collect(page as never, 'state1', 'sess1', []);
const page2 = makePage(
{ ttfb: 150, domContentLoaded: 450, loadComplete: 900 },
{ lcp: 2000, cls: null, inp: null }
);
await collector.collect(page2 as never, 'state2', 'sess1', []);
expect(collector.getMetrics()).toHaveLength(2);
});
it('handles page.evaluate failure gracefully', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = {
url: () => 'http://localhost',
evaluate: jest.fn().mockRejectedValue(new Error('page disconnected')),
};
const { metrics, anomalies } = await collector.collect(page as never, 's1', 'sess1', []);
expect(metrics.ttfb).toBe(0);
expect(anomalies).toHaveLength(0);
});
it('uses default thresholds matching DEFAULT_PERF_CONFIG', () => {
expect(DEFAULT_PERF_CONFIG.lcpThresholdMs).toBe(4000);
expect(DEFAULT_PERF_CONFIG.clsThreshold).toBe(0.25);
expect(DEFAULT_PERF_CONFIG.inpThresholdMs).toBe(500);
expect(DEFAULT_PERF_CONFIG.ttfbThresholdMs).toBe(1800);
});
});

View File

@@ -0,0 +1,109 @@
import { PlaywrightReproducer } from '../../src/plugins/reproducers/PlaywrightReproducer';
import { IAction } from '../../src/core/interfaces';
function makeAction(id: string, type: IAction['type'], overrides: Partial<IAction> = {}): IAction {
return {
id,
type,
selector: '#btn',
timestamp: 1000,
seed: 42,
stateId: 's1',
...overrides,
};
}
describe('PlaywrightReproducer', () => {
let reproducer: PlaywrightReproducer;
beforeEach(() => {
reproducer = new PlaywrightReproducer();
});
describe('serialize', () => {
it('serializes an action trace to valid JSON', () => {
const trace = [makeAction('a1', 'click'), makeAction('a2', 'fill', { value: 'test' })];
const json = reproducer.serialize(trace);
expect(() => JSON.parse(json)).not.toThrow();
});
it('round-trips through serialize/deserialize', () => {
const trace = [
makeAction('a1', 'navigate', { url: 'http://localhost/', selector: undefined }),
makeAction('a2', 'click'),
makeAction('a3', 'fill', { value: '' }),
];
const json = reproducer.serialize(trace);
const restored = reproducer.deserialize(json);
expect(restored).toHaveLength(3);
expect(restored[0].id).toBe('a1');
expect(restored[1].type).toBe('click');
expect(restored[2].value).toBe('');
});
it('serializes empty trace', () => {
expect(reproducer.serialize([])).toBe('[]');
});
});
describe('deserialize', () => {
it('throws on invalid JSON', () => {
expect(() => reproducer.deserialize('not-json')).toThrow();
});
it('throws when JSON is not an array', () => {
expect(() => reproducer.deserialize('{"id":"a1"}')).toThrow(
'PlaywrightReproducer.deserialize: expected a JSON array'
);
});
it('preserves all action fields', () => {
const action = makeAction('a1', 'fill', { value: 'hello', selector: 'input#email' });
const restored = reproducer.deserialize(reproducer.serialize([action]));
expect(restored[0]).toEqual(action);
});
});
describe('generateScript', () => {
it('generates a non-empty string', () => {
const trace = [makeAction('a1', 'click')];
const script = reproducer.generateScript(trace);
expect(typeof script).toBe('string');
expect(script.length).toBeGreaterThan(0);
});
it('includes playwright require', () => {
const script = reproducer.generateScript([makeAction('a1', 'click')]);
expect(script).toContain("require('playwright')");
});
it('generates navigate step', () => {
const action = makeAction('a1', 'navigate', { url: 'http://localhost:3000', selector: undefined });
const script = reproducer.generateScript([action]);
expect(script).toContain('page.goto');
expect(script).toContain('http://localhost:3000');
});
it('generates click step', () => {
const script = reproducer.generateScript([makeAction('a1', 'click', { selector: '#submit' })]);
expect(script).toContain('click()');
expect(script).toContain('#submit');
});
it('generates fill step with value', () => {
const script = reproducer.generateScript([makeAction('a1', 'fill', { selector: '#email', value: '' })]);
expect(script).toContain('fill');
expect(script).toContain('#email');
});
it('includes seed comment for reproducibility', () => {
const script = reproducer.generateScript([makeAction('a1', 'click')]);
expect(script).toContain('seed=42');
});
it('generates empty script for empty trace', () => {
const script = reproducer.generateScript([]);
expect(script).toContain('browser.close');
});
});
});

View File

@@ -0,0 +1,128 @@
/**
* Tests for VisualBaselineRepository and VisualRegressionCollector.
*/
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/db/migrations';
import { VisualBaselineRepository } from '../../src/db/VisualBaselineRepository';
import { VisualRegressionCollector } from '../../src/plugins/collectors/VisualRegressionCollector';
import { IState } from '../../src/core/interfaces';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
function makeDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
return db;
}
function makeState(id = 'state1'): IState {
return {
id,
url: 'http://test.com/page',
title: 'Test Page',
timestamp: Date.now(),
domSnapshot: '<body></body>',
visitCount: 1,
};
}
describe('VisualBaselineRepository', () => {
let db: Database.Database;
let repo: VisualBaselineRepository;
beforeEach(() => {
db = makeDb();
repo = new VisualBaselineRepository(db);
});
afterEach(() => db.close());
it('creates and finds a baseline by state id', () => {
repo.createBaseline({
id: 'b1',
stateId: 'state1',
url: 'http://test.com',
screenshotPath: '/tmp/screenshot.png',
width: 1280,
height: 720,
});
const found = repo.findBaselineByStateId('state1');
expect(found).toBeDefined();
expect(found!.id).toBe('b1');
expect(found!.approved_by).toBe('user');
});
it('returns undefined for unknown state', () => {
expect(repo.findBaselineByStateId('unknown')).toBeUndefined();
});
it('creates and finds a comparison', () => {
repo.createComparison({
id: 'cmp1',
sessionId: 'sess1',
stateId: 'state1',
currentScreenshotPath: '/tmp/current.png',
status: 'new_state',
});
const found = repo.findComparisonById('cmp1');
expect(found).toBeDefined();
expect(found!.status).toBe('new_state');
expect(found!.baseline_id).toBeNull();
});
it('findComparisons with status filter', () => {
repo.createComparison({ id: 'c1', sessionId: 's1', stateId: 'st1', currentScreenshotPath: '/a.png', status: 'new_state' });
repo.createComparison({ id: 'c2', sessionId: 's1', stateId: 'st2', currentScreenshotPath: '/b.png', status: 'passed' });
const pending = repo.findComparisons({ status: 'new_state' });
expect(pending).toHaveLength(1);
expect(pending[0]!.id).toBe('c1');
});
it('updateComparisonStatus changes status', () => {
repo.createComparison({ id: 'c1', sessionId: 's1', stateId: 'st1', currentScreenshotPath: '/a.png', status: 'pending' });
repo.updateComparisonStatus('c1', 'passed');
expect(repo.findComparisonById('c1')!.status).toBe('passed');
});
it('promoteToBaseline creates baseline and updates comparison status', () => {
repo.createComparison({ id: 'cmp1', sessionId: 's1', stateId: 'state1', currentScreenshotPath: '/current.png', status: 'new_state' });
const baselineId = repo.promoteToBaseline('cmp1');
expect(baselineId).toBeTruthy();
expect(repo.findComparisonById('cmp1')!.status).toBe('passed');
});
});
describe('VisualRegressionCollector', () => {
let db: Database.Database;
let repo: VisualBaselineRepository;
let tmpDir: string;
beforeEach(() => {
db = makeDb();
repo = new VisualBaselineRepository(db);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-visual-test-'));
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns null when disabled', async () => {
const collector = new VisualRegressionCollector(tmpDir, repo, { enabled: false });
const result = await collector.processScreenshot('/fake.png', makeState(), 'sess1', []);
expect(result).toBeNull();
});
it('creates new_state comparison when no baseline exists', async () => {
const collector = new VisualRegressionCollector(tmpDir, repo);
const result = await collector.processScreenshot('/fake.png', makeState(), 'sess1', []);
expect(result).toBeNull();
const comparisons = repo.findComparisons({ sessionId: 'sess1' });
expect(comparisons).toHaveLength(1);
expect(comparisons[0]!.status).toBe('new_state');
});
});

View File

@@ -0,0 +1,187 @@
/**
* Tests for AI providers with mocked HTTP calls.
*/
import { ClaudeProvider } from '../../src/server/enrichment/ClaudeProvider';
import { OpenAIProvider } from '../../src/server/enrichment/OpenAIProvider';
import { OllamaProvider } from '../../src/server/enrichment/OllamaProvider';
import type { IAnomaly, IEnrichmentContext } from '../../src/core/interfaces';
function makeAnomaly(): IAnomaly {
return {
id: 'anom_test',
type: 'http_error',
severity: 'high',
observationId: 'obs_1',
actionTrace: [],
description: 'HTTP 500 error on /api/users',
evidence: { rawErrors: ['GET /api/users → 500'] },
timestamp: Date.now(),
};
}
function makeContext(): IEnrichmentContext {
return {
domSnapshot: '<body>Error</body>',
httpLog: [{ url: '/api/users', status: 500, method: 'GET', durationMs: 120 }],
consoleErrors: ['Uncaught TypeError: Cannot read properties of undefined'],
actionTrace: [],
pageTitle: 'Dashboard',
url: 'http://localhost:3000/dashboard',
};
}
const VALID_ENRICHMENT_JSON = JSON.stringify({
rootCause: 'Null pointer in user data fetch',
userImpact: 'Users cannot load the dashboard',
suggestedFix: 'Add null check before accessing user.name',
confidence: 'high',
});
// ─── ClaudeProvider ───────────────────────────────────────────────────────────
describe('ClaudeProvider', () => {
const origFetch = global.fetch;
afterEach(() => {
global.fetch = origFetch;
});
it('enriches anomaly with valid response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
content: [{ type: 'text', text: VALID_ENRICHMENT_JSON }],
}),
}) as unknown as typeof fetch;
const provider = new ClaudeProvider('test-api-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBe('Null pointer in user data fetch');
expect(result.userImpact).toBe('Users cannot load the dashboard');
expect(result.confidence).toBe('high');
expect(result.provider).toBe('claude');
expect(result.generatedAt).toBeLessThanOrEqual(Date.now());
});
it('throws on non-OK API response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
}) as unknown as typeof fetch;
const provider = new ClaudeProvider('bad-key');
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('Anthropic API error: 401');
});
it('falls back gracefully on non-JSON response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
content: [{ type: 'text', text: 'Sorry, I cannot help with that.' }],
}),
}) as unknown as typeof fetch;
const provider = new ClaudeProvider('test-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBeTruthy();
expect(result.provider).toBe('claude');
expect(result.confidence).toBe('low');
});
});
// ─── OpenAIProvider ───────────────────────────────────────────────────────────
describe('OpenAIProvider', () => {
const origFetch = global.fetch;
afterEach(() => {
global.fetch = origFetch;
});
it('enriches anomaly with valid response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: VALID_ENRICHMENT_JSON } }],
}),
}) as unknown as typeof fetch;
const provider = new OpenAIProvider('test-openai-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBe('Null pointer in user data fetch');
expect(result.confidence).toBe('high');
expect(result.provider).toBe('openai');
});
it('throws on non-OK API response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 429,
text: () => Promise.resolve('Rate limit exceeded'),
}) as unknown as typeof fetch;
const provider = new OpenAIProvider('test-key');
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('OpenAI API error: 429');
});
it('falls back on missing choices', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ choices: [] }),
}) as unknown as typeof fetch;
const provider = new OpenAIProvider('test-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.provider).toBe('openai');
});
});
// ─── OllamaProvider ───────────────────────────────────────────────────────────
describe('OllamaProvider', () => {
const origFetch = global.fetch;
afterEach(() => {
global.fetch = origFetch;
});
it('enriches anomaly with valid response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ response: VALID_ENRICHMENT_JSON }),
}) as unknown as typeof fetch;
const provider = new OllamaProvider('http://localhost:11434');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBe('Null pointer in user data fetch');
expect(result.provider).toBe('ollama');
});
it('throws on non-OK Ollama response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 503,
}) as unknown as typeof fetch;
const provider = new OllamaProvider();
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('Ollama API error: 503');
});
it('falls back on non-JSON response text', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ response: 'No valid JSON here' }),
}) as unknown as typeof fetch;
const provider = new OllamaProvider();
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.provider).toBe('ollama');
expect(result.rootCause).toBeTruthy();
});
});

75
tests/server/auth.test.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Tests for API key authentication middleware and security features.
*/
import request from 'supertest';
import { createApp } from '../../src/server/index';
import { SessionStore } from '../../src/server/SessionStore';
function makeApp(apiKey?: string) {
if (apiKey !== undefined) {
process.env['ABE_API_KEY'] = apiKey;
} else {
delete process.env['ABE_API_KEY'];
}
const store = new SessionStore('./reports');
return createApp(store);
}
afterEach(() => {
delete process.env['ABE_API_KEY'];
});
describe('Auth middleware', () => {
it('allows all requests in dev mode (no ABE_API_KEY set)', async () => {
const app = makeApp(undefined);
const res = await request(app).get('/api/sessions');
expect(res.status).not.toBe(401);
});
it('returns 401 when API key is missing', async () => {
const app = makeApp('my-secret-key');
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(401);
});
it('returns 401 when wrong API key provided', async () => {
const app = makeApp('my-secret-key');
const res = await request(app).get('/api/sessions').set('x-abe-api-key', 'wrong-key');
expect(res.status).toBe(401);
});
it('allows request with correct API key', async () => {
const app = makeApp('my-secret-key');
const res = await request(app).get('/api/sessions').set('x-abe-api-key', 'my-secret-key');
expect(res.status).toBe(200);
});
});
describe('Health endpoints (no auth)', () => {
it('GET /health requires no auth', async () => {
const app = makeApp('super-secret');
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('GET /ready requires no auth', async () => {
const app = makeApp('super-secret');
const res = await request(app).get('/ready');
expect(res.status).toBe(200);
});
});
describe('Concurrent session limit', () => {
it('returns 429 when limit exceeded', async () => {
delete process.env['ABE_API_KEY'];
const store = new SessionStore('./reports', undefined, undefined, 0);
const app = createApp(store);
const res = await request(app)
.post('/api/sessions')
.send({ url: 'http://localhost:3000' });
expect(res.status).toBe(429);
expect(res.body.error).toMatch(/Max concurrent/i);
});
});

91
tests/server/cli.test.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Tests for CLI flag parsing and exit code logic.
* These test the logic functions extracted from the CLI, not the CLI process itself.
*/
describe('CLI exit code logic', () => {
const severityRank: Record<string, number> = { low: 0, medium: 1, high: 2, critical: 3 };
function shouldFailOnSeverity(
anomalies: Array<{ severity: string }>,
threshold: string
): boolean {
const thresholdRank = severityRank[threshold] ?? 0;
return anomalies.some((a) => (severityRank[a.severity] ?? 0) >= thresholdRank);
}
it('exit 0 when no anomalies', () => {
expect(shouldFailOnSeverity([], 'high')).toBe(false);
});
it('exit 1 when anomaly at threshold', () => {
expect(shouldFailOnSeverity([{ severity: 'high' }], 'high')).toBe(true);
});
it('exit 1 when anomaly above threshold', () => {
expect(shouldFailOnSeverity([{ severity: 'critical' }], 'high')).toBe(true);
});
it('exit 0 when anomaly below threshold', () => {
expect(shouldFailOnSeverity([{ severity: 'low' }], 'high')).toBe(false);
});
it('exit 0 when medium anomaly and threshold is high', () => {
expect(shouldFailOnSeverity([{ severity: 'medium' }], 'high')).toBe(false);
});
});
describe('JUnit XML generation', () => {
function buildJunit(
anomalies: Array<{ id: string; type: string; severity: string; description: string }>,
url: string
): string {
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const cases = 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="${anomalies.length}" failures="${anomalies.length}">\n` +
cases + '\n' +
`</testsuite>\n`
);
}
it('generates valid XML with one anomaly', () => {
const xml = buildJunit(
[{ id: 'a1', type: 'http_error', severity: 'high', description: 'Server error on form' }],
'http://localhost:3000'
);
expect(xml).toContain('<testsuite');
expect(xml).toContain('<failure');
expect(xml).toContain('http_error');
expect(xml).toContain('Server error on form');
});
it('generates empty testsuite for no anomalies', () => {
const xml = buildJunit([], 'http://localhost:3000');
expect(xml).toContain('tests="0"');
expect(xml).not.toContain('<failure');
});
it('escapes XML special characters', () => {
const xml = buildJunit(
[{ id: 'a1', type: 'http_error', severity: 'high', description: '<script>alert(1)</script>' }],
'http://app.com'
);
expect(xml).not.toContain('<script>');
expect(xml).toContain('&lt;script&gt;');
});
});

View File

@@ -0,0 +1,131 @@
/**
* Unit tests for SlackNotifier, WebhookNotifier, and NotificationService.
* HTTP calls are mocked via jest.spyOn on global fetch.
*/
import { SlackNotifier } from '../../src/server/notifications/SlackNotifier';
import { WebhookNotifier } from '../../src/server/notifications/WebhookNotifier';
import { NotificationService } from '../../src/server/notifications/NotificationService';
import { IAnomaly } from '../../src/core/interfaces';
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
return {
id: 'anom_1',
type: 'http_error',
severity: 'high',
observationId: 'obs_1',
actionTrace: [],
description: 'Test anomaly',
evidence: {},
timestamp: 1000000,
...overrides,
};
}
function mockFetch(ok: boolean, text = 'ok') {
return jest.spyOn(global, 'fetch').mockResolvedValue({
ok,
status: ok ? 200 : 500,
text: () => Promise.resolve(text),
} as Response);
}
afterEach(() => {
jest.restoreAllMocks();
});
// ─── SlackNotifier ────────────────────────────────────────────────────────────
describe('SlackNotifier', () => {
it('sends a POST to the Slack webhook URL', async () => {
const spy = mockFetch(true);
const notifier = new SlackNotifier('https://hooks.slack.com/test');
await notifier.send(makeAnomaly(), 'sess_1', 'http://app.com');
expect(spy).toHaveBeenCalledTimes(1);
const [url, opts] = spy.mock.calls[0]!;
expect(url).toBe('https://hooks.slack.com/test');
expect((opts as RequestInit).method).toBe('POST');
const body = JSON.parse((opts as RequestInit).body as string);
expect(body.blocks).toBeDefined();
});
it('throws when Slack returns non-200', async () => {
mockFetch(false, 'channel_not_found');
const notifier = new SlackNotifier('https://hooks.slack.com/test');
await expect(notifier.send(makeAnomaly(), 'sess_1', 'http://app.com')).rejects.toThrow('500');
});
});
// ─── WebhookNotifier ─────────────────────────────────────────────────────────
describe('WebhookNotifier', () => {
it('sends a POST with anomaly JSON to webhook URL', async () => {
const spy = mockFetch(true);
const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe');
await notifier.send(makeAnomaly());
expect(spy).toHaveBeenCalledTimes(1);
const [url, opts] = spy.mock.calls[0]!;
expect(url).toBe('https://myapp.com/webhooks/abe');
expect((opts as RequestInit).method).toBe('POST');
const body = JSON.parse((opts as RequestInit).body as string);
expect(body.id).toBe('anom_1');
});
it('throws when webhook returns non-200', async () => {
mockFetch(false, 'bad gateway');
const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe');
await expect(notifier.send(makeAnomaly())).rejects.toThrow('500');
});
});
// ─── NotificationService ─────────────────────────────────────────────────────
describe('NotificationService', () => {
it('skips notifications below minSeverity', async () => {
const spy = mockFetch(true);
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'high',
});
await service.notify(makeAnomaly({ severity: 'low' }), 'sess_1', 'http://app.com');
expect(spy).not.toHaveBeenCalled();
});
it('sends notification at or above minSeverity', async () => {
const spy = mockFetch(true);
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'high',
});
await service.notify(makeAnomaly({ severity: 'high' }), 'sess_1', 'http://app.com');
expect(spy).toHaveBeenCalledTimes(1);
});
it('calls persister with success record on successful send', async () => {
mockFetch(true);
const persisted: unknown[] = [];
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'low',
persister: (r) => persisted.push(r),
});
await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com');
expect(persisted).toHaveLength(1);
expect((persisted[0] as { status: string }).status).toBe('success');
});
it('calls persister with failed record on send failure', async () => {
mockFetch(false, 'error');
jest.useFakeTimers();
const persisted: unknown[] = [];
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'low',
persister: (r) => persisted.push(r),
});
await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com');
expect(persisted).toHaveLength(1);
expect((persisted[0] as { status: string }).status).toBe('failed');
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,151 @@
/**
* Tests for SchedulerService: cron parsing, schedule registration, skip when session active.
*/
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/db/migrations';
import { ScheduleRepository } from '../../src/db/ScheduleRepository';
import { SchedulerService } from '../../src/server/scheduler/SchedulerService';
import { SessionStore } from '../../src/server/SessionStore';
function makeDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
return db;
}
describe('SchedulerService', () => {
let db: Database.Database;
let repo: ScheduleRepository;
let store: SessionStore;
let scheduler: SchedulerService;
beforeEach(() => {
db = makeDb();
repo = new ScheduleRepository(db);
store = new SessionStore('./reports');
scheduler = new SchedulerService(repo, store);
});
afterEach(() => {
scheduler.stop();
db.close();
});
it('starts without error when no schedules exist', () => {
expect(() => scheduler.start()).not.toThrow();
});
it('registers a valid cron job', () => {
repo.create({
id: 'sched_1',
name: 'Daily check',
url: 'http://example.com',
configJson: '{}',
cronExpression: '0 2 * * *',
enabled: true,
});
expect(() => scheduler.register(repo.findById('sched_1')!)).not.toThrow();
});
it('skips invalid cron expressions', () => {
repo.create({
id: 'sched_bad',
name: 'Bad',
url: 'http://x.com',
configJson: '{}',
cronExpression: 'not-valid-cron',
enabled: true,
});
expect(() => scheduler.register(repo.findById('sched_bad')!)).not.toThrow();
});
it('unregisters a schedule job', () => {
repo.create({
id: 'sched_2',
name: 'Test',
url: 'http://example.com',
configJson: '{}',
cronExpression: '0 * * * *',
enabled: true,
});
scheduler.register(repo.findById('sched_2')!);
expect(() => scheduler.unregister('sched_2')).not.toThrow();
});
it('computeNextRunAt returns a future timestamp for valid cron', () => {
const nextRun = SchedulerService.computeNextRunAt('0 2 * * *');
expect(nextRun).not.toBeNull();
expect(nextRun!).toBeGreaterThan(Date.now());
});
it('computeNextRunAt returns null for invalid cron', () => {
expect(SchedulerService.computeNextRunAt('not-valid')).toBeNull();
});
it('does not register job for disabled schedule', () => {
repo.create({
id: 'sched_3',
name: 'Disabled',
url: 'http://example.com',
configJson: '{}',
cronExpression: '0 * * * *',
enabled: false,
});
// Should not throw and job should not be registered
expect(() => scheduler.register(repo.findById('sched_3')!)).not.toThrow();
// Unregistering a non-existing job is also safe
expect(() => scheduler.unregister('sched_3')).not.toThrow();
});
});
describe('ScheduleRepository', () => {
let db: Database.Database;
let repo: ScheduleRepository;
beforeEach(() => {
db = makeDb();
repo = new ScheduleRepository(db);
});
afterEach(() => db.close());
it('creates and retrieves a schedule', () => {
repo.create({
id: 's1',
name: 'Daily',
url: 'http://test.com',
configJson: '{"maxStates":10}',
cronExpression: '0 2 * * *',
});
const record = repo.findById('s1');
expect(record).toBeDefined();
expect(record!.name).toBe('Daily');
expect(record!.enabled).toBe(true);
});
it('findAll returns all schedules', () => {
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
repo.create({ id: 's2', name: 'B', url: 'http://b.com', configJson: '{}', cronExpression: '0 2 * * *' });
expect(repo.findAll()).toHaveLength(2);
});
it('findAll(true) returns only enabled schedules', () => {
repo.create({ id: 's1', name: 'Enabled', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *', enabled: true });
repo.create({ id: 's2', name: 'Disabled', url: 'http://b.com', configJson: '{}', cronExpression: '0 * * * *', enabled: false });
expect(repo.findAll(true)).toHaveLength(1);
});
it('updates enabled field', () => {
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
repo.update('s1', { enabled: false });
expect(repo.findById('s1')!.enabled).toBe(false);
});
it('deletes a schedule', () => {
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
repo.delete('s1');
expect(repo.findById('s1')).toBeUndefined();
});
});

224
tests/server/server.test.ts Normal file
View File

@@ -0,0 +1,224 @@
/**
* Integration tests for the ABE API server.
* Uses supertest to hit the Express app directly (no real browser).
* The SessionStore is given a mock that never calls Playwright.
*/
import request from 'supertest';
import { createApp } from '../../src/server/index';
import { SessionStore, SessionRecord } from '../../src/server/SessionStore';
import { IAnomaly } from '../../src/core/interfaces';
// ─── Mock SessionStore ────────────────────────────────────────────────────────
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
return {
id: 'anom_test1',
type: 'http_error',
severity: 'high',
observationId: 'obs_1',
actionTrace: [],
description: 'HTTP 500 on form submit',
evidence: {},
timestamp: 1000000,
...overrides,
};
}
function makeSession(overrides: Partial<SessionRecord> = {}): SessionRecord {
return {
sessionId: 'sess_1',
url: 'http://localhost:3000',
seed: 42,
maxStates: 50,
status: 'running',
startedAt: '2026-01-01T00:00:00.000Z',
statesVisited: 5,
anomaliesFound: 1,
anomalies: [makeAnomaly()],
...overrides,
};
}
class MockSessionStore extends SessionStore {
private _sessions: SessionRecord[];
constructor(sessions: SessionRecord[] = []) {
super('./reports');
this._sessions = sessions;
}
getAllSessions() { return this._sessions; }
getSession(id: string) { return this._sessions.find((s) => s.sessionId === id); }
getAllAnomalies(sessionId?: string, severity?: string) {
return this._sessions
.flatMap((s) => s.anomalies)
.filter((a) => !sessionId || this.findSessionForAnomaly(a.id) === sessionId)
.filter((a) => !severity || a.severity === severity);
}
getAnomaly(id: string) {
return this._sessions.flatMap((s) => s.anomalies).find((a) => a.id === id);
}
findSessionForAnomaly(anomalyId: string) {
return this._sessions.find((s) => s.anomalies.some((a) => a.id === anomalyId))?.sessionId;
}
screenshotPath(_anomalyId: string) { return undefined; }
stopSession(id: string) {
const s = this.getSession(id);
if (!s || s.status !== 'running') return false;
s.status = 'stopped';
return true;
}
async startSession(params: { url: string; seed: number; maxStates: number }) {
const record = makeSession({
sessionId: `sess_new`,
url: params.url,
seed: params.seed,
maxStates: params.maxStates,
status: 'running',
anomalies: [],
anomaliesFound: 0,
});
this._sessions.push(record);
return record;
}
async replayAnomaly(anomalyId: string) {
if (!this.getAnomaly(anomalyId)) throw new Error('Anomaly not found');
return `replay_${anomalyId}`;
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('GET /api/sessions', () => {
it('returns empty array when no sessions', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returns list of sessions', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].sessionId).toBe('sess_1');
expect(res.body[0].status).toBe('running');
});
});
describe('GET /api/sessions/:sessionId', () => {
it('returns session detail', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/sessions/sess_1');
expect(res.status).toBe(200);
expect(res.body.sessionId).toBe('sess_1');
expect(res.body.seed).toBe(42);
});
it('returns 404 for unknown session', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).get('/api/sessions/no_such');
expect(res.status).toBe(404);
});
});
describe('POST /api/sessions', () => {
it('creates a new session', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app)
.post('/api/sessions')
.send({ url: 'http://localhost:3000', seed: 1, maxStates: 10 });
expect(res.status).toBe(201);
expect(res.body.sessionId).toBeDefined();
expect(res.body.status).toBe('running');
});
it('returns 400 when url is missing', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).post('/api/sessions').send({ seed: 1 });
expect(res.status).toBe(400);
});
});
describe('DELETE /api/sessions/:sessionId', () => {
it('stops a running session', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).delete('/api/sessions/sess_1');
expect(res.status).toBe(200);
expect(res.body.stopped).toBe(true);
});
it('returns 404 for unknown session', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).delete('/api/sessions/no_such');
expect(res.status).toBe(404);
});
});
describe('GET /api/anomalies', () => {
it('returns all anomalies', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/anomalies');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe('anom_test1');
expect(res.body[0].severity).toBe('high');
});
it('filters by severity', async () => {
const session = makeSession({
anomalies: [
makeAnomaly({ id: 'anom_h', severity: 'high' }),
makeAnomaly({ id: 'anom_l', severity: 'low' }),
],
anomaliesFound: 2,
});
const app = createApp(new MockSessionStore([session]));
const res = await request(app).get('/api/anomalies?severity=low');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe('anom_l');
});
});
describe('GET /api/anomalies/:anomalyId', () => {
it('returns anomaly detail', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/anomalies/anom_test1');
expect(res.status).toBe(200);
expect(res.body.type).toBe('http_error');
expect(res.body.description).toBe('HTTP 500 on form submit');
});
it('returns 404 for unknown anomaly', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).get('/api/anomalies/no_such');
expect(res.status).toBe(404);
});
});
describe('GET /api/anomalies/:anomalyId/screenshot', () => {
it('returns 404 when no screenshot exists', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/anomalies/anom_test1/screenshot');
expect(res.status).toBe(404);
});
});
describe('POST /api/anomalies/:anomalyId/replay', () => {
it('returns replayId and running status', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).post('/api/anomalies/anom_test1/replay');
expect(res.status).toBe(200);
expect(res.body.replayId).toBeDefined();
expect(res.body.status).toBe('running');
});
it('returns 404 for unknown anomaly', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).post('/api/anomalies/no_such/replay');
expect(res.status).toBe(404);
});
});