mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
feat(viewer-cloud): add ExecutionLogger for workflow execution tracking
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.
This commit is contained in:
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* ExecutionLogger - High-level API for logging workflow executions
|
||||||
|
*
|
||||||
|
* Provides lifecycle methods for tracking workflow and node executions.
|
||||||
|
* Wraps ExecutionStore with additional features like:
|
||||||
|
* - Configuration for enabling/disabling capture
|
||||||
|
* - Automatic data truncation
|
||||||
|
* - State management for current execution
|
||||||
|
* - Retention policy enforcement
|
||||||
|
*
|
||||||
|
* @module execution-history/ExecutionLogger
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const logger = new ExecutionLogger(store, { enabled: true });
|
||||||
|
*
|
||||||
|
* // Start a workflow execution
|
||||||
|
* const execId = logger.startExecution({
|
||||||
|
* workflowId: 'wf-123',
|
||||||
|
* workflowName: 'My Workflow',
|
||||||
|
* triggerType: 'webhook',
|
||||||
|
* triggerData: { path: '/api/hook' }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Log node executions
|
||||||
|
* const stepId = logger.startNode({
|
||||||
|
* nodeId: 'node-1',
|
||||||
|
* nodeType: 'noodl.logic.condition',
|
||||||
|
* inputData: { value: true }
|
||||||
|
* });
|
||||||
|
* logger.completeNode(stepId, true, { result: false });
|
||||||
|
*
|
||||||
|
* // Complete the workflow
|
||||||
|
* logger.completeExecution(true);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExecutionStore } from './store';
|
||||||
|
import type { TriggerType } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for ExecutionLogger
|
||||||
|
*/
|
||||||
|
export interface LoggerConfig {
|
||||||
|
/** Whether logging is enabled (default: true) */
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
/** Whether to capture input data for nodes (default: true) */
|
||||||
|
captureInputs: boolean;
|
||||||
|
|
||||||
|
/** Whether to capture output data for nodes (default: true) */
|
||||||
|
captureOutputs: boolean;
|
||||||
|
|
||||||
|
/** Maximum size for JSON data in bytes (default: 100KB) */
|
||||||
|
maxDataSize: number;
|
||||||
|
|
||||||
|
/** Number of days to retain execution records (default: 30) */
|
||||||
|
retentionDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for starting an execution
|
||||||
|
*/
|
||||||
|
export interface StartExecutionParams {
|
||||||
|
/** ID of the workflow component */
|
||||||
|
workflowId: string;
|
||||||
|
|
||||||
|
/** Display name of the workflow */
|
||||||
|
workflowName: string;
|
||||||
|
|
||||||
|
/** Type of trigger that started the workflow */
|
||||||
|
triggerType: TriggerType;
|
||||||
|
|
||||||
|
/** Trigger-specific data */
|
||||||
|
triggerData?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Additional metadata */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for starting a node execution
|
||||||
|
*/
|
||||||
|
export interface StartNodeParams {
|
||||||
|
/** ID of the node in the graph */
|
||||||
|
nodeId: string;
|
||||||
|
|
||||||
|
/** Type of node */
|
||||||
|
nodeType: string;
|
||||||
|
|
||||||
|
/** Display name of the node */
|
||||||
|
nodeName?: string;
|
||||||
|
|
||||||
|
/** Input data received by the node */
|
||||||
|
inputData?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default logger configuration */
|
||||||
|
const DEFAULT_CONFIG: LoggerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
captureInputs: true,
|
||||||
|
captureOutputs: true,
|
||||||
|
maxDataSize: 100_000, // 100KB
|
||||||
|
retentionDays: 30
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExecutionLogger class
|
||||||
|
*
|
||||||
|
* High-level API for logging workflow executions with lifecycle management.
|
||||||
|
*/
|
||||||
|
export class ExecutionLogger {
|
||||||
|
private store: ExecutionStore;
|
||||||
|
private config: LoggerConfig;
|
||||||
|
|
||||||
|
// Current execution state
|
||||||
|
private currentExecutionId: string | null = null;
|
||||||
|
private executionStartTime: number = 0;
|
||||||
|
private stepIndex: number = 0;
|
||||||
|
|
||||||
|
// Track active steps for timing
|
||||||
|
private activeSteps: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an ExecutionLogger
|
||||||
|
*
|
||||||
|
* @param store - ExecutionStore instance
|
||||||
|
* @param config - Optional configuration overrides
|
||||||
|
*/
|
||||||
|
constructor(store: ExecutionStore, config?: Partial<LoggerConfig>) {
|
||||||
|
this.store = store;
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if logging is enabled
|
||||||
|
*/
|
||||||
|
get isEnabled(): boolean {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable logging
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this.config.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update configuration
|
||||||
|
*/
|
||||||
|
updateConfig(updates: Partial<LoggerConfig>): void {
|
||||||
|
this.config = { ...this.config, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current configuration
|
||||||
|
*/
|
||||||
|
getConfig(): Readonly<LoggerConfig> {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// EXECUTION LIFECYCLE
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start logging a workflow execution
|
||||||
|
*
|
||||||
|
* @param params - Execution parameters
|
||||||
|
* @returns Execution ID (empty string if logging is disabled)
|
||||||
|
*/
|
||||||
|
startExecution(params: StartExecutionParams): string {
|
||||||
|
if (!this.config.enabled) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any previous execution state
|
||||||
|
this.resetState();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.executionStartTime = now;
|
||||||
|
|
||||||
|
const executionId = this.store.createExecution({
|
||||||
|
workflowId: params.workflowId,
|
||||||
|
workflowName: params.workflowName,
|
||||||
|
triggerType: params.triggerType,
|
||||||
|
triggerData: this.truncateData(params.triggerData),
|
||||||
|
status: 'running',
|
||||||
|
startedAt: now,
|
||||||
|
metadata: this.truncateData(params.metadata)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentExecutionId = executionId;
|
||||||
|
return executionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the current workflow execution
|
||||||
|
*
|
||||||
|
* @param success - Whether the execution succeeded
|
||||||
|
* @param error - Error if execution failed
|
||||||
|
*/
|
||||||
|
completeExecution(success: boolean, error?: Error): void {
|
||||||
|
if (!this.config.enabled || !this.currentExecutionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const durationMs = now - this.executionStartTime;
|
||||||
|
|
||||||
|
this.store.updateExecution(this.currentExecutionId, {
|
||||||
|
status: success ? 'success' : 'error',
|
||||||
|
completedAt: now,
|
||||||
|
durationMs,
|
||||||
|
errorMessage: error?.message,
|
||||||
|
errorStack: error?.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up state
|
||||||
|
this.resetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current execution ID
|
||||||
|
*/
|
||||||
|
getCurrentExecutionId(): string | null {
|
||||||
|
return this.currentExecutionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's an active execution
|
||||||
|
*/
|
||||||
|
hasActiveExecution(): boolean {
|
||||||
|
return this.currentExecutionId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// NODE LIFECYCLE
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start logging a node execution
|
||||||
|
*
|
||||||
|
* @param params - Node parameters
|
||||||
|
* @returns Step ID (empty string if logging is disabled or no active execution)
|
||||||
|
*/
|
||||||
|
startNode(params: StartNodeParams): string {
|
||||||
|
if (!this.config.enabled || !this.currentExecutionId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const stepId = this.store.addStep({
|
||||||
|
executionId: this.currentExecutionId,
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
nodeType: params.nodeType,
|
||||||
|
nodeName: params.nodeName,
|
||||||
|
stepIndex: this.stepIndex++,
|
||||||
|
startedAt: now,
|
||||||
|
status: 'running',
|
||||||
|
inputData: this.config.captureInputs ? this.truncateData(params.inputData) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track start time for duration calculation
|
||||||
|
this.activeSteps.set(stepId, now);
|
||||||
|
|
||||||
|
return stepId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a node execution
|
||||||
|
*
|
||||||
|
* @param stepId - Step ID returned from startNode
|
||||||
|
* @param success - Whether the node execution succeeded
|
||||||
|
* @param outputData - Output data produced by the node
|
||||||
|
* @param error - Error if node failed
|
||||||
|
*/
|
||||||
|
completeNode(stepId: string, success: boolean, outputData?: Record<string, unknown>, error?: Error): void {
|
||||||
|
if (!this.config.enabled || !stepId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const startTime = this.activeSteps.get(stepId);
|
||||||
|
const durationMs = startTime ? now - startTime : undefined;
|
||||||
|
|
||||||
|
this.store.updateStep(stepId, {
|
||||||
|
status: success ? 'success' : 'error',
|
||||||
|
completedAt: now,
|
||||||
|
durationMs,
|
||||||
|
outputData: this.config.captureOutputs ? this.truncateData(outputData) : undefined,
|
||||||
|
errorMessage: error?.message
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up tracking
|
||||||
|
this.activeSteps.delete(stepId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a node as skipped
|
||||||
|
*
|
||||||
|
* @param params - Node parameters
|
||||||
|
* @returns Step ID
|
||||||
|
*/
|
||||||
|
skipNode(params: StartNodeParams): string {
|
||||||
|
if (!this.config.enabled || !this.currentExecutionId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return this.store.addStep({
|
||||||
|
executionId: this.currentExecutionId,
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
nodeType: params.nodeType,
|
||||||
|
nodeName: params.nodeName,
|
||||||
|
stepIndex: this.stepIndex++,
|
||||||
|
startedAt: now,
|
||||||
|
completedAt: now,
|
||||||
|
durationMs: 0,
|
||||||
|
status: 'skipped'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// UTILITIES
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate data if it exceeds the maximum size
|
||||||
|
*/
|
||||||
|
private truncateData(data?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||||
|
if (data === undefined || data === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
if (json.length <= this.config.maxDataSize) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return truncated indicator
|
||||||
|
return {
|
||||||
|
__truncated: true,
|
||||||
|
__originalSize: json.length,
|
||||||
|
__maxSize: this.config.maxDataSize,
|
||||||
|
__preview: json.substring(0, 1000) + '...'
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
__error: 'Failed to serialize data',
|
||||||
|
__message: e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset internal state
|
||||||
|
*/
|
||||||
|
private resetState(): void {
|
||||||
|
this.currentExecutionId = null;
|
||||||
|
this.executionStartTime = 0;
|
||||||
|
this.stepIndex = 0;
|
||||||
|
this.activeSteps.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// RETENTION
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run retention cleanup based on configured policy
|
||||||
|
*
|
||||||
|
* @returns Number of executions deleted
|
||||||
|
*/
|
||||||
|
runRetentionCleanup(): number {
|
||||||
|
const maxAgeMs = this.config.retentionDays * 24 * 60 * 60 * 1000;
|
||||||
|
return this.store.cleanupByAge(maxAgeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying store for advanced queries
|
||||||
|
*/
|
||||||
|
getStore(): ExecutionStore {
|
||||||
|
return this.store;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,10 @@
|
|||||||
export { ExecutionStore } from './store';
|
export { ExecutionStore } from './store';
|
||||||
export type { SQLiteDatabase } from './store';
|
export type { SQLiteDatabase } from './store';
|
||||||
|
|
||||||
|
// Export logger
|
||||||
|
export { ExecutionLogger } from './ExecutionLogger';
|
||||||
|
export type { LoggerConfig, StartExecutionParams, StartNodeParams } from './ExecutionLogger';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type {
|
export type {
|
||||||
// Status types
|
// Status types
|
||||||
|
|||||||
439
packages/noodl-viewer-cloud/tests/execution-logger.test.ts
Normal file
439
packages/noodl-viewer-cloud/tests/execution-logger.test.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user