diff --git a/src/sn/flow/FlowManager.ts b/src/sn/flow/FlowManager.ts index 8d3c288..9994e32 100644 --- a/src/sn/flow/FlowManager.ts +++ b/src/sn/flow/FlowManager.ts @@ -24,7 +24,19 @@ import { ProcessFlowApiResponse, ProcessFlowTestPayload, CopyFlowOptions, - FlowCopyResult + FlowCopyResult, + FlowContextDetailsResult, + FlowContextInfo, + FlowExecutionReport, + FlowReportAvailabilityDetails, + FlowContextOperationsResponse, + FlowLogOptions, + FlowLogResult, + FlowLogEntry, + FlowOperationsCore, + FlowOperationsData, + FlowActionReport, + FlowExecutionSource } from './FlowModels'; import { TableAPIRequest } from '../../comm/http/TableAPIRequest'; import { ProcessFlowRequest } from '../../comm/http/ProcessFlowRequest'; @@ -32,6 +44,18 @@ import { ProcessFlowRequest } from '../../comm/http/ProcessFlowRequest'; const RESULT_MARKER = '___FLOW_EXEC_RESULT___'; const VALID_TYPES: FlowObjectType[] = ['flow', 'subflow', 'action']; +/** Shape of a sys_flow_log record as returned by the Table API. */ +interface FlowLogRecord { + sys_id: string; + level: string; + message: string; + action: string; + operation: string; + order: string; + sys_created_on: string; + sys_created_by: string; +} + /** * Provides operations for executing ServiceNow Flow Designer flows, * subflows, and actions remotely via BackgroundScriptExecutor. @@ -645,6 +669,272 @@ export class FlowManager { } } + // ================================================================ + // Flow Context Details API (ProcessFlow Operations) + // ================================================================ + + /** + * Get detailed flow execution context via the ProcessFlow operations API. + * + * Returns rich execution data including per-action timing, inputs, outputs, + * execution metadata (who ran it, test vs production, runtime, etc.), and + * optionally the full flow definition snapshot. + * + * This uses `GET /api/now/processflow/operations/flow/context/{id}` which is + * the same endpoint Flow Designer uses to display execution details. + * + * Note: The execution report (`flowReport`) requires operations view / flow + * logging to be enabled. If not available, `flowReportAvailabilityDetails` + * will contain information about why. + * + * @param contextId The sys_id of the flow context (from sys_flow_context) + * @param scope Optional scope sys_id for the transaction scope query parameter + * @param includeFlowDefinition Whether to include the full flow definition snapshot (default: false) + * @returns FlowContextDetailsResult with execution context and report + */ + public async getFlowContextDetails( + contextId: string, + scope?: string, + includeFlowDefinition: boolean = false + ): Promise { + this._validateContextId(contextId); + this._logger.info(`Getting flow context details: ${contextId}`); + + const pfr = new ProcessFlowRequest(this._instance); + const query = scope ? { sysparm_transaction_scope: scope } : undefined; + + try { + const response = await pfr.get( + 'operations/flow/context/{context_id}', + { context_id: contextId }, + query + ); + + const result = (response as any)?.data?.result ?? (response as any)?.bodyObject?.result; + if (!result) { + return { + success: false, + contextId, + errorMessage: 'Unexpected response structure from processflow operations API: no result field', + rawResponse: response + }; + } + + const flowContext = this._mapFlowContextInfo(result.flowContext); + const flowDef = result.flow as Record | undefined; + const flowReport = this._mapFlowExecutionReport(result.flowReport, flowDef); + const reportAvailability = result.flowReportAvailabilityDetails as FlowReportAvailabilityDetails | undefined; + + this._logger.info(`Flow context details fetched: state=${flowContext?.state ?? 'unknown'}`); + + return { + success: true, + contextId, + flowContext, + flowReport, + flowReportAvailabilityDetails: reportAvailability, + flowDefinition: includeFlowDefinition ? result.flow as Record : undefined, + rawResponse: result + }; + } catch (error) { + const err = error as Error; + this._logger.error(`Error fetching flow context details "${contextId}": ${err.message}`); + return { + success: false, + contextId, + errorMessage: `Failed to fetch flow context details: ${err.message}` + }; + } + } + + // ================================================================ + // Flow Logs API (sys_flow_log Table) + // ================================================================ + + /** + * Retrieve flow execution logs from the sys_flow_log table. + * + * Returns log entries associated with a flow context, including error messages, + * step-level logs, and cancellation reasons. Log entries may be empty for + * simple successful executions. + * + * @param contextId The sys_id of the flow context to get logs for + * @param options Optional query options (limit, order direction) + * @returns FlowLogResult with array of log entries + */ + public async getFlowLogs( + contextId: string, + options?: FlowLogOptions + ): Promise { + this._validateContextId(contextId); + + if (options?.limit !== undefined && (options.limit < 1 || !Number.isInteger(options.limit))) { + throw new Error('Limit must be a positive integer'); + } + + this._logger.info(`Getting flow logs for context: ${contextId}`); + + const limit = options?.limit ?? 100; + const orderDir = options?.orderDirection ?? 'asc'; + const orderBy = orderDir === 'desc' ? 'ORDERBYDESCorder' : 'ORDERBYorder'; + + const tableRequest = new TableAPIRequest(this._instance); + + try { + const response = await tableRequest.get<{ result: FlowLogRecord[] }>('sys_flow_log', { + sysparm_query: `context=${contextId}^${orderBy}`, + sysparm_fields: 'sys_id,level,message,action,operation,order,sys_created_on,sys_created_by', + sysparm_limit: String(limit) + }); + + const records: FlowLogRecord[] = (response as any)?.data?.result ?? (response as any)?.bodyObject?.result ?? []; + + const entries: FlowLogEntry[] = records.map((r) => ({ + sysId: r.sys_id, + level: r.level, + message: r.message, + action: r.action, + operation: r.operation, + order: r.order, + createdOn: r.sys_created_on, + createdBy: r.sys_created_by + })); + + this._logger.info(`Flow logs fetched: ${entries.length} entries`); + + return { + success: true, + contextId, + entries, + rawResponse: records + }; + } catch (error) { + const err = error as Error; + this._logger.error(`Error fetching flow logs for "${contextId}": ${err.message}`); + return { + success: false, + contextId, + entries: [], + errorMessage: `Failed to fetch flow logs: ${err.message}` + }; + } + } + + /** + * Map the raw flowContext object from the operations API to a typed FlowContextInfo. + * @internal + */ + private _mapFlowContextInfo(raw: Record | undefined): FlowContextInfo | undefined { + if (!raw) return undefined; + + const execSource = raw.executionSource as Record | undefined; + + return { + flowId: String(raw.flowId ?? ''), + name: String(raw.name ?? ''), + state: String(raw.state ?? '') as FlowContextInfo['state'], + runTime: String(raw.runTime ?? ''), + isTestRun: raw.isTestRun === true, + executedAs: String(raw.executedAs ?? ''), + flowInitiatedBy: String(raw.flowInitiatedBy ?? ''), + reporting: String(raw.reporting ?? ''), + debugMode: raw.debugMode === true, + executionSource: { + callingSource: execSource?.callingSource ?? '', + executionSourceTable: execSource?.executionSourceTable ?? '', + executionSourceRecord: execSource?.executionSourceRecord ?? '', + executionSourceRecordDisplay: execSource?.executionSourceRecordDisplay ?? '' + }, + enableOpsViewExpansion: raw.enableOpsViewExpansion === true, + flowRetentionPolicyCandidate: raw.flowRetentionPolicyCandidate === true + }; + } + + /** + * Map the raw flowReport object from the operations API to a typed FlowExecutionReport. + * Cross-references with the flow definition to resolve human-readable step names. + * @internal + */ + private _mapFlowExecutionReport( + raw: Record | undefined, + flowDef?: Record + ): FlowExecutionReport | undefined { + if (!raw) return undefined; + + // Build a lookup from action instance ID → { actionTypeName, comment } + const actionLookup = this._buildActionInstanceLookup(flowDef); + + const mapActionReports = (reports: Record> | undefined): Record => { + if (!reports) return {}; + const result: Record = {}; + for (const [key, report] of Object.entries(reports)) { + const lookup = actionLookup.get(key); + const actionTypeName = lookup?.actionTypeName; + const stepComment = lookup?.comment; + const stepLabel = actionTypeName + ? (stepComment ? `${actionTypeName} (${stepComment})` : actionTypeName) + : (stepComment || undefined); + + result[key] = { + fStepCount: String(report.fStepCount ?? ''), + actionName: String(report.actionName ?? ''), + instanceReference: String(report.instanceReference ?? ''), + stepLabel, + actionTypeName, + stepComment, + operationsCore: (report.operationsCore ?? { error: '', state: '', startTime: '', order: '', runTime: '' }) as FlowOperationsCore, + relatedLinks: (report.relatedLinks ?? {}) as Record, + operationsOutput: (report.operationsOutput ?? { data: {} }) as FlowOperationsData, + operationsInput: (report.operationsInput ?? { data: {} }) as FlowOperationsData, + reportId: String(report.reportId ?? '') + }; + } + return result; + }; + + return { + flowId: String(raw.flowId ?? ''), + domainSeparationEnabled: raw.domainSeparationEnabled === true, + executionDomain: String(raw.executionDomain ?? ''), + actionOperationsReports: mapActionReports(raw.actionOperationsReports as Record>), + subflowOperationsReports: mapActionReports(raw.subflowOperationsReports as Record>), + iterationOperationsReports: (raw.iterationOperationsReports ?? {}) as Record, + instanceReference: String(raw.instanceReference ?? ''), + operationsCore: (raw.operationsCore ?? { error: '', state: '', startTime: '', order: '', runTime: '' }) as FlowOperationsCore, + relatedLinks: (raw.relatedLinks ?? {}) as Record, + operationsOutput: (raw.operationsOutput ?? { data: {} }) as FlowOperationsData, + operationsInput: (raw.operationsInput ?? { data: {} }) as FlowOperationsData, + reportId: String(raw.reportId ?? '') + }; + } + + /** + * Build a lookup map from action instance ID to human-readable names + * by scanning the flow definition's actionInstances array. + * @internal + */ + private _buildActionInstanceLookup(flowDef?: Record): Map { + const lookup = new Map(); + if (!flowDef) return lookup; + + const actionInstances = flowDef.actionInstances as Array> | undefined; + if (!Array.isArray(actionInstances)) return lookup; + + for (const instance of actionInstances) { + const id = instance.id as string; + if (!id) continue; + + const actionType = instance.actionType as Record | undefined; + const actionTypeName = (actionType?.displayName ?? actionType?.name) as string | undefined + || undefined; + const comment = (instance.comment as string | undefined) || undefined; + + lookup.set(id, { actionTypeName, comment }); + } + + return lookup; + } + /** * Extract the result from a ProcessFlow API response, handling both * Axios-style (data.result) and RequestHandler-style (bodyObject.result) response shapes. @@ -838,11 +1128,14 @@ export class FlowManager { // Lifecycle Script Builders // ================================================================ - /** Validate that a context ID is non-empty. */ + /** Validate that a context ID is a non-empty 32-character hex sys_id. */ private _validateContextId(contextId: string): void { if (!contextId || contextId.trim().length === 0) { throw new Error('Context ID is required'); } + if (!/^[0-9a-f]{32}$/i.test(contextId.trim())) { + throw new Error(`Invalid context ID format: "${contextId}". Expected a 32-character hex sys_id.`); + } } /** Escape a string for embedding in a generated SN script single-quoted string. */ diff --git a/src/sn/flow/FlowModels.ts b/src/sn/flow/FlowModels.ts index 1f2045f..0a3749d 100644 --- a/src/sn/flow/FlowModels.ts +++ b/src/sn/flow/FlowModels.ts @@ -361,3 +361,296 @@ export interface FlowCopyResult { /** The raw API response for advanced inspection */ rawResponse?: unknown; } + +// ============================================================ +// Flow Context Details Types (ProcessFlow Operations API) +// ============================================================ + +/** + * Core execution timing and state for a flow or action operation. + * Common shape returned within flowReport at both flow and action level. + */ +export interface FlowOperationsCore { + /** Error message if the operation failed (empty string if no error) */ + error: string; + + /** Execution state: COMPLETE, IN_PROGRESS, WAITING, ERROR, CANCELLED, etc. */ + state: string; + + /** Start time as a date-time string from the SN server */ + startTime: string; + + /** Execution order within the flow */ + order: string; + + /** Runtime in milliseconds */ + runTime: string; + + /** Flow context sys_id (present at the top-level operationsCore) */ + context?: string; +} + +/** + * Input or output data for a flow operation. + * Keys are variable names; values contain the raw value, display value, and metadata. + */ +export interface FlowOperationsData { + data: Record; +} + +/** Per-action execution report within the flow execution report. */ +export interface FlowActionReport { + /** Number of steps executed in this action */ + fStepCount: string; + + /** Action instance name/identifier (raw sys_id from the API) */ + actionName: string; + + /** Reference to the action instance */ + instanceReference: string; + + /** + * Human-readable step label resolved from the flow definition. + * Combines the action type name and comment, e.g. "Update Record (Approve Change)". + * Only populated when the flow definition is available in the response. + */ + stepLabel?: string; + + /** + * Action type name from the flow definition, e.g. "Update Record", "Look Up Record". + * Only populated when the flow definition is available in the response. + */ + actionTypeName?: string; + + /** + * Step comment from the flow definition, e.g. "Approve Change". + * Only populated when the flow definition is available in the response. + */ + stepComment?: string; + + /** Core execution timing and state */ + operationsCore: FlowOperationsCore; + + /** Links to detailed action operation reports */ + relatedLinks: Record; + + /** Output data from the action */ + operationsOutput: FlowOperationsData; + + /** Input data fed to the action */ + operationsInput: FlowOperationsData; + + /** Report record sys_id */ + reportId: string; +} + +/** Full execution report for a flow context. */ +export interface FlowExecutionReport { + /** Flow snapshot sys_id */ + flowId: string; + + /** Whether domain separation is enabled on the instance */ + domainSeparationEnabled: boolean; + + /** Domain in which the flow executed */ + executionDomain: string; + + /** Per-action execution reports, keyed by action instance sys_id */ + actionOperationsReports: Record; + + /** Per-subflow execution reports, keyed by subflow instance sys_id */ + subflowOperationsReports: Record; + + /** Per-iteration execution reports (for loops) */ + iterationOperationsReports: Record; + + /** Reference to the flow instance */ + instanceReference: string; + + /** Top-level execution timing and state */ + operationsCore: FlowOperationsCore; + + /** Links to related resources */ + relatedLinks: Record; + + /** Flow-level output data */ + operationsOutput: FlowOperationsData; + + /** Flow-level input data (trigger inputs) */ + operationsInput: FlowOperationsData; + + /** Report record sys_id */ + reportId: string; +} + +/** Execution source information from the flow context. */ +export interface FlowExecutionSource { + /** How the flow was triggered (e.g. "TEST_BUTTON", "RECORD_TRIGGER") */ + callingSource: string; + + /** Source table that triggered the flow (if record-triggered) */ + executionSourceTable: string; + + /** Source record sys_id */ + executionSourceRecord: string; + + /** Display value of the source record */ + executionSourceRecordDisplay: string; +} + +/** High-level flow context metadata from the operations API. */ +export interface FlowContextInfo { + /** Parent flow sys_id */ + flowId: string; + + /** Flow name */ + name: string; + + /** Execution state: COMPLETE, IN_PROGRESS, WAITING, ERROR, CANCELLED, etc. */ + state: FlowContextState; + + /** Total runtime in milliseconds */ + runTime: string; + + /** Whether this was a test execution */ + isTestRun: boolean; + + /** User who executed the flow */ + executedAs: string; + + /** User who initiated the flow */ + flowInitiatedBy: string; + + /** Reporting level (e.g. "TRACE", "NONE") */ + reporting: string; + + /** Whether debug mode was enabled */ + debugMode: boolean; + + /** How the flow was triggered */ + executionSource: FlowExecutionSource; + + /** Whether operations view expansion is enabled */ + enableOpsViewExpansion: boolean; + + /** Flow retention policy candidate flag */ + flowRetentionPolicyCandidate: boolean; +} + +/** Availability details for the flow execution report. */ +export interface FlowReportAvailabilityDetails { + /** Message about report availability (may be an error) */ + errorMessage: string; + + /** Severity level (e.g. "notification-danger", "notification-info") */ + errorLevel: string; + + /** Link text for the context record */ + linkMessage: string; + + /** URL to the context record */ + linkURL: string; +} + +/** Result from getting detailed flow context via the operations API. */ +export interface FlowContextDetailsResult { + /** Whether the API call completed successfully */ + success: boolean; + + /** The context sys_id that was queried */ + contextId: string; + + /** High-level execution metadata */ + flowContext?: FlowContextInfo; + + /** Detailed execution report with per-action timing, inputs, outputs */ + flowReport?: FlowExecutionReport; + + /** Availability details for the execution report */ + flowReportAvailabilityDetails?: FlowReportAvailabilityDetails; + + /** Full flow definition snapshot (only if requested) */ + flowDefinition?: Record; + + /** Error message if the API call failed */ + errorMessage?: string; + + /** The raw API response for advanced inspection */ + rawResponse?: unknown; +} + +// ============================================================ +// Flow Log Types (sys_flow_log Table API) +// ============================================================ + +/** Options for querying flow execution logs. */ +export interface FlowLogOptions { + /** Maximum number of log entries to return (default: 100) */ + limit?: number; + + /** Order direction: 'asc' or 'desc' (default: 'asc' by order) */ + orderDirection?: 'asc' | 'desc'; +} + +/** Individual flow log entry from sys_flow_log. */ +export interface FlowLogEntry { + /** Log entry sys_id */ + sysId: string; + + /** Log level (numeric string, e.g. "2" for info, "-1" for error) */ + level: string; + + /** Log message text */ + message: string; + + /** Dot-path reference to the flow action that generated the log */ + action: string; + + /** Operation type */ + operation: string; + + /** Execution order */ + order: string; + + /** When the log entry was created */ + createdOn: string; + + /** Who created the log entry */ + createdBy: string; +} + +/** Result from querying flow execution logs. */ +export interface FlowLogResult { + /** Whether the query completed successfully */ + success: boolean; + + /** The context sys_id that was queried */ + contextId: string; + + /** Array of log entries */ + entries: FlowLogEntry[]; + + /** Error message if the query failed */ + errorMessage?: string; + + /** The raw API response for advanced inspection */ + rawResponse?: unknown; +} + +/** + * JSON envelope structure returned by the ProcessFlow operations API + * for flow context details. + * @internal + */ +export interface FlowContextOperationsResponse { + result: { + flowContext: Record; + flow: Record; + flowReportAvailabilityDetails: FlowReportAvailabilityDetails; + flowReport: Record; + }; +} diff --git a/test/integration/sn/flow/FlowManager_IT.test.ts b/test/integration/sn/flow/FlowManager_IT.test.ts index 3e686eb..f517050 100644 --- a/test/integration/sn/flow/FlowManager_IT.test.ts +++ b/test/integration/sn/flow/FlowManager_IT.test.ts @@ -3,7 +3,7 @@ import { getCredentials } from "@servicenow/sdk-cli/dist/auth/index.js"; import { SN_INSTANCE_ALIAS } from '../../../test_utils/test_config'; import { FlowManager } from '../../../../src/sn/flow/FlowManager'; -import { FlowExecutionResult, FlowContextStatusResult, FlowPublishResult, FlowDefinitionResult, FlowTestResult, FlowCopyResult } from '../../../../src/sn/flow/FlowModels'; +import { FlowExecutionResult, FlowContextStatusResult, FlowPublishResult, FlowDefinitionResult, FlowTestResult, FlowCopyResult, FlowContextDetailsResult, FlowLogResult } from '../../../../src/sn/flow/FlowModels'; const SECONDS = 1000; @@ -604,4 +604,135 @@ describe('FlowManager - Integration Tests', () => { expect(result.errorMessage).toBeDefined(); }, 120 * SECONDS); }); + + // ============================================================ + // getFlowContextDetails + // ============================================================ + + describe('getFlowContextDetails', () => { + // Instance-specific: context ID from "Copy of Change - Standard" test execution on dev224436. + // These tests will fail on other instances — update the ID and expected values accordingly. + const KNOWN_CONTEXT_ID = '811e52d5702372105d88c5714b9b559b'; + + it('should return detailed flow context for a known execution', async () => { + const result: FlowContextDetailsResult = await flowMgr.getFlowContextDetails(KNOWN_CONTEXT_ID); + + console.log('\n=== getFlowContextDetails (known context) ==='); + console.log('Success:', result.success); + console.log('Flow name:', result.flowContext?.name); + console.log('State:', result.flowContext?.state); + console.log('RunTime:', result.flowContext?.runTime, 'ms'); + console.log('Is test run:', result.flowContext?.isTestRun); + console.log('Executed as:', result.flowContext?.executedAs); + console.log('Calling source:', result.flowContext?.executionSource?.callingSource); + console.log('Report available:', result.flowReportAvailabilityDetails?.errorMessage || 'yes'); + if (result.flowReport) { + console.log('Flow report state:', result.flowReport.operationsCore.state); + console.log('Action reports count:', Object.keys(result.flowReport.actionOperationsReports).length); + } + + expect(result.success).toBe(true); + expect(result.contextId).toBe(KNOWN_CONTEXT_ID); + expect(result.flowContext).toBeDefined(); + expect(result.flowContext!.state).toBe('COMPLETE'); + expect(result.flowContext!.name).toBe('Copy of Change - Standard'); + }, 120 * SECONDS); + + it('should include flow definition when requested', async () => { + const result: FlowContextDetailsResult = await flowMgr.getFlowContextDetails( + KNOWN_CONTEXT_ID, undefined, true + ); + + console.log('\n=== getFlowContextDetails (with definition) ==='); + console.log('Success:', result.success); + console.log('Flow definition present:', !!result.flowDefinition); + if (result.flowDefinition) { + console.log('Definition name:', result.flowDefinition.name); + } + + expect(result.success).toBe(true); + expect(result.flowDefinition).toBeDefined(); + }, 120 * SECONDS); + + it('should return execution report with per-action details', async () => { + const result: FlowContextDetailsResult = await flowMgr.getFlowContextDetails(KNOWN_CONTEXT_ID); + + console.log('\n=== getFlowContextDetails (execution report) ==='); + if (result.flowReport) { + const actionKeys = Object.keys(result.flowReport.actionOperationsReports); + console.log('Action count:', actionKeys.length); + for (const key of actionKeys) { + const action = result.flowReport.actionOperationsReports[key]; + console.log(` Action ${key}: state=${action.operationsCore.state}, runTime=${action.operationsCore.runTime}ms, step="${action.stepLabel ?? 'N/A'}"`); + } + } + + expect(result.success).toBe(true); + expect(result.flowReport).toBeDefined(); + // The "Copy of Change - Standard" flow has action steps + const actionCount = Object.keys(result.flowReport!.actionOperationsReports).length; + expect(actionCount).toBeGreaterThan(0); + }, 120 * SECONDS); + + it('should handle non-existent context gracefully', async () => { + const result: FlowContextDetailsResult = await flowMgr.getFlowContextDetails( + '00000000000000000000000000000000' + ); + + console.log('\n=== getFlowContextDetails (non-existent) ==='); + console.log('Success:', result.success); + console.log('Error:', result.errorMessage); + + // The API may return an error or an empty response + // Either way we should handle it without crashing + expect(result.contextId).toBe('00000000000000000000000000000000'); + }, 120 * SECONDS); + }); + + // ============================================================ + // getFlowLogs + // ============================================================ + + describe('getFlowLogs', () => { + const KNOWN_CONTEXT_ID = '811e52d5702372105d88c5714b9b559b'; + + it('should query flow logs for a known context', async () => { + const result: FlowLogResult = await flowMgr.getFlowLogs(KNOWN_CONTEXT_ID); + + console.log('\n=== getFlowLogs (known context) ==='); + console.log('Success:', result.success); + console.log('Entry count:', result.entries.length); + for (const entry of result.entries.slice(0, 5)) { + console.log(` [${entry.level}] ${entry.message} (action: ${entry.action})`); + } + + expect(result.success).toBe(true); + expect(result.contextId).toBe(KNOWN_CONTEXT_ID); + expect(Array.isArray(result.entries)).toBe(true); + // This context may or may not have logs - just verify the structure + }, 120 * SECONDS); + + it('should support custom limit', async () => { + const result: FlowLogResult = await flowMgr.getFlowLogs(KNOWN_CONTEXT_ID, { limit: 2 }); + + console.log('\n=== getFlowLogs (limit 2) ==='); + console.log('Success:', result.success); + console.log('Entry count:', result.entries.length); + + expect(result.success).toBe(true); + expect(result.entries.length).toBeLessThanOrEqual(2); + }, 120 * SECONDS); + + it('should return empty entries for context with no logs', async () => { + // Use a non-existent context ID that won't have any logs + const result: FlowLogResult = await flowMgr.getFlowLogs('00000000000000000000000000000000'); + + console.log('\n=== getFlowLogs (no logs) ==='); + console.log('Success:', result.success); + console.log('Entry count:', result.entries.length); + + expect(result.success).toBe(true); + expect(result.entries).toHaveLength(0); + }, 120 * SECONDS); + }); }); diff --git a/test/unit/sn/flow/FlowManager.test.ts b/test/unit/sn/flow/FlowManager.test.ts index b9439ed..e9572cd 100644 --- a/test/unit/sn/flow/FlowManager.test.ts +++ b/test/unit/sn/flow/FlowManager.test.ts @@ -819,6 +819,16 @@ describe('FlowManager - Unit Tests', () => { it('should not throw for valid contextId', () => { expect(() => (flowMgr as any)._validateContextId('abc123def456789012345678901234ab')).not.toThrow(); }); + + it('should throw for non-hex contextId (query injection)', () => { + expect(() => (flowMgr as any)._validateContextId('abc^ORDERBYDESCsys_created_on')) + .toThrow('Invalid context ID format'); + }); + + it('should throw for contextId with wrong length', () => { + expect(() => (flowMgr as any)._validateContextId('abc123')) + .toThrow('Invalid context ID format'); + }); }); describe('_escapeForScript', () => { @@ -2025,4 +2035,431 @@ describe('FlowManager - Unit Tests', () => { expect(result.errorMessage).toContain('no flow sys_id'); }); }); + + // ================================================================ + // getFlowContextDetails + // ================================================================ + + describe('getFlowContextDetails', () => { + const CONTEXT_ID = 'c0ffee00def456789012345678901234'; + + const MOCK_OPERATIONS_RESPONSE = { + flowContext: { + flowId: 'abc123def456789012345678901234ab', + name: 'Test Flow', + state: 'COMPLETE', + runTime: '5298', + isTestRun: true, + executedAs: 'System Administrator', + flowInitiatedBy: 'System Administrator', + reporting: 'TRACE', + debugMode: false, + executionSource: { + callingSource: 'TEST_BUTTON', + executionSourceTable: '', + executionSourceRecord: '', + executionSourceRecordDisplay: '' + }, + enableOpsViewExpansion: true, + flowRetentionPolicyCandidate: false + }, + flow: { + id: 'snapshot123', + name: 'Test Flow', + status: 'published', + actionInstances: [ + { + id: 'action_001', + comment: 'Approve Change', + actionType: { + name: 'Update Record', + displayName: 'Update Record' + } + } + ] + }, + flowReportAvailabilityDetails: { + errorMessage: '', + errorLevel: '', + linkMessage: 'Open Context Record', + linkURL: '/sys_flow_context.do?sys_id=' + CONTEXT_ID + }, + flowReport: { + flowId: 'snapshot123', + domainSeparationEnabled: false, + executionDomain: 'global', + actionOperationsReports: { + 'action_001': { + fStepCount: '1', + actionName: 'action_001', + instanceReference: 'action_001', + operationsCore: { + error: '', + state: 'COMPLETE', + startTime: '2026-03-10 01:24:03', + order: '5', + runTime: '42' + }, + relatedLinks: {}, + operationsOutput: { data: {} }, + operationsInput: { data: {} }, + reportId: 'report_001' + } + }, + subflowOperationsReports: {}, + iterationOperationsReports: {}, + instanceReference: 'snapshot123', + operationsCore: { + error: '', + state: 'COMPLETE', + startTime: '2026-03-10 01:23:58', + order: '1', + context: CONTEXT_ID, + runTime: '5298' + }, + relatedLinks: {}, + operationsOutput: { data: {} }, + operationsInput: { data: { current: { value: 'rec123', displayValue: 'rec123', inputUsed: false } } }, + reportId: 'report_top' + } + }; + + it('should throw for empty context ID', async () => { + await expect(flowMgr.getFlowContextDetails('')).rejects.toThrow('Context ID is required'); + }); + + it('should throw for whitespace-only context ID', async () => { + await expect(flowMgr.getFlowContextDetails(' ')).rejects.toThrow('Context ID is required'); + }); + + it('should return full context details on success', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: MOCK_OPERATIONS_RESPONSE } + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + expect(result.success).toBe(true); + expect(result.contextId).toBe(CONTEXT_ID); + expect(result.flowContext).toBeDefined(); + expect(result.flowContext!.name).toBe('Test Flow'); + expect(result.flowContext!.state).toBe('COMPLETE'); + expect(result.flowContext!.runTime).toBe('5298'); + expect(result.flowContext!.isTestRun).toBe(true); + expect(result.flowContext!.executedAs).toBe('System Administrator'); + expect(result.flowContext!.executionSource.callingSource).toBe('TEST_BUTTON'); + }); + + it('should return flow execution report with action reports and resolved step labels', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: MOCK_OPERATIONS_RESPONSE } + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + expect(result.flowReport).toBeDefined(); + expect(result.flowReport!.operationsCore.state).toBe('COMPLETE'); + expect(result.flowReport!.operationsCore.runTime).toBe('5298'); + expect(result.flowReport!.actionOperationsReports).toBeDefined(); + + const actionReport = result.flowReport!.actionOperationsReports['action_001']; + expect(actionReport).toBeDefined(); + expect(actionReport.operationsCore.state).toBe('COMPLETE'); + expect(actionReport.operationsCore.runTime).toBe('42'); + expect(actionReport.reportId).toBe('report_001'); + + // Step label resolution from flow definition + expect(actionReport.stepLabel).toBe('Update Record (Approve Change)'); + expect(actionReport.actionTypeName).toBe('Update Record'); + expect(actionReport.stepComment).toBe('Approve Change'); + }); + + it('should include flow definition when requested', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: MOCK_OPERATIONS_RESPONSE } + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID, undefined, true); + + expect(result.flowDefinition).toBeDefined(); + expect(result.flowDefinition!.name).toBe('Test Flow'); + }); + + it('should not include flow definition by default', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: MOCK_OPERATIONS_RESPONSE } + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + expect(result.flowDefinition).toBeUndefined(); + }); + + it('should pass scope as query parameter when provided', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: MOCK_OPERATIONS_RESPONSE } + }); + + await flowMgr.getFlowContextDetails(CONTEXT_ID, 'my_scope_id'); + + const callArgs = mockRequestHandler.get.mock.calls[0][0]; + expect(callArgs.query).toBeDefined(); + expect(callArgs.query.sysparm_transaction_scope).toBe('my_scope_id'); + }); + + it('should include report availability details', async () => { + const responseWithError = { + ...MOCK_OPERATIONS_RESPONSE, + flowReportAvailabilityDetails: { + errorMessage: 'Execution details not available', + errorLevel: 'notification-danger', + linkMessage: 'Open Context Record', + linkURL: '/sys_flow_context.do?sys_id=' + CONTEXT_ID + } + }; + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: responseWithError } + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + expect(result.flowReportAvailabilityDetails).toBeDefined(); + expect(result.flowReportAvailabilityDetails!.errorMessage).toBe('Execution details not available'); + expect(result.flowReportAvailabilityDetails!.errorLevel).toBe('notification-danger'); + }); + + it('should return failure when no result field in response', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: {} + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain('no result field'); + }); + + it('should handle HTTP errors gracefully', async () => { + mockRequestHandler.get.mockRejectedValueOnce(new Error('Connection refused')); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain('Connection refused'); + }); + + it('should leave step labels undefined when flow definition has no actionInstances', async () => { + const responseNoInstances = { + ...MOCK_OPERATIONS_RESPONSE, + flow: { id: 'snapshot123', name: 'Test Flow' } // no actionInstances + }; + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: responseNoInstances } + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + const actionReport = result.flowReport!.actionOperationsReports['action_001']; + expect(actionReport).toBeDefined(); + expect(actionReport.stepLabel).toBeUndefined(); + expect(actionReport.actionTypeName).toBeUndefined(); + expect(actionReport.stepComment).toBeUndefined(); + }); + + it('should handle missing flowContext and flowReport gracefully', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: { flowContext: undefined, flowReport: undefined } } + }); + + const result = await flowMgr.getFlowContextDetails(CONTEXT_ID); + + expect(result.success).toBe(true); + expect(result.flowContext).toBeUndefined(); + expect(result.flowReport).toBeUndefined(); + }); + + it('should use correct API path with context ID', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: MOCK_OPERATIONS_RESPONSE } + }); + + await flowMgr.getFlowContextDetails(CONTEXT_ID); + + const callArgs = mockRequestHandler.get.mock.calls[0][0]; + expect(callArgs.path).toContain('/api/now/processflow/operations/flow/context/'); + expect(callArgs.path).toContain(CONTEXT_ID); + }); + }); + + // ================================================================ + // getFlowLogs + // ================================================================ + + describe('getFlowLogs', () => { + const CONTEXT_ID = 'c0ffee00def456789012345678901234'; + + const MOCK_LOG_RECORDS = [ + { + sys_id: 'log001', + level: '2', + message: 'Flow started', + action: 'Test_Flow.trigger_1', + operation: 'start', + order: '1', + sys_created_on: '2026-03-10 01:23:58', + sys_created_by: 'admin' + }, + { + sys_id: 'log002', + level: '-1', + message: 'Error in action: record not found', + action: 'Test_Flow.action_1', + operation: 'error', + order: '2', + sys_created_on: '2026-03-10 01:24:01', + sys_created_by: 'admin' + } + ]; + + it('should throw for empty context ID', async () => { + await expect(flowMgr.getFlowLogs('')).rejects.toThrow('Context ID is required'); + }); + + it('should return log entries on success', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: MOCK_LOG_RECORDS } + }); + + const result = await flowMgr.getFlowLogs(CONTEXT_ID); + + expect(result.success).toBe(true); + expect(result.contextId).toBe(CONTEXT_ID); + expect(result.entries).toHaveLength(2); + expect(result.entries[0].sysId).toBe('log001'); + expect(result.entries[0].level).toBe('2'); + expect(result.entries[0].message).toBe('Flow started'); + expect(result.entries[1].level).toBe('-1'); + expect(result.entries[1].message).toContain('record not found'); + }); + + it('should return empty entries when no logs exist', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: [] } + }); + + const result = await flowMgr.getFlowLogs(CONTEXT_ID); + + expect(result.success).toBe(true); + expect(result.entries).toHaveLength(0); + }); + + it('should map all log entry fields correctly', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: [MOCK_LOG_RECORDS[0]] } + }); + + const result = await flowMgr.getFlowLogs(CONTEXT_ID); + const entry = result.entries[0]; + + expect(entry.sysId).toBe('log001'); + expect(entry.level).toBe('2'); + expect(entry.message).toBe('Flow started'); + expect(entry.action).toBe('Test_Flow.trigger_1'); + expect(entry.operation).toBe('start'); + expect(entry.order).toBe('1'); + expect(entry.createdOn).toBe('2026-03-10 01:23:58'); + expect(entry.createdBy).toBe('admin'); + }); + + it('should query with correct table and fields', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: [] } + }); + + await flowMgr.getFlowLogs(CONTEXT_ID); + + const callArgs = mockRequestHandler.get.mock.calls[0][0]; + expect(callArgs.path).toContain('sys_flow_log'); + expect(callArgs.query.sysparm_fields).toContain('sys_id'); + expect(callArgs.query.sysparm_fields).toContain('message'); + expect(callArgs.query.sysparm_fields).toContain('level'); + expect(callArgs.query.sysparm_query).toContain(`context=${CONTEXT_ID}`); + }); + + it('should use default limit of 100', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: [] } + }); + + await flowMgr.getFlowLogs(CONTEXT_ID); + + const callArgs = mockRequestHandler.get.mock.calls[0][0]; + expect(callArgs.query.sysparm_limit).toBe('100'); + }); + + it('should use custom limit when provided', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: [] } + }); + + await flowMgr.getFlowLogs(CONTEXT_ID, { limit: 10 }); + + const callArgs = mockRequestHandler.get.mock.calls[0][0]; + expect(callArgs.query.sysparm_limit).toBe('10'); + }); + + it('should support descending order', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: [] } + }); + + await flowMgr.getFlowLogs(CONTEXT_ID, { orderDirection: 'desc' }); + + const callArgs = mockRequestHandler.get.mock.calls[0][0]; + expect(callArgs.query.sysparm_query).toContain('ORDERBYDESCorder'); + }); + + it('should default to ascending order', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: [] } + }); + + await flowMgr.getFlowLogs(CONTEXT_ID); + + const callArgs = mockRequestHandler.get.mock.calls[0][0]; + expect(callArgs.query.sysparm_query).toContain('ORDERBYorder'); + expect(callArgs.query.sysparm_query).not.toContain('ORDERBYDESC'); + }); + + it('should throw for zero limit', async () => { + await expect(flowMgr.getFlowLogs(CONTEXT_ID, { limit: 0 })) + .rejects.toThrow('Limit must be a positive integer'); + }); + + it('should throw for negative limit', async () => { + await expect(flowMgr.getFlowLogs(CONTEXT_ID, { limit: -5 })) + .rejects.toThrow('Limit must be a positive integer'); + }); + + it('should handle HTTP errors gracefully', async () => { + mockRequestHandler.get.mockRejectedValueOnce(new Error('Table not found')); + + const result = await flowMgr.getFlowLogs(CONTEXT_ID); + + expect(result.success).toBe(false); + expect(result.entries).toHaveLength(0); + expect(result.errorMessage).toContain('Table not found'); + }); + + it('should handle missing result gracefully', async () => { + mockRequestHandler.get.mockResolvedValueOnce({ + data: { result: undefined } + }); + + const result = await flowMgr.getFlowLogs(CONTEXT_ID); + + expect(result.success).toBe(true); + expect(result.entries).toHaveLength(0); + }); + }); });