docs: enterprise refactor plan with ralph specs

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

View File

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

View File

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

View File

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