Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 92 additions & 3 deletions src/sn/flow/FlowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
FlowTestResult,
FlowDefinitionResult,
ProcessFlowApiResponse,
ProcessFlowTestPayload
ProcessFlowTestPayload,
CopyFlowOptions,
FlowCopyResult
} from './FlowModels';
import { TableAPIRequest } from '../../comm/http/TableAPIRequest';
import { ProcessFlowRequest } from '../../comm/http/ProcessFlowRequest';
Expand Down Expand Up @@ -428,7 +430,7 @@ export class FlowManager {

const apiResult = this._extractProcessFlowResult(response);

if (apiResult.errorCode !== 0 || (apiResult.errorCode == null && apiResult.errorMessage)) {
if ((apiResult.errorCode != null && apiResult.errorCode !== 0) || (apiResult.errorCode == null && apiResult.errorMessage)) {
return {
success: false,
errorMessage: apiResult.errorMessage || 'Unknown error from processflow API',
Expand Down Expand Up @@ -527,7 +529,7 @@ export class FlowManager {

const apiResult = this._extractProcessFlowResult(response);

if (apiResult.errorCode !== 0 || (apiResult.errorCode == null && apiResult.errorMessage)) {
if ((apiResult.errorCode != null && apiResult.errorCode !== 0) || (apiResult.errorCode == null && apiResult.errorMessage)) {
this._logger.error(`Test flow failed: ${apiResult.errorMessage}`);
return {
success: false,
Expand Down Expand Up @@ -556,6 +558,93 @@ export class FlowManager {
}
}

/**
* Copy an existing flow into a target scoped application.
*
* This uses the ProcessFlow REST API (`POST /api/now/processflow/flow/{id}/copy`)
* to create a duplicate of a flow in a specified scope. The copied flow lands in
* draft/unpublished state and can then be modified, tested, and published.
*
* @param options Copy flow options including sourceFlowId, name, and targetScope
* @returns FlowCopyResult with the new flow's sys_id on success
*/
public async copyFlow(options: CopyFlowOptions): Promise<FlowCopyResult> {
if (!options.sourceFlowId || options.sourceFlowId.trim().length === 0) {
throw new Error('Source flow identifier is required (sys_id or scoped_name)');
}
if (!options.name || options.name.trim().length === 0) {
throw new Error('Name is required for the copied flow');
}
if (!options.targetScope || options.targetScope.trim().length === 0) {
throw new Error('Target scope sys_id is required');
}

this._logger.info(`Copying flow "${options.sourceFlowId}" as "${options.name}" into scope ${options.targetScope}`);

// Resolve source flow identifier to sys_id
let resolved: ResolvedFlowIdentifier;
try {
resolved = await this._resolveFlowIdentifier(options.sourceFlowId);
} catch (error) {
const err = error as Error;
return {
success: false,
errorMessage: err.message
};
}

const pfr = new ProcessFlowRequest(this._instance);
const query = { sysparm_transaction_scope: options.targetScope };
const payload = { name: options.name, scope: options.targetScope };

try {
const response = await pfr.post<ProcessFlowApiResponse>(
'flow/{flow_sys_id}/copy',
{ flow_sys_id: resolved.sysId },
query,
payload
);

const apiResult = this._extractProcessFlowResult(response);

if ((apiResult.errorCode != null && apiResult.errorCode !== 0) || (apiResult.errorCode == null && apiResult.errorMessage)) {
this._logger.error(`Copy flow failed: ${apiResult.errorMessage}`);
return {
success: false,
errorMessage: apiResult.errorMessage || 'Unknown error from processflow copy API',
errorCode: apiResult.errorCode,
rawResponse: apiResult
};
}

const newFlowSysId = apiResult.data as string;
if (!newFlowSysId || typeof newFlowSysId !== 'string' || newFlowSysId.trim().length === 0) {
return {
success: false,
errorMessage: 'ProcessFlow copy API returned no flow sys_id',
rawResponse: apiResult
};
}

this._logger.info(`Flow copied successfully, new sys_id: ${newFlowSysId}`);

return {
success: true,
newFlowSysId,
errorCode: 0,
rawResponse: apiResult
};
} catch (error) {
const err = error as Error;
this._logger.error(`Error copying flow "${options.sourceFlowId}": ${err.message}`);
return {
success: false,
errorMessage: `Failed to copy flow: ${err.message}`,
rawResponse: null
};
}
}

/**
* Extract the result from a ProcessFlow API response, handling both
* Axios-style (data.result) and RequestHandler-style (bodyObject.result) response shapes.
Expand Down
36 changes: 36 additions & 0 deletions src/sn/flow/FlowModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,39 @@ export interface ProcessFlowTestPayload {
outputMap: Record<string, string>;
runOnThread: boolean;
}

// ============================================================
// Copy Flow Types (ProcessFlow REST API)
// ============================================================

/**
* Options for copying a flow into a target scoped application via the ProcessFlow REST API.
*/
export interface CopyFlowOptions {
/** Source flow identifier: either a 32-char hex sys_id or a scoped name (e.g. "global.change__standard") */
sourceFlowId: string;

/** Display name for the new copied flow */
name: string;

/** Target scope sys_id to copy the flow into */
targetScope: string;
}

/** Result from copying a flow via the ProcessFlow REST API. */
export interface FlowCopyResult {
/** Whether the copy operation completed successfully */
success: boolean;

/** sys_id of the newly created flow copy */
newFlowSysId?: string;

/** Error message from the API or from local processing */
errorMessage?: string;

/** Numeric error code from the API (0 = no error) */
errorCode?: number;

/** The raw API response for advanced inspection */
rawResponse?: unknown;
}
75 changes: 74 additions & 1 deletion test/integration/sn/flow/FlowManager_IT.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from '../../../../src/sn/flow/FlowModels';
import { FlowExecutionResult, FlowContextStatusResult, FlowPublishResult, FlowDefinitionResult, FlowTestResult, FlowCopyResult } from '../../../../src/sn/flow/FlowModels';

const SECONDS = 1000;

Expand Down Expand Up @@ -531,4 +531,77 @@ describe('FlowManager - Integration Tests', () => {
expect(result.errorMessage).toBeDefined();
}, 120 * SECONDS);
});

// ============================================================
// copyFlow (ProcessFlow REST API)
// ============================================================

describe('copyFlow', () => {
// Source: "Change - Standard" OOB flow
const SOURCE_FLOW_SYS_ID = process.env.TEST_COPY_SOURCE_FLOW || 'e89e3ade731310108ef62d2b04f6a744';
// Target scope: "My Awesome App" on dev224436
const TARGET_SCOPE = process.env.TEST_COPY_TARGET_SCOPE || '4a5a6115402946939ee48e3fe80f60f8';

it('should copy a flow into a target scope', async () => {
const copyName = `IT Copy ${Date.now()}`;
const result: FlowCopyResult = await flowMgr.copyFlow({
sourceFlowId: SOURCE_FLOW_SYS_ID,
name: copyName,
targetScope: TARGET_SCOPE
});

console.log('\n=== copyFlow ===');
console.log('Success:', result.success);
console.log('New Flow SysId:', result.newFlowSysId);
console.log('Error Code:', result.errorCode);
console.log('Error Message:', result.errorMessage);

expect(result.success).toBe(true);
expect(result.newFlowSysId).toBeDefined();
expect(result.newFlowSysId).toMatch(/^[0-9a-f]{32}$/);
expect(result.errorCode).toBe(0);
}, 120 * SECONDS);

it('should return the new flow definition when fetched', async () => {
const copyName = `IT Copy Verify ${Date.now()}`;
const copyResult: FlowCopyResult = await flowMgr.copyFlow({
sourceFlowId: SOURCE_FLOW_SYS_ID,
name: copyName,
targetScope: TARGET_SCOPE
});

console.log('\n=== copyFlow + verify ===');
console.log('Copy Success:', copyResult.success);
console.log('New Flow SysId:', copyResult.newFlowSysId);

expect(copyResult.success).toBe(true);
expect(copyResult.newFlowSysId).toBeDefined();

// Verify the new flow exists by fetching its definition
const defResult = await flowMgr.getFlowDefinition(copyResult.newFlowSysId!);

console.log('Definition fetch success:', defResult.success);
console.log('Flow name:', defResult.definition?.name);
console.log('Flow status:', defResult.definition?.status);

expect(defResult.success).toBe(true);
expect(defResult.definition).toBeDefined();
expect(defResult.definition!.name).toBe(copyName);
}, 120 * SECONDS);

it('should handle non-existent source flow gracefully', async () => {
const result: FlowCopyResult = await flowMgr.copyFlow({
sourceFlowId: 'global.nonexistent_flow_xyz_99999',
name: 'Should Not Exist',
targetScope: TARGET_SCOPE
});

console.log('\n=== copyFlow (non-existent source) ===');
console.log('Success:', result.success);
console.log('Error:', result.errorMessage);

expect(result.success).toBe(false);
expect(result.errorMessage).toBeDefined();
}, 120 * SECONDS);
});
});
Loading