docs: enterprise refactor plan with ralph specs
This commit is contained in:
147
tests/core/AnomalyDetector.test.ts
Normal file
147
tests/core/AnomalyDetector.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
196
tests/core/ExplorationEngine.test.ts
Normal file
196
tests/core/ExplorationEngine.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
136
tests/core/StateGraph.test.ts
Normal file
136
tests/core/StateGraph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
140
tests/db/repositories.test.ts
Normal file
140
tests/db/repositories.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
197
tests/plugins/accessibility.test.ts
Normal file
197
tests/plugins/accessibility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
143
tests/plugins/agents/PlaywrightAgent.test.ts
Normal file
143
tests/plugins/agents/PlaywrightAgent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
102
tests/plugins/collectors.test.ts
Normal file
102
tests/plugins/collectors.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
108
tests/plugins/explorationConfig.test.ts
Normal file
108
tests/plugins/explorationConfig.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
111
tests/plugins/exporters/JSONExporter.test.ts
Normal file
111
tests/plugins/exporters/JSONExporter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
tests/plugins/exporters/MarkdownExporter.test.ts
Normal file
133
tests/plugins/exporters/MarkdownExporter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
246
tests/plugins/fuzzers.test.ts
Normal file
246
tests/plugins/fuzzers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
289
tests/plugins/networkChaos.test.ts
Normal file
289
tests/plugins/networkChaos.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
185
tests/plugins/performanceCollector.test.ts
Normal file
185
tests/plugins/performanceCollector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
109
tests/plugins/reproducers.test.ts
Normal file
109
tests/plugins/reproducers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
128
tests/plugins/visualRegression.test.ts
Normal file
128
tests/plugins/visualRegression.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
187
tests/server/aiProviders.test.ts
Normal file
187
tests/server/aiProviders.test.ts
Normal 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
75
tests/server/auth.test.ts
Normal 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
91
tests/server/cli.test.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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('<script>');
|
||||
});
|
||||
});
|
||||
131
tests/server/notifications.test.ts
Normal file
131
tests/server/notifications.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
151
tests/server/scheduler.test.ts
Normal file
151
tests/server/scheduler.test.ts
Normal 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
224
tests/server/server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user