Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/oauth-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ This page lists the specific OAuth scopes required in external app for each SDK
|--------|-------------|
| `getAll()` | `PIMS` |
| `getIncidents()` | `PIMS` |
| `start()` | `OR.Jobs` or `OR.Jobs.Write` |

## Maestro Process Instances

Expand Down
28 changes: 28 additions & 0 deletions src/models/maestro/processes.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

import { RawMaestroProcessGetAllResponse } from './processes.types';
import { ProcessIncidentGetResponse } from './process-incidents.types';
import {
ProcessStartRequest,
ProcessStartResponse,
} from '../orchestrator/processes.types';
import { RequestOptions } from '../common/types';

/**
* Service for managing UiPath Maestro Processes
Expand Down Expand Up @@ -66,6 +71,29 @@ export interface MaestroProcessesServiceModel {
* ```
*/
getIncidents(processKey: string, folderKey: string): Promise<ProcessIncidentGetResponse[]>;

/**
* Starts a Maestro process execution within a folder.
*
* @param request - Process start request body (provide either `processKey` or `processName`)
* @param folderKey - Required folder key
* @param options - Optional query parameters
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The {@link ProcessStartResponse} is on a separate line instead of inline with @returns. Per conventions: "Link response types with {@link TypeName} in every method's JSDoc @returns."

* @returns Promise resolving to the created jobs  see {@link ProcessStartResponse}

* @returns Promise resolving to the created jobs
* {@link ProcessStartResponse}
* @example
* ```typescript
* // Start a process by process key
* const jobs = await maestroProcesses.start({
* processKey: '<processKey>'
* }, '<folderKey>');
*
* // Start a process by name
* const jobs = await maestroProcesses.start({
* processName: 'MyProcess'
* }, '<folderKey>');
* ```
*/
start(request: ProcessStartRequest, folderKey: string, options?: RequestOptions): Promise<ProcessStartResponse[]>;
}

// Method interface that will be added to process objects
Expand Down
44 changes: 43 additions & 1 deletion src/services/maestro/processes/processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { track } from '../../../core/telemetry';
import { createHeaders } from '../../../utils/http/headers';
import { FOLDER_KEY } from '../../../utils/constants/headers';
import { ProcessInstancesService } from './process-instances';
import {
ProcessStartRequest,
ProcessStartResponse,
} from '../../../models/orchestrator/processes.types';
import { RequestOptions } from '../../../models/common/types';
import { startProcessRequest } from '../../orchestrator/processes/helpers';

/**
* Service for interacting with Maestro Processes
Expand Down Expand Up @@ -77,4 +83,40 @@ export class MaestroProcessesService extends BaseService implements MaestroProce
// Fetch BPMN XML and add element name/type to each incident
return BpmnHelpers.enrichIncidentsWithBpmnData(rawResponse.data || [], folderKey, this.processInstancesService);
}
}

/**
* Starts a Maestro process execution within a folder
*
* @param request - Process start request body
* @param folderKey - Required folder key
* @param options - Optional query parameters
* @returns Promise resolving to the created jobs
*
* @example
* ```typescript
* import { MaestroProcesses } from '@uipath/uipath-typescript/maestro-processes';
*
* const maestroProcesses = new MaestroProcesses(sdk);
*
* // Start a process by process key
* const jobs = await maestroProcesses.start({
* processKey: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
* }, "<folderKey>");
*
* // Start a process by name
* const jobs = await maestroProcesses.start({
* processName: "MyProcess"
* }, "<folderKey>");
* ```
*/
@track('MaestroProcesses.Start')
async start(request: ProcessStartRequest, folderKey: string, options: RequestOptions = {}
): Promise<ProcessStartResponse[]> {
return startProcessRequest(
this.post.bind(this),
request,
createHeaders({ [FOLDER_KEY]: folderKey }),
options
);
}
}
56 changes: 56 additions & 0 deletions src/services/orchestrator/processes/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ApiResponse } from '../../base';
import type { RequestSpec } from '../../../models/common/request-spec';
import {
ProcessStartRequest,
ProcessStartResponse,
} from '../../../models/orchestrator/processes.types';
import { ProcessMap } from '../../../models/orchestrator/processes.constants';
import { CollectionResponse, RequestOptions } from '../../../models/common/types';
import {
addPrefixToKeys,
pascalToCamelCaseKeys,
transformData,
transformRequest,
} from '../../../utils/transform';
import { ODATA_PREFIX } from '../../../utils/constants/common';
import { PROCESS_ENDPOINTS } from '../../../utils/constants/endpoints';

type Poster = <T>(path: string, body?: unknown, options?: RequestSpec) => Promise<ApiResponse<T>>;

