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 type { SQLiteDatabase } from './store';
|
||||
|
||||
// Export logger
|
||||
export { ExecutionLogger } from './ExecutionLogger';
|
||||
export type { LoggerConfig, StartExecutionParams, StartNodeParams } from './ExecutionLogger';
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
// 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