diff --git a/apps/vs-code-designer/src/app/commands/registerCommands.ts b/apps/vs-code-designer/src/app/commands/registerCommands.ts index 4a6aea8ef4c..5a13e7696d4 100644 --- a/apps/vs-code-designer/src/app/commands/registerCommands.ts +++ b/apps/vs-code-designer/src/app/commands/registerCommands.ts @@ -66,9 +66,11 @@ import { pickCustomCodeNetHostProcess } from './pickCustomCodeNetHostProcess'; import { debugLogicApp } from './debugLogicApp'; import { syncCloudSettings } from './syncCloudSettings'; import { getDebugSymbolDll } from '../utils/getDebugSymbolDll'; +import { generateTests } from './workflows/unitTest/generateTests'; export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping(extensionCommand.openDesigner, openDesigner); + registerCommandWithTreeNodeUnwrapping(extensionCommand.generateTests, generateTests); registerCommandWithTreeNodeUnwrapping(extensionCommand.openFile, (context: IActionContext, node: FileTreeItem) => executeOnFunctions(openFile, context, context, node) ); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts index 71ddaea3986..d0ce3b37586 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts @@ -44,6 +44,7 @@ import { env, ProgressLocation, Uri, ViewColumn, window, workspace } from 'vscod import type { WebviewPanel, ProgressOptions } from 'vscode'; import { saveBlankUnitTest } from '../unitTest/saveBlankUnitTest'; import { getBundleVersionNumber } from '../../../utils/getDebugSymbolDll'; +import { generateTests } from '../unitTest/generateTests'; export default class OpenDesignerForLocalProject extends OpenDesignerBase { private readonly workflowFilePath: string; @@ -222,6 +223,12 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { }); break; } + case ExtensionCommand.generateTests: { + await callWithTelemetryAndErrorHandling('GenerateTestsFromDesigner', async (activateContext: IActionContext) => { + await generateTests(activateContext, Uri.file(this.workflowFilePath), msg.operationData); + }); + break; + } case ExtensionCommand.saveUnitTest: { await callWithTelemetryAndErrorHandling('SaveUnitTestFromDesigner', async (activateContext: IActionContext) => { await saveUnitTestDefinition(activateContext, this.projectPath, this.workflowName, this.unitTestName, msg.definition); diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/saveBlankUnitTest.test.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/saveBlankUnitTest.test.ts index 06bde647a0e..1f8cf8ce579 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/saveBlankUnitTest.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/saveBlankUnitTest.test.ts @@ -74,7 +74,7 @@ describe('saveBlankUnitTest', () => { vi.spyOn(workspaceUtils, 'getWorkspacePath').mockResolvedValue(dummyWorkspaceFolder.uri.fsPath); vi.spyOn(workspaceUtils, 'getWorkspaceFolder').mockResolvedValue(dummyWorkspaceFolder); vi.spyOn(projectRootUtils, 'tryGetLogicAppProjectRoot').mockResolvedValue(dummyProjectPath); - vi.spyOn(unitTestUtils, 'parseUnitTestOutputs').mockResolvedValue({} as any); + vi.spyOn(unitTestUtils, 'preprocessOutputParameters').mockResolvedValue({} as any); vi.spyOn(unitTestUtils, 'selectWorkflowNode').mockResolvedValue(dummyWorkflowNodeUri); vi.spyOn(unitTestUtils, 'promptForUnitTestName').mockResolvedValue(dummyUnitTestName); vi.spyOn(unitTestUtils, 'validateWorkflowPath').mockResolvedValue(); @@ -143,7 +143,7 @@ describe('saveBlankUnitTest', () => { test('should log an error and call handleError when an exception occurs', async () => { const testError = new Error('Test error'); - vi.spyOn(unitTestUtils, 'parseUnitTestOutputs').mockRejectedValueOnce(testError); + vi.spyOn(unitTestUtils, 'preprocessOutputParameters').mockRejectedValueOnce(testError); await saveBlankUnitTest(dummyContext, dummyNode, dummyUnitTestDefinition); diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts index f765eecba49..2d746fe33e6 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts @@ -16,7 +16,7 @@ import { handleError, logTelemetry, parseErrorBeforeTelemetry, - parseUnitTestOutputs, + preprocessOutputParameters, getOperationMockClassContent, promptForUnitTestName, selectWorkflowNode, @@ -40,14 +40,14 @@ import { syncCloudSettings } from '../../syncCloudSettings'; * @param {IActionContext} context - The action context. * @param {vscode.Uri | undefined} node - Optional URI of the workflow node. * @param {string | undefined} runId - Optional run ID. - * @param {any} unitTestDefinition - The unit test definition. + * @param {any} operationData - The original operation data with operationInfo and outputParameters. * @returns {Promise} Resolves when the unit test creation process completes. */ export async function createUnitTest( context: IActionContext, node: vscode.Uri | undefined, runId?: string, - unitTestDefinition?: any + operationData?: any ): Promise { try { // Validate and extract Run ID @@ -107,7 +107,7 @@ export async function createUnitTest( }); context.telemetry.properties.lastStep = 'generateUnitTestFromRun'; - await generateUnitTestFromRun(context, projectPath, workflowName, unitTestName, validatedRunId, unitTestDefinition, node.fsPath); + await generateUnitTestFromRun(context, projectPath, workflowName, unitTestName, validatedRunId, operationData, node.fsPath); context.telemetry.properties.result = 'Succeeded'; } catch (error) { handleError(context, error, 'createUnitTest'); @@ -122,7 +122,7 @@ export async function createUnitTest( * @param {string} workflowName - Name of the workflow. * @param {string} unitTestName - Name of the unit test. * @param {string} runId - Run ID. - * @param {any} unitTestDefinition - The unit test definition. + * @param {any} operationData - The original operation data with operationInfo and outputParameters. * @returns {Promise} Resolves when the unit test has been generated. */ async function generateUnitTestFromRun( @@ -131,7 +131,7 @@ async function generateUnitTestFromRun( workflowName: string, unitTestName: string, runId: string, - unitTestDefinition: any, + operationData: any, workflowPath: string ): Promise { // Initialize telemetry properties @@ -147,7 +147,7 @@ async function generateUnitTestFromRun( // Get parsed outputs context.telemetry.properties.lastStep = 'parseUnitTestOutputs'; - const parsedOutputs = await parseUnitTestOutputs(unitTestDefinition); + const parsedOutputs = await preprocessOutputParameters(operationData); const operationInfo = parsedOutputs['operationInfo']; const outputParameters = parsedOutputs['outputParameters']; logTelemetry(context, { diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts new file mode 100644 index 00000000000..63968470320 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -0,0 +1,285 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { ensureDirectoryInWorkspace, getWorkflowNode, getWorkspacePath } from '../../../utils/workspace'; +import { Uri } from 'vscode'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import { localize } from '../../../../localize'; +import * as fse from 'fs-extra'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { FlowGraph, type ParentPathNode, type PathNode } from '../../../utils/flowgraph'; +import { ext } from '../../../../extensionVariables'; +import { + createTestExecutorFile, + createTestSettingsConfigFile, + ensureCsproj, + getOperationMockClassContent, + getUnitTestPaths, + preprocessOutputParameters, + updateCsprojFile, + updateTestsSln, + validateWorkflowPath, +} from '../../../utils/unitTests'; +import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject'; +import { assetsFolderName, unitTestTemplatesFolderName } from '../../../../constants'; +import { syncCloudSettings } from '../../syncCloudSettings'; + +/** + * Generates unit tests for a Logic App workflow based on its execution paths. + * @param {IActionContext} context - The action context. + * @param {Uri | undefined} node - The URI of the workflow node, if available. + * @param {any} operationData - The original operation data with operationInfo and outputParameters. + * @returns {Promise} - A Promise that resolves when the unit tests are generated. + */ +export async function generateTests(context: IActionContext, node: Uri | undefined, operationData: any): Promise { + context.telemetry.properties.lastStep = 'getWorkflowNode'; + const workflowNode = getWorkflowNode(node); + if (!(workflowNode instanceof Uri)) { + const errorMessage = 'The workflow node is undefined. A valid workflow node is required to generate tests.'; + context.telemetry.properties.result = 'Failed'; + context.telemetry.properties.errorMessage = errorMessage; + throw new Error(localize('workflowNodeUndefined', errorMessage)); + } + + context.telemetry.properties.lastStep = 'readWorkflowDefinition'; + const workflowPath = workflowNode.fsPath; + const workflowContent = JSON.parse(await fse.readFile(workflowPath, 'utf8')) as Record; + const workflowDefinition = workflowContent.definition as Record; + context.telemetry.properties.lastStep = 'createFlowGraph'; + const workflowGraph = new FlowGraph(workflowDefinition); + context.telemetry.properties.lastStep = 'getAllExecutionPaths'; + const paths = workflowGraph.getAllExecutionPaths(); + ext.outputChannel.appendLog( + localize('generateTestsPaths', 'Generated {0} execution paths for workflow: {1}', paths.length, workflowPath) + ); + + context.telemetry.properties.lastStep = 'preprocessOutputParameters'; + const { operationInfo, outputParameters } = await preprocessOutputParameters(operationData); + context.telemetry.properties.lastStep = 'getWorkspacePath'; + const workspaceFolder = getWorkspacePath(workflowNode.fsPath); + context.telemetry.properties.lastStep = 'tryGetLogicAppProjectRoot'; + const projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); + context.telemetry.properties.lastStep = 'validateWorkflowPath'; + validateWorkflowPath(projectPath, workflowNode.fsPath); + const workflowName = path.basename(path.dirname(workflowNode.fsPath)); + context.telemetry.properties.lastStep = 'getUnitTestPaths'; + const { testsDirectory, logicAppName, logicAppTestFolderPath, workflowTestFolderPath, mocksFolderPath } = getUnitTestPaths( + projectPath, + workflowName + ); + context.telemetry.properties.lastStep = 'getOperationMockClassContent'; + const { mockClassContent, foundActionMocks, foundTriggerMocks } = await getOperationMockClassContent( + operationInfo, + outputParameters, + workflowNode.fsPath, + workflowName, + logicAppName + ); + + const workflowNameCleaned = workflowName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, ''); + const logicAppNameCleaned = logicAppName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, ''); + + context.telemetry.properties.lastStep = 'getTestCaseMethods'; + const testCaseMethods: string[] = []; + const testCaseData: string[] = []; + for (const [index, scenario] of paths.entries()) { + const triggerNode = scenario.path[0]; + const triggerMockOutputClassName = foundTriggerMocks[triggerNode.name]; + const triggerMockClassName = triggerMockOutputClassName?.replace(/(.*)Output$/, '$1Mock'); + if (!triggerMockOutputClassName) { + throw new Error(localize('generateTestsNoTriggerMock', 'No mock found for trigger: {0}', triggerNode.name)); + } + + const pathActions = scenario.path.slice(1); + const actionChain = getExecutedActionChain(pathActions); + const actionChainMockable = getMockableExecutedActions(actionChain, foundActionMocks); + const actionAssertions = (await Promise.all(pathActions.map((actionNode) => getActionAssertion(actionNode)))).flat(); + const pathName = getPathName(index, scenario.overallStatus); + const pathDescription = getPathDescription(actionChain); + + const testCaseMethodTemplateFileName = 'TestCaseMethod'; + const testCaseMethodTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, testCaseMethodTemplateFileName); + const testCaseMethodTemplate = await fse.readFile(testCaseMethodTemplatePath, 'utf-8'); + testCaseMethods.push( + testCaseMethodTemplate + .replace(/<%= WorkflowName %>/g, workflowName) + .replace(/<%= WorkflowNameCleaned %>/g, workflowNameCleaned) + .replace(/<%= PathDescriptionString %>/g, pathDescription) + .replace(/<%= PathName %>/g, pathName) + .replace(/<%= ActionAssertionsContent %>/g, actionAssertions.join('\n\n')) + .replace(/<%= PathOverallStatus %>/g, toTestWorkflowStatus(scenario.overallStatus)) + ); + + testCaseData.push(` /// + /// Test data for the workflow path: ${pathDescription} + /// + public static IEnumerable ${pathName}_TestData + { + get + { + yield return new object[] + { + new ${triggerMockClassName}(outputs: new ${triggerMockOutputClassName}()), + new Dictionary() + { + ${actionChainMockable.map((actionNode) => getTestDataActionMockEntry(actionNode, foundActionMocks)).join(`,\n${' '.repeat(24)}`)} + } + }; + } + }`); + } + + context.telemetry.properties.lastStep = 'ensureTestFolders'; + await Promise.all([fse.ensureDir(logicAppTestFolderPath), fse.ensureDir(workflowTestFolderPath), fse.ensureDir(mocksFolderPath)]); + + context.telemetry.properties.lastStep = 'createTestSettingsConfigFile'; + await createTestSettingsConfigFile(workflowTestFolderPath, workflowName, logicAppName); + context.telemetry.properties.lastStep = 'createTestExecutorFile'; + await createTestExecutorFile(logicAppTestFolderPath, logicAppNameCleaned); + + context.telemetry.properties.lastStep = 'createMockClasses'; + for (const [mockClassName, classContent] of Object.entries(mockClassContent)) { + const mockFilePath = path.join(mocksFolderPath, `${mockClassName}.cs`); + await fse.writeFile(mockFilePath, classContent, 'utf-8'); + } + + context.telemetry.properties.lastStep = 'writeTestClassFile'; + const testClassTemplateFileName = 'GenericTestClass'; + const testClassTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, testClassTemplateFileName); + const testClassTemplate = await fse.readFile(testClassTemplatePath, 'utf-8'); + const testClassContent = testClassTemplate + .replace(/<%= WorkflowName %>/g, workflowName) + .replace(/<%= LogicAppNameCleaned %>/g, logicAppNameCleaned) + .replace(/<%= WorkflowNameCleaned %>/g, workflowNameCleaned) + .replace(/<%= TestCaseData %>/g, testCaseData.join('\n\n')) + .replace(/<%= TestCaseMethods %>/g, testCaseMethods.join('\n\n')); + const csFilePath = path.join(workflowTestFolderPath, `${workflowNameCleaned}Tests.cs`); + await fse.writeFile(csFilePath, testClassContent); + + context.telemetry.properties.lastStep = 'ensureCsproj'; + await ensureCsproj(testsDirectory, logicAppTestFolderPath, logicAppName); + context.telemetry.properties.lastStep = 'updateCsprojFile'; + const csprojFilePath = path.join(logicAppTestFolderPath, `${logicAppName}.csproj`); + await updateCsprojFile(csprojFilePath, workflowName); + + context.telemetry.properties.lastStep = 'ensureTestsDirectoryInWorkspace'; + await ensureDirectoryInWorkspace(testsDirectory); + + context.telemetry.properties.lastStep = 'updateTestsSln'; + await updateTestsSln(testsDirectory, csprojFilePath); + + context.telemetry.properties.lastStep = 'syncCloudSettings'; + await syncCloudSettings(context, vscode.Uri.file(projectPath)); + + const successMessage = localize( + 'generateTestsSuccess', + 'Tests generated successfully for workflow "{0}" at: "{1}".', + workflowName, + logicAppTestFolderPath + ); + ext.outputChannel.appendLog(successMessage); + vscode.window.showInformationMessage(successMessage); +} + +/** + * Constructs the executed action chain (including nested actions in order) for a given path. + * @param {PathNode[]} path - The path to construct the action chain for. + * @returns {PathNode[]} - The constructed action chain. + */ +function getExecutedActionChain(path: PathNode[]): PathNode[] { + const actionChain: PathNode[] = []; + + for (const actionNode of path) { + if (actionNode.type === 'Switch' || actionNode.type === 'If' || actionNode.type === 'Scope') { + actionChain.push(...getExecutedActionChain((actionNode as ParentPathNode).actions)); + } else { + actionChain.push(actionNode); + } + } + + return actionChain; +} + +/** + * Filters the action chain to only include actions that are mockable. + * @param {PathNode[]} actionChain - The executed action chain. + * @param {Record} foundActionMocks - The found action mocks. + * @returns {PathNode[]} - The filtered action chain containing only mockable actions. + */ +function getMockableExecutedActions(actionChain: PathNode[], foundActionMocks: Record): PathNode[] { + return actionChain.filter((actionNode) => actionNode.name in foundActionMocks); +} + +/** + * Gets a string name for the action path. + * @param {number} index - The index of the path in the list of paths. + * @param {string} overallStatus - The overall status of the path. + * @returns {string} - A string name for the action path. + */ +function getPathName(index: number, overallStatus: string): string { + return `Path${index}_${overallStatus}`; +} + +/** + * Gets a string description of the action path. + * @param {PathNode[]} actionChain - The executed action chain. + * @returns {string} - A string description of the action path. + */ +function getPathDescription(actionChain: PathNode[]): string { + return actionChain.map((action) => `[${action.status}] ${action.name}`).join(' -> '); +} + +/** + * Gets all action assertions for a given action node, including nested actions if applicable. + * @param {PathNode} actionNode - The action node to get assertions for. + * @param {string} [nestedActionPath] - The nested action path on TestWorkflowRun object. + * @returns {Promise} - A Promise that resolves to an array of action assertion strings. + */ +async function getActionAssertion(actionNode: PathNode, nestedActionPath = ''): Promise { + const actionAssertionTemplateFileName = 'TestActionAssertion'; + const actionAssertionTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, actionAssertionTemplateFileName); + const actionAssertionTemplate = await fse.readFile(actionAssertionTemplatePath, 'utf-8'); + + const childActionAssertions = + actionNode.type === 'Switch' || actionNode.type === 'If' || actionNode.type === 'Scope' + ? ( + await Promise.all( + (actionNode as ParentPathNode).actions.map((childActionNode) => + getActionAssertion(childActionNode, `${nestedActionPath}["${actionNode.name}"].ChildActions`) + ) + ) + ).flat() + : []; + + return [ + actionAssertionTemplate + .replace(/<%= ActionName %>/g, actionNode.name) + .replace(/<%= ActionStatus %>/g, toTestWorkflowStatus(actionNode.status)) + .replace(/<%= NestedActionPath %>/g, nestedActionPath), + ...childActionAssertions, + ]; +} + +/** + * Gets a string representation of the action mock dictionary item for a given action node. + * @param {PathNode} actionNode - The action node to get the mock entry for. + * @param {Record} foundActionMocks - The found action mocks. + * @returns {string} - A string representation of the action mock dictionary item. + */ +function getTestDataActionMockEntry(actionNode: PathNode, foundActionMocks: Record): string { + const actionMockOutputClassName = foundActionMocks[actionNode.name]; + const actionMockClassName = actionMockOutputClassName?.replace(/(.*)Output$/, '$1Mock'); + + return `{ "${actionNode.name}", new ${actionMockClassName}(status: ${toTestWorkflowStatus(actionNode.status)}, outputs: new ${actionMockOutputClassName}()) }`; +} + +/** + * Converts a workflow status string to a TestWorkflowStatus enum string. + * @param {string} status - The workflow status to convert. + * @returns {string} - The corresponding TestWorkflowStatus enum string. + */ +function toTestWorkflowStatus(status: string): string { + return `TestWorkflowStatus.${status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}`; +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/saveBlankUnitTest.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/saveBlankUnitTest.ts index 1f2a088dc3b..bc5b364e3f7 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/saveBlankUnitTest.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/saveBlankUnitTest.ts @@ -14,7 +14,7 @@ import { logError, logSuccess, logTelemetry, - parseUnitTestOutputs, + preprocessOutputParameters, promptForUnitTestName, selectWorkflowNode, getOperationMockClassContent, @@ -35,10 +35,10 @@ import { syncCloudSettings } from '../../syncCloudSettings'; * Creates a unit test for a Logic App workflow (codeful only), with telemetry logging and error handling. * @param {IActionContext} context - The action context. * @param {vscode.Uri | undefined} node - The URI of the workflow node, if available. - * @param {any} unitTestDefinition - The definition of the unit test. + * @param {any} operationData - The original operation data with operationInfo and outputParameters. * @returns {Promise} - A Promise that resolves when the unit test is created. */ -export async function saveBlankUnitTest(context: IActionContext, node: vscode.Uri | undefined, unitTestDefinition: any): Promise { +export async function saveBlankUnitTest(context: IActionContext, node: vscode.Uri | undefined, operationData: any): Promise { const startTime = Date.now(); // Initialize telemetry properties @@ -89,7 +89,7 @@ export async function saveBlankUnitTest(context: IActionContext, node: vscode.Ur // Get parsed outputs context.telemetry.properties.lastStep = 'parseUnitTestOutputs'; - const parsedOutputs = await parseUnitTestOutputs(unitTestDefinition); + const parsedOutputs = await preprocessOutputParameters(operationData); const operationInfo = parsedOutputs['operationInfo']; const outputParameters = parsedOutputs['outputParameters']; logTelemetry(context, { diff --git a/apps/vs-code-designer/src/app/utils/flowgraph.ts b/apps/vs-code-designer/src/app/utils/flowgraph.ts new file mode 100644 index 00000000000..01cb83edc78 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/flowgraph.ts @@ -0,0 +1,478 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../localize'; + +type Attributes = Record; + +type Edge = { + to: string; + attr: Attributes; +}; + +type FlowActionStatus = 'SUCCEEDED' | 'FAILED' | 'TIMEDOUT' | 'SKIPPED'; + +// TODO(aeldridge): Support paths for all possible flow run statuses +type FlowPathOverallStatus = 'SUCCEEDED' | 'FAILED'; + +export type FlowPath = { + overallStatus: FlowPathOverallStatus; + path: PathNode[]; +}; + +export type PathNode = { + name: string; + type: string; + status: FlowActionStatus; +}; + +export type ParentPathNode = PathNode & { + actions: PathNode[]; +}; + +export type IfPathNode = ParentPathNode & { + conditionResult: boolean; +}; + +export type SwitchPathNode = ParentPathNode & { + caseResult: string; + isDefaultCase: boolean; +}; + +export type ScopePathNode = ParentPathNode; + +const unsupportedActions = new Set(['ForEach', 'Until', 'Terminate']); +const validStatuses = new Set(['SUCCEEDED', 'FAILED']); + +export class FlowGraph { + private static Subgraph(): FlowGraph { + return new FlowGraph(null); + } + + private static getPathOverallStatus(path: PathNode[]): FlowPathOverallStatus { + return path.some((pathNode) => pathNode.status !== 'SUCCEEDED') ? 'FAILED' : 'SUCCEEDED'; + } + + private static shouldExpandSucc( + path: PathNode[], + currNodeStatus: FlowActionStatus, + succ: string, + succRunAfter: FlowActionStatus[] + ): boolean { + const pathNodeIds = path.map((pathNode) => pathNode.name); + return !pathNodeIds.includes(succ) && succRunAfter.includes(currNodeStatus); + } + + private static isValidSubpath(subpath: PathNode[], parentStatus: FlowActionStatus): boolean { + // TODO(aeldridge): Need to consider other action statuses which correspond to Failed overall status + return FlowGraph.getPathOverallStatus(subpath) === parentStatus; + } + + private nodes: Map; + private edges: Map; + private triggerName: string | undefined; + + public constructor(workflowDefinition: Record) { + this.nodes = new Map(); + this.edges = new Map(); + if (workflowDefinition !== null) { + const [[triggerName, trigger]] = Object.entries(workflowDefinition['triggers']); + this.triggerName = triggerName; + this.addNode(this.triggerName, { type: trigger['type'] }); + for (const [actionName, action] of Object.entries(workflowDefinition['actions'])) { + this.addNodeRec(actionName, action); + } + for (const [actionName, actionNode] of this.nodes.entries()) { + if (actionName === this.triggerName) { + continue; + } + this.addEdgesForNodeRec(actionName, actionNode, workflowDefinition['actions'][actionName]); + } + } + } + + public addNode(id: string, attr: Attributes = {}) { + if (this.nodes.has(id)) { + Object.assign(this.nodes.get(id)!, attr); + } else { + this.nodes.set(id, attr); + this.edges.set(id, []); + } + } + + public getNode(id: string): Attributes | undefined { + return this.nodes.get(id); + } + + public addEdge(from: string, to: string, attr: Attributes = {}) { + if (!this.nodes.has(from) || !this.nodes.has(to)) { + throw new Error(`Cannot add edge from ${from} to ${to}: node(s) missing`); + } + this.edges.get(from)!.push({ to, attr: attr }); + } + + public getEdge(from: string, to: string): Edge | undefined { + return this.edges.get(from)?.find((e) => e.to === to); + } + + public getNodeIds(): string[] { + return Array.from(this.nodes.keys()); + } + + public getSuccessors(id: string): string[] { + return this.edges.get(id)?.map((e) => e.to) || []; + } + + public getOutgoingEdges(id: string): Edge[] { + return this.edges.get(id) || []; + } + + public getInDegree(id: string): number { + let count = 0; + for (const edgeList of this.edges.values()) { + if (edgeList.some((e) => e.to === id)) { + count++; + } + } + return count; + } + + public getStartNode(): string | undefined { + const startNodes = this.getNodeIds().filter((id) => this.getInDegree(id) === 0); + if (startNodes.length > 1) { + throw new Error(`Multiple start nodes in scope not allowed: ${startNodes.join(', ')}.`); + } + return startNodes.length === 1 ? startNodes[0] : undefined; + } + + public toJSON() { + return { + nodes: Array.from(this.nodes.entries()).map(([id, attrs]) => ({ id, attrs })), + edges: Array.from(this.edges.entries()).flatMap(([from, edgeList]) => + edgeList.map((edge) => ({ from, to: edge.to, attrs: edge.attr })) + ), + }; + } + + public isTerminalNode(id: string, currPathNodeStatus: FlowActionStatus): boolean { + let hasRunAfterCurrStatus = false; + for (const edge of this.getOutgoingEdges(id)) { + if ((edge.attr['runAfter'] as FlowActionStatus[]).includes(currPathNodeStatus)) { + hasRunAfterCurrStatus = true; + break; + } + } + return this.getSuccessors(id).length === 0 || !hasRunAfterCurrStatus; + } + + public getAllExecutionPaths(): FlowPath[] { + const paths = this.getAllExecutionPathsRec(this.triggerName, true); + return paths.map((path) => ({ + overallStatus: FlowGraph.getPathOverallStatus(path), + path: path, + })); + } + + private getAllExecutionPathsRec(startNodeId: string, isTrigger = false): PathNode[][] { + if (startNodeId === undefined) { + return []; + } + + const paths: PathNode[][] = []; + + const dfsSwitch = (nodeId: string, path: PathNode[]) => { + const nodeData = this.getNode(nodeId)!; + const basePathNode = path.pop()!; + + const graphDefaultCase = nodeData['default'] as FlowGraph; + const pathsDefaultCase = graphDefaultCase.getAllExecutionPathsRec(graphDefaultCase.getStartNode()); + for (const subpathDefaultCase of pathsDefaultCase) { + const currPathNode = { + ...basePathNode, + caseResult: 'default', + isDefaultCase: true, + actions: subpathDefaultCase, + } as SwitchPathNode; + path.push(currPathNode); + + if (FlowGraph.isValidSubpath(subpathDefaultCase, currPathNode.status)) { + if (this.isTerminalNode(nodeId, currPathNode.status)) { + paths.push(path.slice()); + } else { + for (const edge of this.getOutgoingEdges(nodeId)) { + if (FlowGraph.shouldExpandSucc(path, currPathNode.status, edge.to, edge.attr['runAfter'])) { + dfs(edge.to, path); + } + } + } + } + + path.pop(); + } + + const graphCasesMap = nodeData['cases'] as Map; + for (const [caseName, graphCase] of graphCasesMap) { + const pathsCase = graphCase.getAllExecutionPathsRec(graphCase.getStartNode()); + for (const subpathCase of pathsCase) { + const currPathNode = { + ...basePathNode, + caseResult: caseName, + isDefaultCase: false, + actions: subpathCase, + } as SwitchPathNode; + path.push(currPathNode); + + if (FlowGraph.isValidSubpath(subpathCase, currPathNode.status)) { + if (this.isTerminalNode(nodeId, currPathNode.status)) { + paths.push(path.slice()); + } else { + for (const edge of this.getOutgoingEdges(nodeId)) { + if (FlowGraph.shouldExpandSucc(path, currPathNode.status, edge.to, edge.attr['runAfter'])) { + dfs(edge.to, path); + } + } + } + } + + path.pop(); + } + } + }; + + const dfsIf = (nodeId: string, path: PathNode[]) => { + const nodeData = this.getNode(nodeId)!; + const basePathNode = path.pop()!; + + const graphTrueBranch = nodeData['trueBranch'] as FlowGraph; + const pathsTrueBranch = graphTrueBranch.getAllExecutionPathsRec(graphTrueBranch.getStartNode()); + for (const subpathTrueBranch of pathsTrueBranch) { + const currPathNode = { + ...basePathNode, + conditionResult: true, + actions: subpathTrueBranch, + } as IfPathNode; + path.push(currPathNode); + + if (FlowGraph.isValidSubpath(subpathTrueBranch, currPathNode.status)) { + if (this.isTerminalNode(nodeId, currPathNode.status)) { + paths.push(path.slice()); + } else { + for (const edge of this.getOutgoingEdges(nodeId)) { + if (FlowGraph.shouldExpandSucc(path, currPathNode.status, edge.to, edge.attr['runAfter'])) { + dfs(edge.to, path); + } + } + } + } + + path.pop(); + } + + const graphFalseBranch = nodeData['falseBranch'] as FlowGraph; + const pathsFalseBranch = graphFalseBranch.getAllExecutionPathsRec(graphFalseBranch.getStartNode()); + for (const subpathFalseBranch of pathsFalseBranch) { + const currPathNode = { + ...basePathNode, + conditionResult: false, + actions: subpathFalseBranch, + } as IfPathNode; + path.push(currPathNode); + + if (FlowGraph.isValidSubpath(subpathFalseBranch, currPathNode.status)) { + if (this.isTerminalNode(nodeId, currPathNode.status)) { + paths.push(path.slice()); + } else { + for (const edge of this.getOutgoingEdges(nodeId)) { + if (FlowGraph.shouldExpandSucc(path, currPathNode.status, edge.to, edge.attr['runAfter'])) { + dfs(edge.to, path); + } + } + } + } + + path.pop(); + } + }; + + const dfsScope = (nodeId: string, path: PathNode[]) => { + const nodeData = this.getNode(nodeId)!; + const basePathNode = path.pop()!; + + const graphScope = nodeData['scope'] as FlowGraph; + const pathsScope = graphScope.getAllExecutionPathsRec(graphScope.getStartNode()); + for (const subpathScope of pathsScope) { + const currPathNode = { + ...basePathNode, + actions: subpathScope, + } as ScopePathNode; + path.push(currPathNode); + + if (FlowGraph.isValidSubpath(subpathScope, currPathNode.status)) { + if (this.isTerminalNode(nodeId, currPathNode.status)) { + paths.push(path.slice()); + } else { + for (const edge of this.getOutgoingEdges(nodeId)) { + if (FlowGraph.shouldExpandSucc(path, currPathNode.status, edge.to, edge.attr['runAfter'])) { + dfs(edge.to, path); + } + } + } + } + + path.pop(); + } + }; + + const dfsInner = (nodeId: string, status: FlowActionStatus, path: PathNode[]) => { + const nodeData = this.getNode(nodeId)!; + const nodeType = nodeData['type']; + path.push({ + name: nodeId, + type: nodeType, + status: status, + }); + + if (nodeType === 'Switch') { + dfsSwitch(nodeId, path); + return; + } + + if (nodeType === 'If') { + dfsIf(nodeId, path); + return; + } + + if (nodeType === 'Scope') { + dfsScope(nodeId, path); + return; + } + + if (this.isTerminalNode(nodeId, status)) { + paths.push(path.slice()); + } else { + for (const edge of this.getOutgoingEdges(nodeId)) { + if (FlowGraph.shouldExpandSucc(path, status, edge.to, edge.attr['runAfter'])) { + dfs(edge.to, path); + } + } + } + + path.pop(); + }; + + const dfs = (nodeId: string, path: PathNode[], isTriggerNode = false) => { + if (isTriggerNode) { + dfsInner(nodeId, 'SUCCEEDED', path); + } else { + for (const status of validStatuses) { + dfsInner(nodeId, status, path); + } + } + }; + + dfs(startNodeId, [], isTrigger); + return paths; + } + + private addNodeRec(actionName: string, action: Record) { + const actionType = action['type']; + if (unsupportedActions.has(actionType)) { + throw new Error(localize('unsupportedAction', `Unsupported action type: "${actionType}".`)); + } + + if (actionType === 'Switch') { + const graphDefaultCase = FlowGraph.Subgraph(); + const actionsDefaultCase = action['default']['actions']; + for (const [childActionName, childAction] of Object.entries(actionsDefaultCase)) { + graphDefaultCase.addNodeRec(childActionName, childAction); + } + + const graphCasesMap = new Map(); + for (const [caseName, caseVal] of Object.entries(action['cases'])) { + const graphCase = FlowGraph.Subgraph(); + const actionsCase = caseVal['actions']; + for (const [childActionName, childAction] of Object.entries(actionsCase)) { + graphCase.addNodeRec(childActionName, childAction); + } + graphCasesMap.set(caseName, graphCase); + } + + this.addNode(actionName, { type: actionType, default: graphDefaultCase, cases: graphCasesMap }); + } else if (actionType === 'If') { + const graphTrueBranch = FlowGraph.Subgraph(); + const actionsTrueBranch = action['actions']; + for (const [childActionName, childAction] of Object.entries(actionsTrueBranch)) { + graphTrueBranch.addNodeRec(childActionName, childAction); + } + + const graphFalseBranch = FlowGraph.Subgraph(); + const actionsFalseBranch = action['else']['actions']; + for (const [childActionName, childAction] of Object.entries(actionsFalseBranch)) { + graphFalseBranch.addNodeRec(childActionName, childAction); + } + + this.addNode(actionName, { type: actionType, trueBranch: graphTrueBranch, falseBranch: graphFalseBranch }); + } else if (actionType === 'Scope') { + const graphScope = FlowGraph.Subgraph(); + for (const [childActionName, childAction] of Object.entries(action['actions'])) { + graphScope.addNodeRec(childActionName, childAction); + } + + this.addNode(actionName, { type: actionType, scope: graphScope }); + } else { + this.addNode(actionName, { type: actionType }); + } + } + + private addEdgesForNodeRec(actionName: string, actionNode: Attributes, action: Record, isChildAction = false) { + const actionType = action['type']; + if (actionType === 'Switch') { + const graphDefaultCase = actionNode['default'] as FlowGraph; + const actionsDefaultCase = action['default']['actions']; + for (const [childActionName, childAction] of Object.entries(actionsDefaultCase)) { + graphDefaultCase.addEdgesForNodeRec(childActionName, graphDefaultCase.getNode(childActionName), childAction, true); + } + + const graphCasesMap = actionNode['cases'] as Map; + for (const [caseName, caseVal] of Object.entries(action['cases'])) { + const graphCase = graphCasesMap.get(caseName)!; + const actionsCase = caseVal['actions']; + for (const [childActionName, childAction] of Object.entries(actionsCase)) { + graphCase.addEdgesForNodeRec(childActionName, graphCase.getNode(childActionName), childAction, true); + } + } + } else if (actionType === 'If') { + const graphTrueBranch = actionNode['trueBranch'] as FlowGraph; + const actionsTrueBranch = action['actions']; + for (const [childActionName, childAction] of Object.entries(actionsTrueBranch)) { + graphTrueBranch.addEdgesForNodeRec(childActionName, graphTrueBranch.getNode(childActionName), childAction, true); + } + + const graphFalseBranch = actionNode['falseBranch'] as FlowGraph; + const actionsFalseBranch = action['else']['actions']; + for (const [childActionName, childAction] of Object.entries(actionsFalseBranch)) { + graphFalseBranch.addEdgesForNodeRec(childActionName, graphFalseBranch.getNode(childActionName), childAction, true); + } + } else if (actionType === 'Scope') { + const graphScope = actionNode['scope'] as FlowGraph; + for (const [childActionName, childAction] of Object.entries(action['actions'])) { + graphScope.addEdgesForNodeRec(childActionName, graphScope.getNode(childActionName), childAction, true); + } + } + + if ('runAfter' in action && Object.keys(action['runAfter']).length > 0) { + const runAfter = action['runAfter']; + if (Object.keys(runAfter).length > 1) { + throw new Error( + localize('invalidRunAfter', 'Multiple "runAfter" not supported on action "{0}": {1}', actionName, JSON.stringify(runAfter)) + ); + } + + const [[prevActionName, runAfterStatuses]] = Object.entries(runAfter); + this.addEdge(prevActionName, actionName, { runAfter: runAfterStatuses }); + } else if (!isChildAction) { + this.addEdge(this.triggerName!, actionName, { runAfter: ['SUCCEEDED'] }); + } + } +} diff --git a/apps/vs-code-designer/src/app/utils/unitTests.ts b/apps/vs-code-designer/src/app/utils/unitTests.ts index c943db2707a..e3776deb191 100644 --- a/apps/vs-code-designer/src/app/utils/unitTests.ts +++ b/apps/vs-code-designer/src/app/utils/unitTests.ts @@ -689,10 +689,10 @@ export function parseErrorBeforeTelemetry(error: any): string { /** * Parses and transforms raw output parameters from a unit test definition into a structured format. - * @param unitTestDefinition - The unit test definition object. - * @returns A Promise resolving to an object containing operationInfo and outputParameters. + * @param operationData - The original operation data with operationInfo and outputParameters. + * @returns A Promise resolving to an object containing the processed operationInfo and outputParameters. */ -export async function parseUnitTestOutputs(unitTestDefinition: any): Promise<{ +export async function preprocessOutputParameters(operationData: any): Promise<{ operationInfo: any; outputParameters: Record; }> { @@ -741,13 +741,13 @@ export async function parseUnitTestOutputs(unitTestDefinition: any): Promise<{ }; const parsedOutputs: { operationInfo: any; outputParameters: any } = { - operationInfo: unitTestDefinition['operationInfo'], + operationInfo: operationData['operationInfo'], outputParameters: {}, }; - for (const parameterKey in unitTestDefinition['outputParameters']) { + for (const parameterKey in operationData['outputParameters']) { parsedOutputs.outputParameters[parameterKey] = { - outputs: transformRawOutputs(unitTestDefinition['outputParameters'][parameterKey].outputs), + outputs: transformRawOutputs(operationData['outputParameters'][parameterKey].outputs), }; } return parsedOutputs; diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass new file mode 100644 index 00000000000..626bc652ed6 --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass @@ -0,0 +1,32 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Azure.Workflows.Common.ErrorResponses; +using Microsoft.Azure.Workflows.UnitTesting; +using Microsoft.Azure.Workflows.UnitTesting.Definitions; +using Microsoft.Azure.Workflows.UnitTesting.ErrorResponses; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using <%= LogicAppNameCleaned %>.Tests.Mocks.<%= WorkflowNameCleaned %>; + +namespace <%= LogicAppNameCleaned %>.Tests +{ + [TestClass] + public class <%= WorkflowNameCleaned %>Tests + { + private TestExecutor TestExecutor { get; set; } + + [TestInitialize] + public void Initialize() + { + this.TestExecutor = new TestExecutor("<%= WorkflowName %>/testSettings.config"); + } + +<%= TestCaseData %> + +<%= TestCaseMethods %> + } +} \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion new file mode 100644 index 00000000000..31fb3166fc5 --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion @@ -0,0 +1,2 @@ + Assert.IsTrue(testRun.Actions<%= NestedActionPath %>.ContainsKey("<%= ActionName %>")); + Assert.AreEqual(expected: <%= ActionStatus %>, actual: testRun.Actions<%= NestedActionPath %>["<%= ActionName %>"].Status); \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod new file mode 100644 index 00000000000..06c27a661dc --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod @@ -0,0 +1,25 @@ + /// + /// Test case for workflow <%= WorkflowName %> on path: <%= PathDescriptionString %> + /// + [TestMethod] + [DynamicData(nameof(<%= PathName %>_TestData))] + public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>(TriggerMock triggerMock, Dictionary actionMocks) + { + // ACT + // Create an instance of UnitTestExecutor, and run the workflow with the mock data + var testMock = new TestMockDefinition( + triggerMock: triggerMock, + actionMocks: actionMocks); + var testRun = await this.TestExecutor + .Create() + .RunWorkflowAsync(testMock: testMock) + .ConfigureAwait(continueOnCapturedContext: false); + + // ASSERT + // Verify that the workflow executed with expected status + Assert.IsNotNull(value: testRun); + Assert.AreEqual(expected: <%= PathOverallStatus %>, actual: testRun.Status); + Assert.AreEqual(expected: TestWorkflowStatus.Succeeded, actual: testRun.Trigger.Status); + +<%= ActionAssertionsContent %> + } \ No newline at end of file diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index bd3368f0df0..a9adeff578c 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -43,6 +43,7 @@ export const testResultsDirectoryName = '.testResults'; export const vscodeFolderName = '.vscode'; export const assetsFolderName = 'assets'; export const deploymentScriptTemplatesFolderName = 'DeploymentScriptTemplates'; +export const unitTestTemplatesFolderName = 'UnitTestTemplates'; export const logicAppsStandardExtensionId = 'ms-azuretools.vscode-azurelogicapps'; @@ -121,6 +122,7 @@ export const designerApiLoadTimeout = 300000; // Commands export const extensionCommand = { openDesigner: 'azureLogicAppsStandard.openDesigner', + generateTests: 'azureLogicAppsStandard.generateTests', activate: 'azureLogicAppsStandard.activate', viewContent: 'azureLogicAppsStandard.viewContent', openFile: 'azureLogicAppsStandard.openFile', diff --git a/apps/vs-code-designer/src/package.json b/apps/vs-code-designer/src/package.json index 4fec679b472..3b9f68b1973 100644 --- a/apps/vs-code-designer/src/package.json +++ b/apps/vs-code-designer/src/package.json @@ -49,6 +49,11 @@ "title": "Open designer", "category": "Azure Logic Apps" }, + { + "command": "azureLogicAppsStandard.generateTests", + "title": "Generate tests", + "category": "Azure Logic Apps" + }, { "command": "azureLogicAppsStandard.viewContent", "title": "View content", @@ -690,11 +695,21 @@ "when": "resourceFilename==workflow.json", "group": "navigation@1" }, + { + "command": "azureLogicAppsStandard.enableAzureConnectors", + "when": "resourceFilename==workflow.json", + "group": "navigation@2" + }, { "command": "azureLogicAppsStandard.openDesigner", "when": "resourceFilename==workflow.json", "group": "navigation@3" }, + { + "command": "azureLogicAppsStandard.generateTests", + "when": "resourceFilename==workflow.json", + "group": "navigation@4" + }, { "command": "azureLogicAppsStandard.switchToDotnetProject", "when": "explorerResourceIsRoot == true", @@ -725,11 +740,6 @@ "when": "resourceFilename==local.settings.json", "group": "zzz_appSettings@3" }, - { - "command": "azureLogicAppsStandard.enableAzureConnectors", - "when": "resourceFilename==workflow.json", - "group": "navigation@2" - }, { "command": "azureLogicAppsStandard.dataMap.loadDataMapFile", "group": "navigation", @@ -741,6 +751,10 @@ "command": "azureLogicAppsStandard.openDesigner", "when": "resourceFilename==workflow.json" }, + { + "command": "azureLogicAppsStandard.generateTests", + "when": "resourceFilename==workflow.json" + }, { "command": "azureLogicAppsStandard.viewContent", "when": "never" @@ -1024,6 +1038,7 @@ ], "activationEvents": [ "onCommand:azureLogicAppsStandard.openDesigner", + "onCommand:azureLogicAppsStandard.generateTests", "onCommand:azureLogicAppsStandard.viewContent", "onCommand:azureLogicAppsStandard.createNewProject", "onCommand:azureLogicAppsStandard.createNewWorkspace", diff --git a/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx b/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx index 73b7cbaea0d..3e1cd3c8613 100644 --- a/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx +++ b/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx @@ -48,6 +48,7 @@ const SaveIcon = bundleIcon(SaveFilled, SaveRegular); const ParametersIcon = bundleIcon(MentionBracketsFilled, MentionBracketsRegular); const ConnectionsIcon = bundleIcon(LinkFilled, LinkRegular); const SaveBlankUnitTestIcon = bundleIcon(BeakerFilled, BeakerRegular); +const GenerateTestsIcon = bundleIcon(BeakerFilled, BeakerRegular); // Base icons const BugIcon = bundleIcon(BugFilled, BugRegular); @@ -148,6 +149,21 @@ export const DesignerCommandBar: React.FC = ({ }); }); + const { isLoading: isGeneratingTests, mutate: generateTestsMutate } = useMutation(async () => { + const designerState = DesignerStore.getState(); + const operationData = await getNodeOutputOperations(designerState); + + vscode.postMessage({ + command: ExtensionCommand.logTelemetry, + data: { name: 'GenerateTests', timestamp: Date.now(), operationData: operationData }, + }); + + await vscode.postMessage({ + command: ExtensionCommand.generateTests, + operationData, + }); + }); + const onResubmit = async () => { vscode.postMessage({ command: ExtensionCommand.resubmitRun, @@ -230,6 +246,11 @@ export const DesignerCommandBar: React.FC = ({ id: 'SUX3dO', description: 'Button test for save blank unit test', }), + GENERATE_UNIT_TESTS: intl.formatMessage({ + defaultMessage: 'Generate tests', + id: 'UpCa6n', + description: 'Button text for generate tests', + }), }; const allInputErrors = (Object.entries(designerState.operations.inputParameters) ?? []).filter(([_id, nodeInputs]) => @@ -338,6 +359,17 @@ export const DesignerCommandBar: React.FC = ({ saveBlankUnitTestMutate(); }, }, + { + key: 'GenerateTests', + disabled: isSaveBlankUnitTestDisabled, + text: Resources.GENERATE_UNIT_TESTS, + ariaLabel: Resources.GENERATE_UNIT_TESTS, + icon: isGeneratingTests ? : , + renderTextIcon: null, + onClick: () => { + generateTestsMutate(); + }, + }, ...baseItems, ]; diff --git a/libs/vscode-extension/src/lib/models/extensioncommand.ts b/libs/vscode-extension/src/lib/models/extensioncommand.ts index 4c3eed94747..bdba2485293 100644 --- a/libs/vscode-extension/src/lib/models/extensioncommand.ts +++ b/libs/vscode-extension/src/lib/models/extensioncommand.ts @@ -44,6 +44,7 @@ export const ExtensionCommand = { webviewRscLoadError: 'webviewRscLoadError', saveUnitTest: 'saveUnitTest', saveBlankUnitTest: 'saveBlankUnitTest', + generateTests: 'generateTests', createUnitTest: 'createUnitTest', viewWorkflow: 'viewWorkflow', openRelativeLink: 'openRelativeLink',