/**
* Shared implementation of the Orchestrator StartJobs request.
*
* Both `ProcessService.start` (orchestrator, folderId header) and
* `MaestroProcessesService.start` (maestro, folderKey header) delegate here.
* Callers are responsible for constructing the appropriate folder header.
*/
export async function startProcessRequest(
post: Poster,
request: ProcessStartRequest,
headers: Record<string, string>,
options: RequestOptions = {}
): Promise<ProcessStartResponse[]> {
// Transform SDK field names to API field names (e.g., processKey → releaseKey)
const apiRequest = transformRequest(request, ProcessMap);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three inline comments explain self-evident code — transformRequest, addPrefixToKeys, and the startInfo body shape are all well-named and need no narration. Per project conventions: "Only add comments where the logic isn't self-evident." Please remove them.

// Create the request object according to API spec
const requestBody = {
startInfo: apiRequest
};

// Prefix all query parameter keys with '$' for OData
const apiOptions = addPrefixToKeys(options, ODATA_PREFIX, Object.keys(options));

const response = await post<CollectionResponse<ProcessStartResponse>>(
PROCESS_ENDPOINTS.START_PROCESS,
requestBody,
{
params: apiOptions,
headers
}
);

return response.data?.value.map(process =>
transformData(pascalToCamelCaseKeys(process) as ProcessStartResponse, ProcessMap)
Comment on lines +52 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optional chain on response.data?.value.map(...) means this can return undefined when response.data is nullish, but the declared return type is Promise<ProcessStartResponse[]>. That's a type lie — callers doing result[0] will get a runtime crash if the API ever returns an empty/null body.

Suggested change
return response.data?.value.map(process =>
transformData(pascalToCamelCaseKeys(process) as ProcessStartResponse, ProcessMap)
return response.data?.value.map(process =>
transformData(pascalToCamelCaseKeys(process) as ProcessStartResponse, ProcessMap)
) ?? [];

);
}
37 changes: 8 additions & 29 deletions src/services/orchestrator/processes/processes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BaseService } from '../../base';
import { CollectionResponse, RequestOptions } from '../../../models/common/types';
import { RequestOptions } from '../../../models/common/types';
import {
ProcessGetResponse,
ProcessGetAllOptions,
Expand All @@ -8,12 +8,13 @@ import {
ProcessGetByIdOptions
} from '../../../models/orchestrator/processes.types';
import { ProcessServiceModel } from '../../../models/orchestrator/processes.models';
import { addPrefixToKeys, pascalToCamelCaseKeys, transformData, transformRequest } from '../../../utils/transform';
import { addPrefixToKeys, pascalToCamelCaseKeys, transformData } from '../../../utils/transform';
import { createHeaders } from '../../../utils/http/headers';
import { ProcessMap } from '../../../models/orchestrator/processes.constants';
import { FOLDER_ID } from '../../../utils/constants/headers';
import { PROCESS_ENDPOINTS } from '../../../utils/constants/endpoints';
import { ODATA_PREFIX, ODATA_PAGINATION, ODATA_OFFSET_PARAMS } from '../../../utils/constants/common';
import { startProcessRequest } from './helpers';
import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../../utils/pagination';
import { PaginationHelpers } from '../../../utils/pagination/helpers';
import { PaginationType } from '../../../utils/pagination/internal-types';
Expand Down Expand Up @@ -124,34 +125,12 @@ export class ProcessService extends BaseService implements ProcessServiceModel {
*/
@track('Processes.Start')
async start(request: ProcessStartRequest, folderId: number, options: RequestOptions = {}): Promise<ProcessStartResponse[]> {
const headers = createHeaders({ [FOLDER_ID]: folderId });

// Transform SDK field names to API field names (e.g., processKey → releaseKey)
const apiRequest = transformRequest(request, ProcessMap);

// Create the request object according to API spec
const requestBody = {
startInfo: apiRequest
};

// Prefix all query parameter keys with '$' for OData
const keysToPrefix = Object.keys(options);
const apiOptions = addPrefixToKeys(options, ODATA_PREFIX, keysToPrefix);

const response = await this.post<CollectionResponse<ProcessStartResponse>>(
PROCESS_ENDPOINTS.START_PROCESS,
requestBody,
{
params: apiOptions,
headers
}
);

const transformedProcess = response.data?.value.map(process =>
transformData(pascalToCamelCaseKeys(process) as ProcessStartResponse, ProcessMap)
return startProcessRequest(
this.post.bind(this),
request,
createHeaders({ [FOLDER_ID]: folderId }),
options
);

return transformedProcess;
}

/**
Expand Down
32 changes: 32 additions & 0 deletions tests/integration/shared/maestro/processes.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,38 @@ describe.each(modes)('Maestro Processes - Integration Tests [%s]', (mode) => {
});
});

describe('start', () => {
it('should start a process and create a job using processKey', async () => {
const { maestroProcesses } = getServices();
const config = getTestConfig();

const processKey = config.orchestratorTestProcessKey;

expect(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard pattern is inconsistent with the rest of this file. The existing getIncidents test uses:

if (!processKey) {
  console.warn('MAESTRO_TEST_PROCESS_KEY not configured, skipping test');
  return;
}

Per conventions: "Use console.warn() + skip for setup preconditions outside the test's control." expect().toBeDefined() throws and marks the test as failed rather than skipped when the env var is missing. Use if (!processKey) { console.warn(...); return; } to be consistent.

processKey,
'ORCHESTRATOR_TEST_PROCESS_KEY must be configured to test process execution'
).toBeDefined();
expect(config.folderId, 'INTEGRATION_TEST_FOLDER_ID must be configured').toBeDefined();

const result = await maestroProcesses.start(
{
processKey: processKey!,
inputArguments: JSON.stringify({
testRun: true,
timestamp: new Date().toISOString(),
}),
},
Comment on lines +147 to +157
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config.folderId maps to the INTEGRATION_TEST_FOLDER_ID env var, which is a numeric Orchestrator folder ID (stored as string). Maestro's start() takes folderKey: string — a named folder key like "Accounting", not a numeric ID. These are semantically different and the API won't accept an ID where a key is expected.

This integration test needs a Maestro-specific string folder key. IntegrationConfig will need a new field (e.g., maestroFolderKey?: string backed by a MAESTRO_FOLDER_KEY env var), or you can reuse an existing string-keyed config field if one already exists in the test environment.

Also, the error message on line 147 says 'INTEGRATION_TEST_FOLDER_ID must be configured' — if you add the new field, update the message to match.

config.folderId!
);

expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
if (result.length > 0) {
expect(result[0].id).toBeDefined();
}
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing a transform validation integration test. Per conventions: "Include a transform validation test for new methods with a transform pipeline. Verify: (a) transformed camelCase fields exist and have values, AND (b) original PascalCase API fields are absent."

Add a second test in this describe('start') block (guarded by the same processKey check):

it('should apply transform pipeline (camelCase fields present, PascalCase absent)', async () => {
  if (!processKey) { console.warn('...'); return; }
  const result = await maestroProcesses.start({ processKey: processKey! }, config.folderId!);
  if (result.length === 0) { console.warn('No jobs returned, skipping transform check'); return; }
  // camelCase present
  expect(result[0].key).toBeDefined();
  expect(result[0].state).toBeDefined();
  // PascalCase absent
  expect((result[0] as any).Key).toBeUndefined();
  expect((result[0] as any).State).toBeUndefined();
  expect((result[0] as any).ReleaseName).toBeUndefined();
});


describe('Process metadata validation', () => {
it('should have expected fields in process objects', async () => {
const { maestroProcesses } = getServices();
Expand Down
103 changes: 98 additions & 5 deletions tests/unit/services/maestro/processes.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
// ===== IMPORTS =====
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MaestroProcessesService } from '../../../../src/services/maestro/processes';
import { MAESTRO_ENDPOINTS } from '../../../../src/utils/constants/endpoints';
import { MAESTRO_ENDPOINTS, PROCESS_ENDPOINTS } from '../../../../src/utils/constants/endpoints';
import { ApiClient } from '../../../../src/core/http/api-client';
import {
import {
MAESTRO_TEST_CONSTANTS,
createMockProcess,
createMockProcess,
createMockProcessesApiResponse,
createMockError,
TEST_CONSTANTS
createMockError,
TEST_CONSTANTS,
createMockProcessStartResponse,
createMockProcessStartApiResponse,
} from '../../../utils/mocks';
import { PROCESS_TEST_CONSTANTS } from '../../../utils/constants';
import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup';
import { JobPriority, ProcessStartRequest } from '../../../../src/models/orchestrator/processes.types';
import { FOLDER_KEY } from '../../../../src/utils/constants/headers';
import { RequestOptions } from '../../../../src/models/common';

Check warning on line 19 in tests/unit/services/maestro/processes.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'RequestOptions'.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-typescript&issues=AZ2HaSi0dequgXJ-0Hbw&open=AZ2HaSi0dequgXJ-0Hbw&pullRequest=365

// ===== MOCKING =====
// Mock the dependencies
Expand Down Expand Up @@ -153,4 +159,91 @@
expect(result[0].name).toBe(MAESTRO_TEST_CONSTANTS.CUSTOM_PACKAGE_ID);
});
});

describe('start', () => {
it('should start process by processKey successfully with transformations applied', async () => {
const mockJob = createMockProcessStartResponse();
const mockResponse = createMockProcessStartApiResponse([mockJob]);
mockApiClient.post.mockResolvedValue(mockResponse);

const request = PROCESS_TEST_CONSTANTS.PROCESS_START_REQUEST as ProcessStartRequest;
const result = await service.start(request, MAESTRO_TEST_CONSTANTS.FOLDER_KEY);

expect(result).toBeDefined();
expect(result).toHaveLength(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing transform-completeness assertions. Per conventions: "Validate transform completeness — verify both: (a) transformed fields have correct values AND (b) original PascalCase fields are absent."

After the existing expect(result[0].state).toBe('Running'), add:

// Verify PascalCase originals are absent (transform regression guard)
expect((result[0] as any).Key).toBeUndefined();
expect((result[0] as any).ReleaseName).toBeUndefined();
expect((result[0] as any).State).toBeUndefined();

See the established pattern at assets.test.ts:94 and queues.test.ts:88.

expect(result[0].key).toBe(PROCESS_TEST_CONSTANTS.JOB_KEY);
expect(result[0].processName).toBe(PROCESS_TEST_CONSTANTS.PROCESS_NAME);
expect(result[0].state).toBe('Running');

expect(mockApiClient.post).toHaveBeenCalledWith(
PROCESS_ENDPOINTS.START_PROCESS,
Comment on lines +175 to +179
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per conventions, transform completeness tests must verify both that the SDK camelCase fields are present and that the original PascalCase fields from the API are absent. Without the absence check, a regression where pascalToCamelCaseKeys is dropped would still pass (the raw fields would just carry through under different names).

Suggested change
expect(result[0].processName).toBe(PROCESS_TEST_CONSTANTS.PROCESS_NAME);
expect(result[0].state).toBe('Running');
expect(mockApiClient.post).toHaveBeenCalledWith(
PROCESS_ENDPOINTS.START_PROCESS,
expect(result[0].key).toBe(PROCESS_TEST_CONSTANTS.JOB_KEY);
expect(result[0].processName).toBe(PROCESS_TEST_CONSTANTS.PROCESS_NAME);
expect(result[0].state).toBe('Running');
// Verify PascalCase originals were removed by the transform pipeline
expect((result[0] as any).ProcessName).toBeUndefined();
expect((result[0] as any).State).toBeUndefined();

See the established pattern in assets.test.ts:94, queues.test.ts:88, choicesets.test.ts:243.

expect.objectContaining({
startInfo: expect.objectContaining({
releaseKey: PROCESS_TEST_CONSTANTS.PROCESS_KEY,
jobPriority: JobPriority.Normal,
inputArguments: PROCESS_TEST_CONSTANTS.PROCESS_START_REQUEST.inputArguments
})
}),
expect.objectContaining({
headers: expect.objectContaining({
[FOLDER_KEY]: MAESTRO_TEST_CONSTANTS.FOLDER_KEY
}),
params: expect.any(Object)
})
);
});

it('should start process by processName successfully with transformations applied', async () => {
const mockJob = createMockProcessStartResponse();
const mockResponse = createMockProcessStartApiResponse([mockJob]);
mockApiClient.post.mockResolvedValue(mockResponse);

const request = PROCESS_TEST_CONSTANTS.PROCESS_START_REQUEST_WITH_NAME as ProcessStartRequest;
const result = await service.start(request, MAESTRO_TEST_CONSTANTS.FOLDER_KEY);

expect(result).toBeDefined();
expect(result).toHaveLength(1);
expect(result[0].key).toBe(PROCESS_TEST_CONSTANTS.JOB_KEY);

expect(mockApiClient.post).toHaveBeenCalledWith(
PROCESS_ENDPOINTS.START_PROCESS,
expect.objectContaining({
startInfo: expect.objectContaining({
releaseName: PROCESS_TEST_CONSTANTS.PROCESS_NAME,
jobPriority: JobPriority.High,
inputArguments: PROCESS_TEST_CONSTANTS.PROCESS_START_REQUEST_WITH_NAME.inputArguments
})
}),
expect.any(Object)
);
});

it('should handle multiple jobs returned from start', async () => {
const mockJobs = [
createMockProcessStartResponse(),
createMockProcessStartResponse({
key: PROCESS_TEST_CONSTANTS.JOB_KEY,
id: 2
})
];
const mockResponse = createMockProcessStartApiResponse(mockJobs);
mockApiClient.post.mockResolvedValue(mockResponse);

const request = PROCESS_TEST_CONSTANTS.PROCESS_START_REQUEST as ProcessStartRequest;
const result = await service.start(request, MAESTRO_TEST_CONSTANTS.FOLDER_KEY);

expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result[1].key).toBe(PROCESS_TEST_CONSTANTS.JOB_KEY);
});

it('should handle API errors', async () => {
const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE);
mockApiClient.post.mockRejectedValue(error);

const request = PROCESS_TEST_CONSTANTS.PROCESS_START_REQUEST as ProcessStartRequest;
await expect(service.start(request, MAESTRO_TEST_CONSTANTS.FOLDER_KEY))
.rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE);
});
});
});
Loading