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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user