mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 10:03:31 +01:00
CF11-005: Execution Logger Integration (Part 1 - Logger Class) - Add ExecutionLogger class wrapping ExecutionStore - Implement execution lifecycle: startExecution, completeExecution - Implement node lifecycle: startNode, completeNode, skipNode - Add configurable settings (enabled, captureInputs, captureOutputs) - Add automatic data truncation for large payloads - Add retention cleanup utility - Add comprehensive unit tests with MockExecutionStore CloudRunner integration (Part 2) deferred until Phase 5 TASK-007C creates the workflow runtime engine.
440 lines
13 KiB
TypeScript
440 lines
13 KiB
TypeScript
/**
|
|
* Unit tests for ExecutionLogger
|
|
*
|
|
* Tests lifecycle methods, configuration, and data handling.
|
|
*/
|
|
|
|
import { ExecutionLogger, ExecutionStore, SQLiteDatabase } from '../src/execution-history';
|
|
|
|
/**
|
|
* Mock ExecutionStore that tracks calls for testing
|
|
*/
|
|
class MockExecutionStore {
|
|
public executions: Map<string, Record<string, unknown>> = new Map();
|
|
public steps: Map<string, Record<string, unknown>> = new Map();
|
|
public calls: Array<{ method: string; args: unknown[] }> = [];
|
|
private executionCounter = 0;
|
|
private stepCounter = 0;
|
|
|
|
createExecution(options: Record<string, unknown>): string {
|
|
const id = `exec_${++this.executionCounter}`;
|
|
this.executions.set(id, { id, ...options });
|
|
this.calls.push({ method: 'createExecution', args: [options] });
|
|
return id;
|
|
}
|
|
|
|
updateExecution(id: string, updates: Record<string, unknown>): void {
|
|
const exec = this.executions.get(id);
|
|
if (exec) {
|
|
Object.assign(exec, updates);
|
|
}
|
|
this.calls.push({ method: 'updateExecution', args: [id, updates] });
|
|
}
|
|
|
|
addStep(options: Record<string, unknown>): string {
|
|
const id = `step_${++this.stepCounter}`;
|
|
this.steps.set(id, { id, ...options });
|
|
this.calls.push({ method: 'addStep', args: [options] });
|
|
return id;
|
|
}
|
|
|
|
updateStep(id: string, updates: Record<string, unknown>): void {
|
|
const step = this.steps.get(id);
|
|
if (step) {
|
|
Object.assign(step, updates);
|
|
}
|
|
this.calls.push({ method: 'updateStep', args: [id, updates] });
|
|
}
|
|
|
|
cleanupByAge(maxAgeMs: number): number {
|
|
this.calls.push({ method: 'cleanupByAge', args: [maxAgeMs] });
|
|
return 0;
|
|
}
|
|
|
|
// Test helpers
|
|
reset(): void {
|
|
this.executions.clear();
|
|
this.steps.clear();
|
|
this.calls = [];
|
|
this.executionCounter = 0;
|
|
this.stepCounter = 0;
|
|
}
|
|
}
|
|
|
|
describe('ExecutionLogger', () => {
|
|
let store: MockExecutionStore;
|
|
let logger: ExecutionLogger;
|
|
|
|
beforeEach(() => {
|
|
store = new MockExecutionStore();
|
|
logger = new ExecutionLogger(store as unknown as ExecutionStore);
|
|
});
|
|
|
|
describe('configuration', () => {
|
|
it('should be enabled by default', () => {
|
|
expect(logger.isEnabled).toBe(true);
|
|
});
|
|
|
|
it('should allow disabling logging', () => {
|
|
logger.setEnabled(false);
|
|
expect(logger.isEnabled).toBe(false);
|
|
});
|
|
|
|
it('should return default config values', () => {
|
|
const config = logger.getConfig();
|
|
expect(config.enabled).toBe(true);
|
|
expect(config.captureInputs).toBe(true);
|
|
expect(config.captureOutputs).toBe(true);
|
|
expect(config.maxDataSize).toBe(100_000);
|
|
expect(config.retentionDays).toBe(30);
|
|
});
|
|
|
|
it('should allow custom config on construction', () => {
|
|
const customLogger = new ExecutionLogger(store as unknown as ExecutionStore, {
|
|
captureInputs: false,
|
|
retentionDays: 7
|
|
});
|
|
const config = customLogger.getConfig();
|
|
expect(config.captureInputs).toBe(false);
|
|
expect(config.retentionDays).toBe(7);
|
|
});
|
|
|
|
it('should allow updating config', () => {
|
|
logger.updateConfig({ maxDataSize: 50_000 });
|
|
expect(logger.getConfig().maxDataSize).toBe(50_000);
|
|
});
|
|
});
|
|
|
|
describe('execution lifecycle', () => {
|
|
it('should create an execution on start', () => {
|
|
const execId = logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test Workflow',
|
|
triggerType: 'manual'
|
|
});
|
|
|
|
expect(execId).toBe('exec_1');
|
|
expect(store.executions.size).toBe(1);
|
|
|
|
const exec = store.executions.get(execId);
|
|
expect(exec?.workflowId).toBe('wf-123');
|
|
expect(exec?.workflowName).toBe('Test Workflow');
|
|
expect(exec?.triggerType).toBe('manual');
|
|
expect(exec?.status).toBe('running');
|
|
});
|
|
|
|
it('should return empty string when disabled', () => {
|
|
logger.setEnabled(false);
|
|
const execId = logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'manual'
|
|
});
|
|
expect(execId).toBe('');
|
|
expect(store.executions.size).toBe(0);
|
|
});
|
|
|
|
it('should complete execution with success', () => {
|
|
const execId = logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'manual'
|
|
});
|
|
|
|
logger.completeExecution(true);
|
|
|
|
const exec = store.executions.get(execId);
|
|
expect(exec?.status).toBe('success');
|
|
expect(exec?.completedAt).toBeDefined();
|
|
expect(exec?.durationMs).toBeDefined();
|
|
});
|
|
|
|
it('should complete execution with error', () => {
|
|
const execId = logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'manual'
|
|
});
|
|
|
|
const error = new Error('Test error');
|
|
logger.completeExecution(false, error);
|
|
|
|
const exec = store.executions.get(execId);
|
|
expect(exec?.status).toBe('error');
|
|
expect(exec?.errorMessage).toBe('Test error');
|
|
});
|
|
|
|
it('should track current execution ID', () => {
|
|
expect(logger.hasActiveExecution()).toBe(false);
|
|
expect(logger.getCurrentExecutionId()).toBeNull();
|
|
|
|
logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'manual'
|
|
});
|
|
|
|
expect(logger.hasActiveExecution()).toBe(true);
|
|
expect(logger.getCurrentExecutionId()).toBe('exec_1');
|
|
|
|
logger.completeExecution(true);
|
|
|
|
expect(logger.hasActiveExecution()).toBe(false);
|
|
expect(logger.getCurrentExecutionId()).toBeNull();
|
|
});
|
|
|
|
it('should store trigger data', () => {
|
|
logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'webhook',
|
|
triggerData: { path: '/api/hook', method: 'POST' }
|
|
});
|
|
|
|
const exec = store.executions.get('exec_1');
|
|
expect(exec?.triggerData).toEqual({ path: '/api/hook', method: 'POST' });
|
|
});
|
|
|
|
it('should store metadata', () => {
|
|
logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'manual',
|
|
metadata: { version: '1.0', user: 'test@example.com' }
|
|
});
|
|
|
|
const exec = store.executions.get('exec_1');
|
|
expect(exec?.metadata).toEqual({ version: '1.0', user: 'test@example.com' });
|
|
});
|
|
});
|
|
|
|
describe('node lifecycle', () => {
|
|
beforeEach(() => {
|
|
logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'manual'
|
|
});
|
|
});
|
|
|
|
it('should create a step on startNode', () => {
|
|
const stepId = logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'noodl.logic.condition',
|
|
nodeName: 'Check Condition'
|
|
});
|
|
|
|
expect(stepId).toBe('step_1');
|
|
expect(store.steps.size).toBe(1);
|
|
|
|
const step = store.steps.get(stepId);
|
|
expect(step?.nodeId).toBe('node-1');
|
|
expect(step?.nodeType).toBe('noodl.logic.condition');
|
|
expect(step?.nodeName).toBe('Check Condition');
|
|
expect(step?.status).toBe('running');
|
|
expect(step?.stepIndex).toBe(0);
|
|
});
|
|
|
|
it('should capture input data when enabled', () => {
|
|
logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test',
|
|
inputData: { value: 42, name: 'test' }
|
|
});
|
|
|
|
const step = store.steps.get('step_1');
|
|
expect(step?.inputData).toEqual({ value: 42, name: 'test' });
|
|
});
|
|
|
|
it('should not capture input data when disabled', () => {
|
|
logger.updateConfig({ captureInputs: false });
|
|
|
|
logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test',
|
|
inputData: { value: 42 }
|
|
});
|
|
|
|
const step = store.steps.get('step_1');
|
|
expect(step?.inputData).toBeUndefined();
|
|
});
|
|
|
|
it('should complete node with success', () => {
|
|
const stepId = logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test'
|
|
});
|
|
|
|
logger.completeNode(stepId, true, { result: 'done' });
|
|
|
|
const step = store.steps.get(stepId);
|
|
expect(step?.status).toBe('success');
|
|
expect(step?.outputData).toEqual({ result: 'done' });
|
|
expect(step?.completedAt).toBeDefined();
|
|
});
|
|
|
|
it('should complete node with error', () => {
|
|
const stepId = logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test'
|
|
});
|
|
|
|
const error = new Error('Node failed');
|
|
logger.completeNode(stepId, false, undefined, error);
|
|
|
|
const step = store.steps.get(stepId);
|
|
expect(step?.status).toBe('error');
|
|
expect(step?.errorMessage).toBe('Node failed');
|
|
});
|
|
|
|
it('should not capture output data when disabled', () => {
|
|
logger.updateConfig({ captureOutputs: false });
|
|
|
|
const stepId = logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test'
|
|
});
|
|
|
|
logger.completeNode(stepId, true, { secret: 'data' });
|
|
|
|
const step = store.steps.get(stepId);
|
|
expect(step?.outputData).toBeUndefined();
|
|
});
|
|
|
|
it('should increment step index for each node', () => {
|
|
logger.startNode({ nodeId: 'node-1', nodeType: 'test' });
|
|
logger.startNode({ nodeId: 'node-2', nodeType: 'test' });
|
|
logger.startNode({ nodeId: 'node-3', nodeType: 'test' });
|
|
|
|
const step1 = store.steps.get('step_1');
|
|
const step2 = store.steps.get('step_2');
|
|
const step3 = store.steps.get('step_3');
|
|
|
|
expect(step1?.stepIndex).toBe(0);
|
|
expect(step2?.stepIndex).toBe(1);
|
|
expect(step3?.stepIndex).toBe(2);
|
|
});
|
|
|
|
it('should return empty string for startNode without active execution', () => {
|
|
logger.completeExecution(true);
|
|
|
|
const stepId = logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test'
|
|
});
|
|
|
|
expect(stepId).toBe('');
|
|
});
|
|
|
|
it('should handle skipNode', () => {
|
|
const stepId = logger.skipNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'noodl.logic.branch',
|
|
nodeName: 'Skip Branch'
|
|
});
|
|
|
|
const step = store.steps.get(stepId);
|
|
expect(step?.status).toBe('skipped');
|
|
expect(step?.durationMs).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('data truncation', () => {
|
|
beforeEach(() => {
|
|
logger.startExecution({
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test',
|
|
triggerType: 'manual'
|
|
});
|
|
});
|
|
|
|
it('should truncate large input data', () => {
|
|
// Set small max size for testing
|
|
logger.updateConfig({ maxDataSize: 100 });
|
|
|
|
const largeData = { bigValue: 'x'.repeat(200) };
|
|
logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test',
|
|
inputData: largeData
|
|
});
|
|
|
|
const step = store.steps.get('step_1');
|
|
expect(step?.inputData?.__truncated).toBe(true);
|
|
expect(step?.inputData?.__originalSize).toBeGreaterThan(100);
|
|
});
|
|
|
|
it('should not truncate small data', () => {
|
|
const smallData = { value: 'small' };
|
|
logger.startNode({
|
|
nodeId: 'node-1',
|
|
nodeType: 'test',
|
|
inputData: smallData
|
|
});
|
|
|
|
const step = store.steps.get('step_1');
|
|
expect(step?.inputData).toEqual(smallData);
|
|
});
|
|
});
|
|
|
|
describe('retention', () => {
|
|
it('should call cleanupByAge with correct value', () => {
|
|
logger.updateConfig({ retentionDays: 7 });
|
|
logger.runRetentionCleanup();
|
|
|
|
expect(store.calls).toContainEqual({
|
|
method: 'cleanupByAge',
|
|
args: [7 * 24 * 60 * 60 * 1000]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getStore', () => {
|
|
it('should return the underlying store', () => {
|
|
const returnedStore = logger.getStore();
|
|
expect(returnedStore).toBe(store);
|
|
});
|
|
});
|
|
|
|
describe('state management', () => {
|
|
it('should reset state between executions', () => {
|
|
// First execution
|
|
logger.startExecution({
|
|
workflowId: 'wf-1',
|
|
workflowName: 'First',
|
|
triggerType: 'manual'
|
|
});
|
|
logger.startNode({ nodeId: 'node-1', nodeType: 'test' });
|
|
logger.startNode({ nodeId: 'node-2', nodeType: 'test' });
|
|
logger.completeExecution(true);
|
|
|
|
// Second execution should start fresh
|
|
logger.startExecution({
|
|
workflowId: 'wf-2',
|
|
workflowName: 'Second',
|
|
triggerType: 'manual'
|
|
});
|
|
logger.startNode({ nodeId: 'node-3', nodeType: 'test' });
|
|
|
|
// Step index should be 0 for new execution
|
|
const step = store.steps.get('step_3');
|
|
expect(step?.stepIndex).toBe(0);
|
|
});
|
|
|
|
it('should handle multiple executions sequentially', () => {
|
|
for (let i = 1; i <= 3; i++) {
|
|
logger.startExecution({
|
|
workflowId: `wf-${i}`,
|
|
workflowName: `Workflow ${i}`,
|
|
triggerType: 'manual'
|
|
});
|
|
logger.startNode({ nodeId: `node-${i}`, nodeType: 'test' });
|
|
logger.completeExecution(true);
|
|
}
|
|
|
|
expect(store.executions.size).toBe(3);
|
|
expect(store.steps.size).toBe(3);
|
|
});
|
|
});
|
|
});
|