From cd174bbff981497ee9e9cc8f7c8f5a49c1b67d77 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Fri, 25 Jul 2025 11:50:06 -0400 Subject: [PATCH 01/11] initial commit for generateTests command --- .../src/app/commands/registerCommands.ts | 2 ++ .../workflows/unitTest/generateTests.ts | 20 +++++++++++++++ apps/vs-code-designer/src/constants.ts | 1 + apps/vs-code-designer/src/package.json | 25 +++++++++++++++---- 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts 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/unitTest/generateTests.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts new file mode 100644 index 00000000000..6953058c03f --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { getWorkflowNode } from '../../../utils/workspace'; +import { Uri } from 'vscode'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import { localize } from '../../../../localize'; + +export async function generateTests(context: IActionContext, node: Uri | undefined): Promise { + 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)); + } + + // TODO(aeldridge): Implement +} diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index bd3368f0df0..6599d7d41b9 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -121,6 +121,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", From 95f0920f76e10031c0f66cc3161a1d1541883179 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Sat, 26 Jul 2025 00:16:47 -0400 Subject: [PATCH 02/11] add FlowGraph class to get execution paths --- .../workflows/unitTest/generateTests.ts | 12 + .../src/app/utils/flowgraph.ts | 390 ++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 apps/vs-code-designer/src/app/utils/flowgraph.ts 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 index 6953058c03f..5e336215cf3 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -6,6 +6,9 @@ import { getWorkflowNode } 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 { FlowGraph } from '../../../utils/flowgraph'; +import { ext } from '../../../../extensionVariables'; export async function generateTests(context: IActionContext, node: Uri | undefined): Promise { const workflowNode = getWorkflowNode(node); @@ -16,5 +19,14 @@ export async function generateTests(context: IActionContext, node: Uri | undefin throw new Error(localize('workflowNodeUndefined', errorMessage)); } + const workflowPath = workflowNode.fsPath; + const workflowContent = JSON.parse(await fse.readFile(workflowPath, 'utf8')) as Record; + const workflowDefinition = workflowContent.definition as Record; + const workflowGraph = new FlowGraph(workflowDefinition); + const paths = workflowGraph.getAllExecutionPaths(); + ext.outputChannel.appendLog( + localize('generateTestsPaths', 'Generated {0} execution paths for workflow: {1}', paths.length, workflowPath) + ); + // TODO(aeldridge): Implement } 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..cd948f0e8ee --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/flowgraph.ts @@ -0,0 +1,390 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +type FlowPath = { + overallStatus: FlowPathOverallStatus; + path: PathNode[]; +}; + +type PathNode = { + name: string; + type: string; + status: FlowActionStatus; +}; + +type IfPathNode = PathNode & { + conditionResult: boolean; + actions: PathNode[]; +}; + +type SwitchPathNode = PathNode & { + caseResult: string; + isDefaultCase: boolean; + actions: PathNode[]; +}; + +const unsupportedActions = new Set(['Scope', 'ForEach', 'Until']); +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); + } + } + } + + 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 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 (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, isChildAction = false) { + 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, true); + } + + 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, true); + } + 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, true); + } + + const graphFalseBranch = FlowGraph.Subgraph(); + const actionsFalseBranch = action['else']['actions']; + for (const [childActionName, childAction] of Object.entries(actionsFalseBranch)) { + graphFalseBranch.addNodeRec(childActionName, childAction, true); + } + + this.addNode(actionName, { type: actionType, trueBranch: graphTrueBranch, falseBranch: graphFalseBranch }); + } else { + this.addNode(actionName, { type: actionType }); + } + + 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'] }); + } + } +} From 425210da60595140777ec3bb9440fbae4ce6aa73 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 28 Jul 2025 01:28:59 -0400 Subject: [PATCH 03/11] add templates for GenerateTests --- .../workflows/unitTest/generateTests.ts | 8 ++++- .../assets/UnitTestTemplates/GenericTestClass | 28 +++++++++++++++ .../UnitTestTemplates/TestActionAssertion | 2 ++ .../assets/UnitTestTemplates/TestActionMock | 2 ++ .../assets/UnitTestTemplates/TestCaseMethod | 34 +++++++++++++++++++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass create mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion create mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock create mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod 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 index 5e336215cf3..57c4f995e9d 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -28,5 +28,11 @@ export async function generateTests(context: IActionContext, node: Uri | undefin localize('generateTestsPaths', 'Generated {0} execution paths for workflow: {1}', paths.length, workflowPath) ); - // TODO(aeldridge): Implement + // TODO(aeldridge): Generate tests from paths using the following templates: + // - GenericTestClass (top-level test class template) + // -- TestCaseMethod[] (template for individual methods in test class, one per path) + // --- TestActionMock[] (template for individual action mocks in a test case, one per mockable action) + // --- TestActionAssertion[] (template for assertions corresponding to actions, one per action in path) + + // TODO(aeldridge): We should be able to repurpose the existing saveBlankUnitTest and "getOperationMockClassContent" to create required files } 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..ec5ad478e07 --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass @@ -0,0 +1,28 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +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 <%= LogicAppName %>.Tests.Mocks.<%= SanitizedWorkflowName %>; + +namespace <%= LogicAppName %>.Tests +{ + [TestClass] + public class GeneratedLogicAppWorkflowTests + { + private TestExecutor TestExecutor { get; set; } + + [TestInitialize] + public void Initialize() + { + this.TestExecutor = new TestExecutor("<%= WorkflowName %>/testSettings.config"); + } + + <%= TestClassContent %> + } +} \ 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..687db22f4eb --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion @@ -0,0 +1,2 @@ +Assert.IsTrue(testRun.ActionResults.ContainsKey("<%= ActionMockName %>")); +Assert.AreEqual(expected: <%= ActionMockStatus %>, actual: testRun.ActionResults["<%= ActionMockName %>"].Status); \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock new file mode 100644 index 00000000000..5bd6635bd52 --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock @@ -0,0 +1,2 @@ +var actionMockOutput = new <%= ActionMockOutputClassName %>(); +var actionMock = new <%= ActionMockClassName %>(status: <%= ActionMockStatus %>, name: "<%= ActionMockName %>", outputs: actionMockOutput); \ 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..37d52f25fd9 --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod @@ -0,0 +1,34 @@ +/// +/// Test case for workflow <%= WorkflowName %> on path: <%= PathString %> +/// +[TestMethod] +[DynamicData(nameof(<%= PathName %>_TestData))] +public async Task <%= WorkflowName %>_<%= PathName %>_ExecuteWorkflow( + Dictionary triggerParameters, + Dictionary> actionParameters) +{ + // PREPARE + // Generate mock trigger and action data from parameters + var triggerMockOutput = new <%= TriggerMockOutputClassName %>(); + var triggerMock = new <%= TriggerMockClassName %>(outputs: triggerMockOutput); + + var actionMocks = new Dictionary(); + <%= ActionMocksContent %> + + // 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 From cfcd9e783891825ec70f1e577bee61cfb71914e4 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 28 Jul 2025 20:12:17 -0400 Subject: [PATCH 04/11] add 'Generate tests' to designer command bar, test creation from templates --- Localize/lang/strings.json | 22 ++ .../openDesignerForLocalProject.ts | 7 + .../__test__/saveBlankUnitTest.test.ts | 4 +- .../workflows/unitTest/createUnitTest.ts | 14 +- .../workflows/unitTest/generateTests.ts | 213 +++++++++++++++++- .../workflows/unitTest/saveBlankUnitTest.ts | 8 +- .../src/app/utils/flowgraph.ts | 14 +- .../src/app/utils/unitTests.ts | 12 +- .../assets/UnitTestTemplates/GenericTestClass | 4 +- .../UnitTestTemplates/TestActionAssertion | 4 +- .../assets/UnitTestTemplates/TestActionMock | 2 +- .../assets/UnitTestTemplates/TestCaseMethod | 4 +- apps/vs-code-designer/src/constants.ts | 1 + .../app/designer/DesignerCommandBar/index.tsx | 32 +++ .../src/lib/models/extensioncommand.ts | 1 + 15 files changed, 301 insertions(+), 41 deletions(-) diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index ff82fd070f0..0e35c38b173 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -1,6 +1,7 @@ { "++ZVe/": "Testing", "+0H8Or": "Warning: input node type does not match the schema node's type", + "+0ua83": "Parameters", "+0yxlR": "Function display", "+3rROX": "Protected", "+EREVh": "Name", @@ -123,6 +124,7 @@ "1LSKq8": "Basics", "1NBvKu": "Convert the parameter argument to a floating-point number", "1Orv4i": "Details", + "1PQFOA": "File a bug", "1REu5/": "See less", "1Xke9D": "open functions drawer", "1dlfUe": "Actions perform operations on data, communicate between systems, or run other tasks.", @@ -242,6 +244,7 @@ "4aaixN": "Tour", "4bT5AR": "Invalid connection. To load complete details, complete or update the connection.", "4c0uPQ": "Required. The name of the parameter whose values you want.", + "4eH9hX": "Create unit test from run", "4ggAK+": "Error occurred while parsing agent instructions.", "4hi3ks": "Your flow has been updated.", "4hlqgK": "No results found for {searchTermBeingSearchedFor_DO_NOT_TRANSLATE}", @@ -812,6 +815,7 @@ "LvLksz": "Loading outputs", "Lx8HRl": "(UTC+02:00) Damascus", "LxB+6u": "Select all operations", + "LxRzQm": "Assertions", "Ly65mM": "User Instructions", "M/3Jq4": "Environment", "M/gUE8": "About", @@ -989,6 +993,7 @@ "QKC8fv": "Enter variable name", "QMyMOI": "Description", "QNfUf/": "Full screen", + "QQmbz+": "Save unit test definition", "QT4IaP": "Filtered!", "QVtqAn": "Description", "QZBPUx": "Returns a single value matching the key name from form-data or form-encoded trigger output", @@ -1051,6 +1056,7 @@ "SLZ0n4": "Checks if the string starts with a value (case-insensitive, invariant culture)", "SPaCir": "Enter instructions for the user", "SToblZ": "Configure parameters for this node", + "SUX3dO": "Create unit test", "SUaXux": "These are the connection services this template depends on. When users deploy a workflow using this template, they’ll be prompted to create connections to the services it uses.", "SXb47U": "{minutes}m", "SY04wn": "Required. The name of the action with a form-data or form-encoded response.", @@ -1145,6 +1151,7 @@ "Unc2tG": "Returns true if two values are equal.", "UnrrzF": "Source schema", "UnytRl": "The type for ''{authenticationKey}'' is ''{propertyType}''.", + "UpCa6n": "Generate tests", "UsEvG2": "Tenant ID", "UtyRCH": "Enter a name for the connection", "UuQh+g": "Loading connectors ...", @@ -1343,10 +1350,12 @@ "ZigP3P": "Save", "ZihyUf": "Close", "ZkjTbp": "Learn more about dynamic content.", + "ZvAp7m": "Save", "ZyDq4/": "Show a different suggestion", "ZyntX1": "Add a description", "_++ZVe/.comment": "Title for testing section", "_+0H8Or.comment": "Warning message for when input node type does not match schema node type", + "_+0ua83.comment": "Button text for parameters", "_+0yxlR.comment": "Label for the function display radio group", "_+3rROX.comment": "Label in the chatbot header stating that the users information is protected in this chatbot", "_+EREVh.comment": "Column name for workflow name", @@ -1469,6 +1478,7 @@ "_1LSKq8.comment": "Accessibility label for the basics section", "_1NBvKu.comment": "Label for description of custom float Function", "_1Orv4i.comment": "Title for the details section", + "_1PQFOA.comment": "Button text for file a bug", "_1REu5/.comment": "Select to view fewer token options.", "_1Xke9D.comment": "aria label to open functions drawer", "_1dlfUe.comment": "Description of what Actions are, on a tooltip about Actions", @@ -1588,6 +1598,7 @@ "_4aaixN.comment": "Button text for tour and tutorial", "_4bT5AR.comment": "Error message to show for connection error during deserialization.", "_4c0uPQ.comment": "Required string parameter to create a new parameter", + "_4eH9hX.comment": "Button text for create unit test", "_4ggAK+.comment": "Error message for the agent instructions parsing failure.", "_4hi3ks.comment": "Chatbot workflow has been updated message", "_4hlqgK.comment": "Text to show when there are no search results", @@ -2158,6 +2169,7 @@ "_LvLksz.comment": "Loading outputs text", "_Lx8HRl.comment": "Time zone value ", "_LxB+6u.comment": "Label for select all checkbox", + "_LxRzQm.comment": "Button text for unit test asssertions", "_Ly65mM.comment": "User instructions label", "_M/3Jq4.comment": "The label for the environment field", "_M/gUE8.comment": "The tab label for the about tab on the operation panel", @@ -2335,6 +2347,7 @@ "_QKC8fv.comment": "Placeholder for variable name", "_QMyMOI.comment": "Description label", "_QNfUf/.comment": "Full Screen token picker", + "_QQmbz+.comment": "Button text for save unit test definition", "_QT4IaP.comment": "Filtered text", "_QVtqAn.comment": "Label for description column.", "_QZBPUx.comment": "Label for description of custom triggerFormDataValue Function", @@ -2397,6 +2410,7 @@ "_SLZ0n4.comment": "Label for description of custom startsWith Function", "_SPaCir.comment": "Agent user placeholder", "_SToblZ.comment": "Parameters tab description", + "_SUX3dO.comment": "Button test for save blank unit test", "_SUaXux.comment": "The description for the connections tab", "_SXb47U.comment": "This is a period in time in seconds. {minutes} is replaced by the number and m is an abbreviation of minutes", "_SY04wn.comment": "Required string parameter to identify action name for formDataMultiValues function", @@ -2491,6 +2505,7 @@ "_Unc2tG.comment": "Label for description of custom equals Function", "_UnrrzF.comment": "Label to inform the below schema name is for source schema", "_UnytRl.comment": "Error message when having invalid authentication property types", + "_UpCa6n.comment": "Button text for generate tests", "_UsEvG2.comment": "tenant dropdown label", "_UtyRCH.comment": "Placeholder text for connection name input", "_UuQh+g.comment": "Loading text", @@ -2689,6 +2704,7 @@ "_ZigP3P.comment": "Button text for registering the MCP server", "_ZihyUf.comment": "Label for the close button in the chatbot header", "_ZkjTbp.comment": "Text for dynamic content link", + "_ZvAp7m.comment": "Button text for save", "_ZyDq4/.comment": "Text for the show different suggestion flow button", "_ZyntX1.comment": "Text that tells you to select for adding a description", "_a21rtJ.comment": "Error shown when the State type list is missing or empty", @@ -3120,6 +3136,7 @@ "_lsH37F.comment": "tool tip explaining what schema validation setting does", "_lwlg2K.comment": "Command for underline text for non-mac users", "_lx0teD.comment": "Last modified label", + "_m/GihH.comment": "Button text for connections", "_m/jJ/5.comment": "Map checker", "_m4qt/b.comment": "Error while creating acl", "_m5InJc.comment": "status code", @@ -3234,6 +3251,7 @@ "_odQ554.comment": "Response body for test map API", "_og5JOA.comment": "Millisecond", "_ohEtV6.comment": "The tab label for the select operations tab on the connector panel", + "_ohOaXj.comment": "Button text for errors", "_ohpbkw.comment": "title for retry policy exponential interval setting", "_ol3TWp.comment": "Button label to automaticlaly generate agent parameter", "_om43/8.comment": "Aria label for workflows list table", @@ -3355,6 +3373,7 @@ "_sJQee6.comment": "Hours", "_sKy720.comment": "Error message when the workflow name is empty.", "_sMjDlb.comment": "Text to show no parameters present in the template.", + "_sOnphB.comment": "Button text for resubmit", "_sQmPbe.comment": "The tab label for the monitoring setup workflows tab on the configure template wizard", "_sROTIO.comment": "Featured connectors label", "_sRpETS.comment": "Warning message for when custom value does not match schema node type", @@ -4094,6 +4113,7 @@ "lsH37F": "Validate request body against the schema provided. In case there is a mismatch, HTTP 400 will be returned", "lwlg2K": "Underline (Ctrl+U)", "lx0teD": "Last modified", + "m/GihH": "Connections", "m/jJ/5": "Map checker", "m4qt/b": "ACL creation failed for connection. Deleting the connection.", "m5InJc": "Status Code", @@ -4208,6 +4228,7 @@ "odQ554": "Response body", "og5JOA": "{count} Millisecond", "ohEtV6": "Select Operation(s)", + "ohOaXj": "Errors", "ohpbkw": "Exponential interval", "ol3TWp": "Select to generate the agent parameter", "om43/8": "Workflows list tabel", @@ -4329,6 +4350,7 @@ "sJQee6": "{count} Hours", "sKy720": "Must provide value for workflow name.", "sMjDlb": "No parameters in this template", + "sOnphB": "Resubmit", "sQmPbe": "Set up workflows", "sROTIO": "Featured connectors", "sRpETS": "Warning: custom value does not match the schema node's type", 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 index 57c4f995e9d..95c9e249d2c 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -2,15 +2,35 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getWorkflowNode } from '../../../utils/workspace'; +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 { FlowGraph } from '../../../utils/flowgraph'; +import * as path from 'path'; +import { FlowGraph, type ParentPathNode, type PathNode } from '../../../utils/flowgraph'; import { ext } from '../../../../extensionVariables'; +import { + createTestExecutorFile, + createTestSettingsConfigFile, + ensureCsproj, + getOperationMockClassContent, + getUnitTestPaths, + preprocessOutputParameters, + updateCsprojFile, + validateWorkflowPath, +} from '../../../utils/unitTests'; +import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject'; +import { assetsFolderName, unitTestTemplatesFolderName } from '../../../../constants'; -export async function generateTests(context: IActionContext, node: Uri | undefined): Promise { +/** + * 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 { const workflowNode = getWorkflowNode(node); if (!(workflowNode instanceof Uri)) { const errorMessage = 'The workflow node is undefined. A valid workflow node is required to generate tests.'; @@ -28,11 +48,186 @@ export async function generateTests(context: IActionContext, node: Uri | undefin localize('generateTestsPaths', 'Generated {0} execution paths for workflow: {1}', paths.length, workflowPath) ); - // TODO(aeldridge): Generate tests from paths using the following templates: - // - GenericTestClass (top-level test class template) - // -- TestCaseMethod[] (template for individual methods in test class, one per path) - // --- TestActionMock[] (template for individual action mocks in a test case, one per mockable action) - // --- TestActionAssertion[] (template for assertions corresponding to actions, one per action in path) + const { operationInfo, outputParameters } = await preprocessOutputParameters(operationData); + const workspaceFolder = getWorkspacePath(workflowNode.fsPath); + const projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); + validateWorkflowPath(projectPath, workflowNode.fsPath); + const workflowName = path.basename(path.dirname(workflowNode.fsPath)); + const { testsDirectory, logicAppName, logicAppTestFolderPath, workflowTestFolderPath, mocksFolderPath } = getUnitTestPaths( + projectPath, + workflowName + ); + const { mockClassContent, foundActionMocks, foundTriggerMocks } = await getOperationMockClassContent( + operationInfo, + outputParameters, + workflowNode.fsPath, + workflowName, + logicAppName + ); + + const workflowNameCleaned = workflowName.replace(/-/g, '_'); + const logicAppNameCleaned = logicAppName.replace(/-/g, '_'); + + const testCaseMethods: string[] = []; + for (const scenario of paths) { + 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 actionMocks = (await Promise.all(pathActions.map((actionNode) => getActionMock(actionNode, foundActionMocks)))).flat(); + const actionAssertions = (await Promise.all(pathActions.map((actionNode) => getActionAssertion(actionNode)))).flat(); + + 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, getPathDescription(actionChain)) + .replace(/<%= PathName %>/g, getPathName(scenario.overallStatus, actionChain)) + .replace(/<%= TriggerMockOutputClassName %>/g, triggerMockOutputClassName) + .replace(/<%= TriggerMockClassName %>/g, triggerMockClassName) + .replace(/<%= ActionMocksContent %>/g, actionMocks.join('\n\n')) + .replace(/<%= ActionAssertionsContent %>/g, actionAssertions.join('\n\n')) + .replace(/<%= PathOverallStatus %>/g, toTestWorkflowStatus(scenario.overallStatus)) + ); + } + + 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'); + } + + 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(/<%= TestClassContent %>/g, testCaseMethods.join('\n\n')); + const csFilePath = path.join(workflowTestFolderPath, `${workflowNameCleaned}Tests.cs`); + await fse.writeFile(csFilePath, testClassContent); + + 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); +} + +/** + * Gets all action mocks for a given action node, including nested actions if applicable. + * @param {PathNode} actionNode - The action node to get mocks for. + * @param {Record} foundActionMocks - The mockable actions. + * @returns {Promise} - A Promise that resolves to an array of action mock strings. + */ +async function getActionMock(actionNode: PathNode, foundActionMocks: Record): Promise { + const actionMockOutputClassName = foundActionMocks[actionNode.name]; + const actionMockClassName = actionMockOutputClassName?.replace(/(.*)Output$/, '$1Mock'); + + if (actionNode.type === 'Switch' || actionNode.type === 'If') { + return ( + await Promise.all((actionNode as ParentPathNode).actions.map((childActionNode) => getActionMock(childActionNode, foundActionMocks))) + ).flat(); + } + + if (actionMockOutputClassName === undefined) { + return []; + } + + const actionMockTemplateFileName = 'TestActionMock'; + const actionMockTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, actionMockTemplateFileName); + const actionMockTemplate = await fse.readFile(actionMockTemplatePath, 'utf-8'); + + return [ + actionMockTemplate + .replace(/<%= ActionName %>/g, actionNode.name) + .replace(/<%= ActionMockStatus %>/g, toTestWorkflowStatus(actionNode.status)) + .replace(/<%= ActionMockOutputClassName %>/g, actionMockOutputClassName) + .replace(/<%= ActionMockClassName %>/g, actionMockClassName), + ]; +} + +/** + * Gets all action assertions for a given action node, including nested actions if applicable. + * @param {PathNode} actionNode - The action node to get assertions for. + * @returns {Promise} - A Promise that resolves to an array of action assertion strings. + */ +async function getActionAssertion(actionNode: PathNode): 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' + ? (await Promise.all((actionNode as ParentPathNode).actions.map((childActionNode) => getActionAssertion(childActionNode)))).flat() + : []; + + return [ + actionAssertionTemplate + .replace(/<%= ActionName %>/g, actionNode.name) + .replace(/<%= ActionStatus %>/g, toTestWorkflowStatus(actionNode.status)), + ...childActionAssertions, + ]; +} + +function toTestWorkflowStatus(status: string): string { + return `TestWorkflowStatus.${status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}`; +} + +/** + * 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') { + actionChain.push(...getExecutedActionChain((actionNode as ParentPathNode).actions)); + } else { + actionChain.push(actionNode); + } + } + + return actionChain; +} + +/** + * 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.name).join(' -> '); +} - // TODO(aeldridge): We should be able to repurpose the existing saveBlankUnitTest and "getOperationMockClassContent" to create required files +/** + * Gets a string name for the action path. + * @param {string} overallStatus - The overall status of the path. + * @param {PathNode[]} actionChain - The executed action chain. + * @returns {string} - A string name for the action path. + */ +function getPathName(overallStatus: string, actionChain: PathNode[]): string { + const actionChainString = actionChain.map((action) => action.name.replace(/-/g, '_')).join('_'); + return `${actionChainString}_${overallStatus}`; } 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 index cd948f0e8ee..38066715277 100644 --- a/apps/vs-code-designer/src/app/utils/flowgraph.ts +++ b/apps/vs-code-designer/src/app/utils/flowgraph.ts @@ -16,26 +16,28 @@ type FlowActionStatus = 'SUCCEEDED' | 'FAILED' | 'TIMEDOUT' | 'SKIPPED'; // TODO(aeldridge): Support paths for all possible flow run statuses type FlowPathOverallStatus = 'SUCCEEDED' | 'FAILED'; -type FlowPath = { +export type FlowPath = { overallStatus: FlowPathOverallStatus; path: PathNode[]; }; -type PathNode = { +export type PathNode = { name: string; type: string; status: FlowActionStatus; }; -type IfPathNode = PathNode & { - conditionResult: boolean; +export type ParentPathNode = PathNode & { actions: PathNode[]; }; -type SwitchPathNode = PathNode & { +export type IfPathNode = ParentPathNode & { + conditionResult: boolean; +}; + +export type SwitchPathNode = ParentPathNode & { caseResult: string; isDefaultCase: boolean; - actions: PathNode[]; }; const unsupportedActions = new Set(['Scope', 'ForEach', 'Until']); 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 index ec5ad478e07..7fdd1b11c9c 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass @@ -8,9 +8,9 @@ using Microsoft.Azure.Workflows.UnitTesting.Definitions; using Microsoft.Azure.Workflows.UnitTesting.ErrorResponses; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; -using <%= LogicAppName %>.Tests.Mocks.<%= SanitizedWorkflowName %>; +using <%= LogicAppNameCleaned %>.Tests.Mocks.<%= WorkflowNameCleaned %>; -namespace <%= LogicAppName %>.Tests +namespace <%= LogicAppNameCleaned %>.Tests { [TestClass] public class GeneratedLogicAppWorkflowTests diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion index 687db22f4eb..8663181aea7 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion @@ -1,2 +1,2 @@ -Assert.IsTrue(testRun.ActionResults.ContainsKey("<%= ActionMockName %>")); -Assert.AreEqual(expected: <%= ActionMockStatus %>, actual: testRun.ActionResults["<%= ActionMockName %>"].Status); \ No newline at end of file +Assert.IsTrue(testRun.ActionResults.ContainsKey("<%= ActionName %>")); +Assert.AreEqual(expected: <%= ActionStatus %>, actual: testRun.ActionResults["<%= ActionName %>"].Status); \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock index 5bd6635bd52..2b5b75dabe8 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock @@ -1,2 +1,2 @@ var actionMockOutput = new <%= ActionMockOutputClassName %>(); -var actionMock = new <%= ActionMockClassName %>(status: <%= ActionMockStatus %>, name: "<%= ActionMockName %>", outputs: actionMockOutput); \ No newline at end of file +var actionMock = new <%= ActionMockClassName %>(status: <%= ActionMockStatus %>, name: "<%= ActionName %>", outputs: actionMockOutput); \ 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 index 37d52f25fd9..1e7d4ce312d 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod @@ -1,9 +1,9 @@ /// -/// Test case for workflow <%= WorkflowName %> on path: <%= PathString %> +/// Test case for workflow <%= WorkflowName %> on path: <%= PathDescriptionString %> /// [TestMethod] [DynamicData(nameof(<%= PathName %>_TestData))] -public async Task <%= WorkflowName %>_<%= PathName %>_ExecuteWorkflow( +public async Task <%= WorkflowNameCleaned %>_<%= PathName %>_ExecuteWorkflow( Dictionary triggerParameters, Dictionary> actionParameters) { diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 6599d7d41b9..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'; 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', From bd08051cf9dd5c37bdec3e16a5c382e5e7d29159 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 29 Jul 2025 10:53:07 -0400 Subject: [PATCH 05/11] fix bugs in test templates --- Localize/lang/strings.json | 22 ------- .../workflows/unitTest/generateTests.ts | 50 ++++++++++++--- .../assets/UnitTestTemplates/GenericTestClass | 5 +- .../UnitTestTemplates/TestActionAssertion | 4 +- .../assets/UnitTestTemplates/TestActionMock | 4 +- .../UnitTestTemplates/TestActionMockEntry | 1 + .../src/assets/UnitTestTemplates/TestCaseData | 22 +++++++ .../assets/UnitTestTemplates/TestCaseMethod | 64 +++++++++---------- 8 files changed, 104 insertions(+), 68 deletions(-) create mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry create mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index 0e35c38b173..ff82fd070f0 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -1,7 +1,6 @@ { "++ZVe/": "Testing", "+0H8Or": "Warning: input node type does not match the schema node's type", - "+0ua83": "Parameters", "+0yxlR": "Function display", "+3rROX": "Protected", "+EREVh": "Name", @@ -124,7 +123,6 @@ "1LSKq8": "Basics", "1NBvKu": "Convert the parameter argument to a floating-point number", "1Orv4i": "Details", - "1PQFOA": "File a bug", "1REu5/": "See less", "1Xke9D": "open functions drawer", "1dlfUe": "Actions perform operations on data, communicate between systems, or run other tasks.", @@ -244,7 +242,6 @@ "4aaixN": "Tour", "4bT5AR": "Invalid connection. To load complete details, complete or update the connection.", "4c0uPQ": "Required. The name of the parameter whose values you want.", - "4eH9hX": "Create unit test from run", "4ggAK+": "Error occurred while parsing agent instructions.", "4hi3ks": "Your flow has been updated.", "4hlqgK": "No results found for {searchTermBeingSearchedFor_DO_NOT_TRANSLATE}", @@ -815,7 +812,6 @@ "LvLksz": "Loading outputs", "Lx8HRl": "(UTC+02:00) Damascus", "LxB+6u": "Select all operations", - "LxRzQm": "Assertions", "Ly65mM": "User Instructions", "M/3Jq4": "Environment", "M/gUE8": "About", @@ -993,7 +989,6 @@ "QKC8fv": "Enter variable name", "QMyMOI": "Description", "QNfUf/": "Full screen", - "QQmbz+": "Save unit test definition", "QT4IaP": "Filtered!", "QVtqAn": "Description", "QZBPUx": "Returns a single value matching the key name from form-data or form-encoded trigger output", @@ -1056,7 +1051,6 @@ "SLZ0n4": "Checks if the string starts with a value (case-insensitive, invariant culture)", "SPaCir": "Enter instructions for the user", "SToblZ": "Configure parameters for this node", - "SUX3dO": "Create unit test", "SUaXux": "These are the connection services this template depends on. When users deploy a workflow using this template, they’ll be prompted to create connections to the services it uses.", "SXb47U": "{minutes}m", "SY04wn": "Required. The name of the action with a form-data or form-encoded response.", @@ -1151,7 +1145,6 @@ "Unc2tG": "Returns true if two values are equal.", "UnrrzF": "Source schema", "UnytRl": "The type for ''{authenticationKey}'' is ''{propertyType}''.", - "UpCa6n": "Generate tests", "UsEvG2": "Tenant ID", "UtyRCH": "Enter a name for the connection", "UuQh+g": "Loading connectors ...", @@ -1350,12 +1343,10 @@ "ZigP3P": "Save", "ZihyUf": "Close", "ZkjTbp": "Learn more about dynamic content.", - "ZvAp7m": "Save", "ZyDq4/": "Show a different suggestion", "ZyntX1": "Add a description", "_++ZVe/.comment": "Title for testing section", "_+0H8Or.comment": "Warning message for when input node type does not match schema node type", - "_+0ua83.comment": "Button text for parameters", "_+0yxlR.comment": "Label for the function display radio group", "_+3rROX.comment": "Label in the chatbot header stating that the users information is protected in this chatbot", "_+EREVh.comment": "Column name for workflow name", @@ -1478,7 +1469,6 @@ "_1LSKq8.comment": "Accessibility label for the basics section", "_1NBvKu.comment": "Label for description of custom float Function", "_1Orv4i.comment": "Title for the details section", - "_1PQFOA.comment": "Button text for file a bug", "_1REu5/.comment": "Select to view fewer token options.", "_1Xke9D.comment": "aria label to open functions drawer", "_1dlfUe.comment": "Description of what Actions are, on a tooltip about Actions", @@ -1598,7 +1588,6 @@ "_4aaixN.comment": "Button text for tour and tutorial", "_4bT5AR.comment": "Error message to show for connection error during deserialization.", "_4c0uPQ.comment": "Required string parameter to create a new parameter", - "_4eH9hX.comment": "Button text for create unit test", "_4ggAK+.comment": "Error message for the agent instructions parsing failure.", "_4hi3ks.comment": "Chatbot workflow has been updated message", "_4hlqgK.comment": "Text to show when there are no search results", @@ -2169,7 +2158,6 @@ "_LvLksz.comment": "Loading outputs text", "_Lx8HRl.comment": "Time zone value ", "_LxB+6u.comment": "Label for select all checkbox", - "_LxRzQm.comment": "Button text for unit test asssertions", "_Ly65mM.comment": "User instructions label", "_M/3Jq4.comment": "The label for the environment field", "_M/gUE8.comment": "The tab label for the about tab on the operation panel", @@ -2347,7 +2335,6 @@ "_QKC8fv.comment": "Placeholder for variable name", "_QMyMOI.comment": "Description label", "_QNfUf/.comment": "Full Screen token picker", - "_QQmbz+.comment": "Button text for save unit test definition", "_QT4IaP.comment": "Filtered text", "_QVtqAn.comment": "Label for description column.", "_QZBPUx.comment": "Label for description of custom triggerFormDataValue Function", @@ -2410,7 +2397,6 @@ "_SLZ0n4.comment": "Label for description of custom startsWith Function", "_SPaCir.comment": "Agent user placeholder", "_SToblZ.comment": "Parameters tab description", - "_SUX3dO.comment": "Button test for save blank unit test", "_SUaXux.comment": "The description for the connections tab", "_SXb47U.comment": "This is a period in time in seconds. {minutes} is replaced by the number and m is an abbreviation of minutes", "_SY04wn.comment": "Required string parameter to identify action name for formDataMultiValues function", @@ -2505,7 +2491,6 @@ "_Unc2tG.comment": "Label for description of custom equals Function", "_UnrrzF.comment": "Label to inform the below schema name is for source schema", "_UnytRl.comment": "Error message when having invalid authentication property types", - "_UpCa6n.comment": "Button text for generate tests", "_UsEvG2.comment": "tenant dropdown label", "_UtyRCH.comment": "Placeholder text for connection name input", "_UuQh+g.comment": "Loading text", @@ -2704,7 +2689,6 @@ "_ZigP3P.comment": "Button text for registering the MCP server", "_ZihyUf.comment": "Label for the close button in the chatbot header", "_ZkjTbp.comment": "Text for dynamic content link", - "_ZvAp7m.comment": "Button text for save", "_ZyDq4/.comment": "Text for the show different suggestion flow button", "_ZyntX1.comment": "Text that tells you to select for adding a description", "_a21rtJ.comment": "Error shown when the State type list is missing or empty", @@ -3136,7 +3120,6 @@ "_lsH37F.comment": "tool tip explaining what schema validation setting does", "_lwlg2K.comment": "Command for underline text for non-mac users", "_lx0teD.comment": "Last modified label", - "_m/GihH.comment": "Button text for connections", "_m/jJ/5.comment": "Map checker", "_m4qt/b.comment": "Error while creating acl", "_m5InJc.comment": "status code", @@ -3251,7 +3234,6 @@ "_odQ554.comment": "Response body for test map API", "_og5JOA.comment": "Millisecond", "_ohEtV6.comment": "The tab label for the select operations tab on the connector panel", - "_ohOaXj.comment": "Button text for errors", "_ohpbkw.comment": "title for retry policy exponential interval setting", "_ol3TWp.comment": "Button label to automaticlaly generate agent parameter", "_om43/8.comment": "Aria label for workflows list table", @@ -3373,7 +3355,6 @@ "_sJQee6.comment": "Hours", "_sKy720.comment": "Error message when the workflow name is empty.", "_sMjDlb.comment": "Text to show no parameters present in the template.", - "_sOnphB.comment": "Button text for resubmit", "_sQmPbe.comment": "The tab label for the monitoring setup workflows tab on the configure template wizard", "_sROTIO.comment": "Featured connectors label", "_sRpETS.comment": "Warning message for when custom value does not match schema node type", @@ -4113,7 +4094,6 @@ "lsH37F": "Validate request body against the schema provided. In case there is a mismatch, HTTP 400 will be returned", "lwlg2K": "Underline (Ctrl+U)", "lx0teD": "Last modified", - "m/GihH": "Connections", "m/jJ/5": "Map checker", "m4qt/b": "ACL creation failed for connection. Deleting the connection.", "m5InJc": "Status Code", @@ -4228,7 +4208,6 @@ "odQ554": "Response body", "og5JOA": "{count} Millisecond", "ohEtV6": "Select Operation(s)", - "ohOaXj": "Errors", "ohpbkw": "Exponential interval", "ol3TWp": "Select to generate the agent parameter", "om43/8": "Workflows list tabel", @@ -4350,7 +4329,6 @@ "sJQee6": "{count} Hours", "sKy720": "Must provide value for workflow name.", "sMjDlb": "No parameters in this template", - "sOnphB": "Resubmit", "sQmPbe": "Set up workflows", "sROTIO": "Featured connectors", "sRpETS": "Warning: custom value does not match the schema node's type", 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 index 95c9e249d2c..4bbae2bff3d 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -65,11 +65,11 @@ export async function generateTests(context: IActionContext, node: Uri | undefin logicAppName ); - const workflowNameCleaned = workflowName.replace(/-/g, '_'); - const logicAppNameCleaned = logicAppName.replace(/-/g, '_'); + const workflowNameCleaned = workflowName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, ''); + const logicAppNameCleaned = logicAppName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, ''); const testCaseMethods: string[] = []; - for (const scenario of paths) { + for (const [index, scenario] of paths.entries()) { const triggerNode = scenario.path[0]; const triggerMockOutputClassName = foundTriggerMocks[triggerNode.name]; const triggerMockClassName = triggerMockOutputClassName?.replace(/(.*)Output$/, '$1Mock'); @@ -80,6 +80,7 @@ export async function generateTests(context: IActionContext, node: Uri | undefin const pathActions = scenario.path.slice(1); const actionChain = getExecutedActionChain(pathActions); const actionMocks = (await Promise.all(pathActions.map((actionNode) => getActionMock(actionNode, foundActionMocks)))).flat(); + const actionMockEntries = (await Promise.all(pathActions.map((actionNode) => getActionMockEntry(actionNode, foundActionMocks)))).flat(); const actionAssertions = (await Promise.all(pathActions.map((actionNode) => getActionAssertion(actionNode)))).flat(); const testCaseMethodTemplateFileName = 'TestCaseMethod'; @@ -91,10 +92,11 @@ export async function generateTests(context: IActionContext, node: Uri | undefin .replace(/<%= WorkflowName %>/g, workflowName) .replace(/<%= WorkflowNameCleaned %>/g, workflowNameCleaned) .replace(/<%= PathDescriptionString %>/g, getPathDescription(actionChain)) - .replace(/<%= PathName %>/g, getPathName(scenario.overallStatus, actionChain)) + .replace(/<%= PathName %>/g, getPathName(index, scenario.overallStatus)) .replace(/<%= TriggerMockOutputClassName %>/g, triggerMockOutputClassName) .replace(/<%= TriggerMockClassName %>/g, triggerMockClassName) .replace(/<%= ActionMocksContent %>/g, actionMocks.join('\n\n')) + .replace(/<%= ActionMockEntries %>/g, actionMockEntries.join(',\n')) .replace(/<%= ActionAssertionsContent %>/g, actionAssertions.join('\n\n')) .replace(/<%= PathOverallStatus %>/g, toTestWorkflowStatus(scenario.overallStatus)) ); @@ -160,12 +162,45 @@ async function getActionMock(actionNode: PathNode, foundActionMocks: Record/g, actionNode.name) + .replace(/<%= ActionNameCleaned %>/g, actionNode.name.replace(/[^a-zA-Z0-9_]/g, '')) .replace(/<%= ActionMockStatus %>/g, toTestWorkflowStatus(actionNode.status)) .replace(/<%= ActionMockOutputClassName %>/g, actionMockOutputClassName) .replace(/<%= ActionMockClassName %>/g, actionMockClassName), ]; } +/** + * Gets all action mock dictionary entries for a given action node, including nested actions if applicable. + * @param {PathNode} actionNode - The action node to get mock entries for. + * @param {Record} foundActionMocks - The mockable actions. + * @returns {Promise} - A Promise that resolves to an array of action mock dictionary entry strings. + */ +async function getActionMockEntry(actionNode: PathNode, foundActionMocks: Record): Promise { + const actionMockOutputClassName = foundActionMocks[actionNode.name]; + + if (actionNode.type === 'Switch' || actionNode.type === 'If') { + return ( + await Promise.all( + (actionNode as ParentPathNode).actions.map((childActionNode) => getActionMockEntry(childActionNode, foundActionMocks)) + ) + ).flat(); + } + + if (actionMockOutputClassName === undefined) { + return []; + } + + const actionMockEntryTemplateFileName = 'TestActionMockEntry'; + const actionMockEntryTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, actionMockEntryTemplateFileName); + const actionMockEntryTemplate = await fse.readFile(actionMockEntryTemplatePath, 'utf-8'); + + return [ + actionMockEntryTemplate + .replace(/<%= ActionName %>/g, actionNode.name) + .replace(/<%= ActionNameCleaned %>/g, actionNode.name.replace(/[^a-zA-Z0-9_]/g, '')), + ]; +} + /** * Gets all action assertions for a given action node, including nested actions if applicable. * @param {PathNode} actionNode - The action node to get assertions for. @@ -223,11 +258,10 @@ function getPathDescription(actionChain: PathNode[]): string { /** * 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. - * @param {PathNode[]} actionChain - The executed action chain. * @returns {string} - A string name for the action path. */ -function getPathName(overallStatus: string, actionChain: PathNode[]): string { - const actionChainString = actionChain.map((action) => action.name.replace(/-/g, '_')).join('_'); - return `${actionChainString}_${overallStatus}`; +function getPathName(index: number, overallStatus: string): string { + return `Path${index}_${overallStatus}`; } diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass index 7fdd1b11c9c..1c0ab515f5d 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Collections.Generic; using Microsoft.Azure.Workflows.Common.ErrorResponses; using Microsoft.Azure.Workflows.UnitTesting; using Microsoft.Azure.Workflows.UnitTesting.Definitions; @@ -13,7 +14,7 @@ using <%= LogicAppNameCleaned %>.Tests.Mocks.<%= WorkflowNameCleaned %>; namespace <%= LogicAppNameCleaned %>.Tests { [TestClass] - public class GeneratedLogicAppWorkflowTests + public class Generated<%= WorkflowNameCleaned %>Tests { private TestExecutor TestExecutor { get; set; } @@ -23,6 +24,6 @@ namespace <%= LogicAppNameCleaned %>.Tests this.TestExecutor = new TestExecutor("<%= WorkflowName %>/testSettings.config"); } - <%= TestClassContent %> +<%= TestClassContent %> } } \ 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 index 8663181aea7..aef33ee7923 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion @@ -1,2 +1,2 @@ -Assert.IsTrue(testRun.ActionResults.ContainsKey("<%= ActionName %>")); -Assert.AreEqual(expected: <%= ActionStatus %>, actual: testRun.ActionResults["<%= ActionName %>"].Status); \ No newline at end of file + Assert.IsTrue(testRun.Actions.ContainsKey("<%= ActionName %>")); + Assert.AreEqual(expected: <%= ActionStatus %>, actual: testRun.Actions["<%= ActionName %>"].Status); \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock index 2b5b75dabe8..2fef27ceefa 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock @@ -1,2 +1,2 @@ -var actionMockOutput = new <%= ActionMockOutputClassName %>(); -var actionMock = new <%= ActionMockClassName %>(status: <%= ActionMockStatus %>, name: "<%= ActionName %>", outputs: actionMockOutput); \ No newline at end of file + var actionMock<%= ActionNameCleaned %>Output = new <%= ActionMockOutputClassName %>(); + var actionMock<%= ActionNameCleaned %> = new <%= ActionMockClassName %>(status: <%= ActionMockStatus %>, name: "<%= ActionName %>", outputs: actionMock<%= ActionNameCleaned %>Output); \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry new file mode 100644 index 00000000000..633475fe415 --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry @@ -0,0 +1 @@ + { "<%= ActionName %>", actionMock<%= ActionNameCleaned %> } \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData new file mode 100644 index 00000000000..9186d39abe3 --- /dev/null +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData @@ -0,0 +1,22 @@ +public static IEnumerable <%= PathName %>_TestData +{ + get + { + return new[] + { + new object[] + { + // Trigger parameters + new Dictionary + { + // Add trigger parameters here + }, + // Action parameters + new Dictionary> + { + // Add action parameters here + } + } + }; + } +} \ 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 index 1e7d4ce312d..8f7cf8f803f 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod @@ -1,34 +1,34 @@ -/// -/// Test case for workflow <%= WorkflowName %> on path: <%= PathDescriptionString %> -/// -[TestMethod] -[DynamicData(nameof(<%= PathName %>_TestData))] -public async Task <%= WorkflowNameCleaned %>_<%= PathName %>_ExecuteWorkflow( - Dictionary triggerParameters, - Dictionary> actionParameters) -{ - // PREPARE - // Generate mock trigger and action data from parameters - var triggerMockOutput = new <%= TriggerMockOutputClassName %>(); - var triggerMock = new <%= TriggerMockClassName %>(outputs: triggerMockOutput); + /// + /// Test case for workflow <%= WorkflowName %> on path: <%= PathDescriptionString %> + /// + [TestMethod] + public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>() + { + // PREPARE + // Generate mock trigger and action data from parameters + var triggerMockOutput = new <%= TriggerMockOutputClassName %>(); + var triggerMock = new <%= TriggerMockClassName %>(outputs: triggerMockOutput); - var actionMocks = new Dictionary(); - <%= ActionMocksContent %> +<%= ActionMocksContent %> + var actionMocks = new Dictionary() + { +<%= ActionMockEntries %> + }; - // 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 + // 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 From 5222b938258523277aaad93276943fac74c49b2f Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 29 Jul 2025 11:18:41 -0400 Subject: [PATCH 06/11] add telemetry, fix nested action bug --- .../workflows/unitTest/generateTests.ts | 38 +++++++++++++++++-- .../UnitTestTemplates/TestActionAssertion | 4 +- 2 files changed, 37 insertions(+), 5 deletions(-) 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 index 4bbae2bff3d..ff0472cf2c9 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -8,6 +8,7 @@ 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 { @@ -31,6 +32,7 @@ import { assetsFolderName, unitTestTemplatesFolderName } from '../../../../const * @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.'; @@ -39,24 +41,33 @@ export async function generateTests(context: IActionContext, node: Uri | undefin 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, @@ -68,6 +79,7 @@ export async function generateTests(context: IActionContext, node: Uri | undefin 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[] = []; for (const [index, scenario] of paths.entries()) { const triggerNode = scenario.path[0]; @@ -102,6 +114,7 @@ export async function generateTests(context: IActionContext, node: Uri | undefin ); } + context.telemetry.properties.lastStep = 'ensureTestFolders'; await Promise.all([fse.ensureDir(logicAppTestFolderPath), fse.ensureDir(workflowTestFolderPath), fse.ensureDir(mocksFolderPath)]); context.telemetry.properties.lastStep = 'createTestSettingsConfigFile'; @@ -115,6 +128,7 @@ export async function generateTests(context: IActionContext, node: Uri | undefin 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'); @@ -126,6 +140,7 @@ export async function generateTests(context: IActionContext, node: Uri | undefin 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`); @@ -133,6 +148,15 @@ export async function generateTests(context: IActionContext, node: Uri | undefin context.telemetry.properties.lastStep = 'ensureTestsDirectoryInWorkspace'; await ensureDirectoryInWorkspace(testsDirectory); + + const successMessage = localize( + 'generateTestsSuccess', + 'Tests generated successfully for workflow "{0}" at: "{1}"', + workflowName, + logicAppTestFolderPath + ); + ext.outputChannel.appendLog(successMessage); + vscode.window.showInformationMessage(successMessage); } /** @@ -204,22 +228,30 @@ async function getActionMockEntry(actionNode: PathNode, foundActionMocks: Record /** * 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): Promise { +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' - ? (await Promise.all((actionNode as ParentPathNode).actions.map((childActionNode) => getActionAssertion(childActionNode)))).flat() + ? ( + 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(/<%= ActionStatus %>/g, toTestWorkflowStatus(actionNode.status)) + .replace(/<%= NestedActionPath %>/g, nestedActionPath), ...childActionAssertions, ]; } diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion index aef33ee7923..31fb3166fc5 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionAssertion @@ -1,2 +1,2 @@ - Assert.IsTrue(testRun.Actions.ContainsKey("<%= ActionName %>")); - Assert.AreEqual(expected: <%= ActionStatus %>, actual: testRun.Actions["<%= ActionName %>"].Status); \ No newline at end of file + Assert.IsTrue(testRun.Actions<%= NestedActionPath %>.ContainsKey("<%= ActionName %>")); + Assert.AreEqual(expected: <%= ActionStatus %>, actual: testRun.Actions<%= NestedActionPath %>["<%= ActionName %>"].Status); \ No newline at end of file From 1187dca5220b2cbb26eda6b034bbb0cadc9dd487 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 29 Jul 2025 15:43:07 -0400 Subject: [PATCH 07/11] add DynamicData inputs for custom mock outputs/errors --- .../workflows/unitTest/generateTests.ts | 17 +++++++---- .../assets/UnitTestTemplates/GenericTestClass | 7 +++-- .../assets/UnitTestTemplates/TestActionMock | 11 +++++-- .../src/assets/UnitTestTemplates/TestCaseData | 30 ++++++++----------- .../assets/UnitTestTemplates/TestCaseMethod | 6 +++- 5 files changed, 43 insertions(+), 28 deletions(-) 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 index ff0472cf2c9..08567ee4b22 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -81,6 +81,7 @@ export async function generateTests(context: IActionContext, node: Uri | undefin 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]; @@ -94,17 +95,17 @@ export async function generateTests(context: IActionContext, node: Uri | undefin const actionMocks = (await Promise.all(pathActions.map((actionNode) => getActionMock(actionNode, foundActionMocks)))).flat(); const actionMockEntries = (await Promise.all(pathActions.map((actionNode) => getActionMockEntry(actionNode, foundActionMocks)))).flat(); const actionAssertions = (await Promise.all(pathActions.map((actionNode) => getActionAssertion(actionNode)))).flat(); + const pathName = getPathName(index, scenario.overallStatus); 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, getPathDescription(actionChain)) - .replace(/<%= PathName %>/g, getPathName(index, scenario.overallStatus)) + .replace(/<%= PathName %>/g, pathName) .replace(/<%= TriggerMockOutputClassName %>/g, triggerMockOutputClassName) .replace(/<%= TriggerMockClassName %>/g, triggerMockClassName) .replace(/<%= ActionMocksContent %>/g, actionMocks.join('\n\n')) @@ -112,6 +113,11 @@ export async function generateTests(context: IActionContext, node: Uri | undefin .replace(/<%= ActionAssertionsContent %>/g, actionAssertions.join('\n\n')) .replace(/<%= PathOverallStatus %>/g, toTestWorkflowStatus(scenario.overallStatus)) ); + + const testCaseDataTemplateFileName = 'TestCaseData'; + const testCaseDataTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, testCaseDataTemplateFileName); + const testCaseDataTemplate = await fse.readFile(testCaseDataTemplatePath, 'utf-8'); + testCaseData.push(testCaseDataTemplate.replace(/<%= PathName %>/g, pathName)); } context.telemetry.properties.lastStep = 'ensureTestFolders'; @@ -136,7 +142,8 @@ export async function generateTests(context: IActionContext, node: Uri | undefin .replace(/<%= WorkflowName %>/g, workflowName) .replace(/<%= LogicAppNameCleaned %>/g, logicAppNameCleaned) .replace(/<%= WorkflowNameCleaned %>/g, workflowNameCleaned) - .replace(/<%= TestClassContent %>/g, testCaseMethods.join('\n\n')); + .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); @@ -151,7 +158,7 @@ export async function generateTests(context: IActionContext, node: Uri | undefin const successMessage = localize( 'generateTestsSuccess', - 'Tests generated successfully for workflow "{0}" at: "{1}"', + 'Tests generated successfully for workflow "{0}" at: "{1}".', workflowName, logicAppTestFolderPath ); @@ -285,7 +292,7 @@ function getExecutedActionChain(path: PathNode[]): PathNode[] { * @returns {string} - A string description of the action path. */ function getPathDescription(actionChain: PathNode[]): string { - return actionChain.map((action) => action.name).join(' -> '); + return actionChain.map((action) => `[${action.status}] ${action.name}`).join(' -> '); } /** diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass index 1c0ab515f5d..626bc652ed6 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/GenericTestClass @@ -3,6 +3,7 @@ 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; @@ -14,7 +15,7 @@ using <%= LogicAppNameCleaned %>.Tests.Mocks.<%= WorkflowNameCleaned %>; namespace <%= LogicAppNameCleaned %>.Tests { [TestClass] - public class Generated<%= WorkflowNameCleaned %>Tests + public class <%= WorkflowNameCleaned %>Tests { private TestExecutor TestExecutor { get; set; } @@ -24,6 +25,8 @@ namespace <%= LogicAppNameCleaned %>.Tests this.TestExecutor = new TestExecutor("<%= WorkflowName %>/testSettings.config"); } -<%= TestClassContent %> +<%= TestCaseData %> + +<%= TestCaseMethods %> } } \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock index 2fef27ceefa..3eaa3faf3fc 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock @@ -1,2 +1,9 @@ - var actionMock<%= ActionNameCleaned %>Output = new <%= ActionMockOutputClassName %>(); - var actionMock<%= ActionNameCleaned %> = new <%= ActionMockClassName %>(status: <%= ActionMockStatus %>, name: "<%= ActionName %>", outputs: actionMock<%= ActionNameCleaned %>Output); \ No newline at end of file + var actionMock<%= ActionNameCleaned %> = mockErrors != null && mockErrors.ContainsKey("<%= ActionName %>") + ? new <%= ActionMockClassName %>( + status: <%= ActionMockStatus %>, + error: mockErrors["<%= ActionName %>"]) + : new <%= ActionMockClassName %>( + status: <%= ActionMockStatus %>, + outputs: mockOutputs != null && mockOutputs.ContainsKey("<%= ActionName %>") + ? (<%= ActionMockOutputClassName %>)mockOutputs["<%= ActionName %>"] + : new <%= ActionMockOutputClassName %>()); \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData index 9186d39abe3..5a5050e5a0b 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData @@ -1,22 +1,16 @@ -public static IEnumerable <%= PathName %>_TestData -{ - get - { - return new[] + public static IEnumerable <%= PathName %>_TestData { - new object[] + get { - // Trigger parameters - new Dictionary + return new[] { - // Add trigger parameters here - }, - // Action parameters - new Dictionary> - { - // Add action parameters here - } + new object[] + { + // Mock outputs + new Dictionary { }, + // Mock errors + new Dictionary { } + } + }; } - }; - } -} \ No newline at end of file + } \ 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 index 8f7cf8f803f..852283f210f 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod @@ -2,7 +2,11 @@ /// Test case for workflow <%= WorkflowName %> on path: <%= PathDescriptionString %> /// [TestMethod] - public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>() + [DynamicData(nameof(<%= PathName %>_TestData))] + public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>( + Dictionary mockOutputs, + Dictionary mockErrors + ) { // PREPARE // Generate mock trigger and action data from parameters From 06e4b9228df289906d5473c1d06113473d3ba322 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 29 Jul 2025 15:56:10 -0400 Subject: [PATCH 08/11] update TestCaseMethod template --- .../src/assets/UnitTestTemplates/TestCaseMethod | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod index 852283f210f..91309973f1c 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod @@ -3,10 +3,7 @@ /// [TestMethod] [DynamicData(nameof(<%= PathName %>_TestData))] - public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>( - Dictionary mockOutputs, - Dictionary mockErrors - ) + public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>(Dictionary mockOutputs, Dictionary mockErrors) { // PREPARE // Generate mock trigger and action data from parameters From fb5eea0a895558a8c1b86d70302dcb6544d9b3c8 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 29 Jul 2025 18:11:37 -0400 Subject: [PATCH 09/11] fix bug in populating flow graph edges, create .sln and cloud.settings.json --- .../workflows/unitTest/generateTests.ts | 8 ++++ .../src/app/utils/flowgraph.ts | 48 +++++++++++++++++-- .../src/assets/UnitTestTemplates/TestCaseData | 4 +- 3 files changed, 53 insertions(+), 7 deletions(-) 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 index 08567ee4b22..bd46ac387b2 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -19,10 +19,12 @@ import { 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. @@ -156,6 +158,12 @@ export async function generateTests(context: IActionContext, node: Uri | undefin 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}".', diff --git a/apps/vs-code-designer/src/app/utils/flowgraph.ts b/apps/vs-code-designer/src/app/utils/flowgraph.ts index 38066715277..c9dfe465785 100644 --- a/apps/vs-code-designer/src/app/utils/flowgraph.ts +++ b/apps/vs-code-designer/src/app/utils/flowgraph.ts @@ -81,6 +81,12 @@ export class FlowGraph { 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]); + } } } @@ -333,7 +339,7 @@ export class FlowGraph { return paths; } - private addNodeRec(actionName: string, action: Record, isChildAction = false) { + private addNodeRec(actionName: string, action: Record) { const actionType = action['type']; if (unsupportedActions.has(actionType)) { throw new Error(localize('unsupportedAction', `Unsupported action type: "${actionType}".`)); @@ -343,7 +349,7 @@ export class FlowGraph { const graphDefaultCase = FlowGraph.Subgraph(); const actionsDefaultCase = action['default']['actions']; for (const [childActionName, childAction] of Object.entries(actionsDefaultCase)) { - graphDefaultCase.addNodeRec(childActionName, childAction, true); + graphDefaultCase.addNodeRec(childActionName, childAction); } const graphCasesMap = new Map(); @@ -351,7 +357,7 @@ export class FlowGraph { const graphCase = FlowGraph.Subgraph(); const actionsCase = caseVal['actions']; for (const [childActionName, childAction] of Object.entries(actionsCase)) { - graphCase.addNodeRec(childActionName, childAction, true); + graphCase.addNodeRec(childActionName, childAction); } graphCasesMap.set(caseName, graphCase); } @@ -361,19 +367,51 @@ export class FlowGraph { const graphTrueBranch = FlowGraph.Subgraph(); const actionsTrueBranch = action['actions']; for (const [childActionName, childAction] of Object.entries(actionsTrueBranch)) { - graphTrueBranch.addNodeRec(childActionName, childAction, true); + 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, true); + graphFalseBranch.addNodeRec(childActionName, childAction); } this.addNode(actionName, { type: actionType, trueBranch: graphTrueBranch, falseBranch: graphFalseBranch }); } 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); + } + } if ('runAfter' in action && Object.keys(action['runAfter']).length > 0) { const runAfter = action['runAfter']; diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData index 5a5050e5a0b..e5709fbcd1c 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData @@ -7,9 +7,9 @@ new object[] { // Mock outputs - new Dictionary { }, + new Dictionary() { }, // Mock errors - new Dictionary { } + new Dictionary() { } } }; } From 67fa7b4e47d7bfe1dd028c6969031a3b8e6b1ab8 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 29 Jul 2025 20:45:09 -0400 Subject: [PATCH 10/11] add support for Scope actions --- .../workflows/unitTest/generateTests.ts | 8 +-- .../src/app/utils/flowgraph.ts | 50 ++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) 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 index bd46ac387b2..1b661e475f2 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -184,7 +184,7 @@ async function getActionMock(actionNode: PathNode, foundActionMocks: Record getActionMock(childActionNode, foundActionMocks))) ).flat(); @@ -217,7 +217,7 @@ async function getActionMock(actionNode: PathNode, foundActionMocks: Record): Promise { const actionMockOutputClassName = foundActionMocks[actionNode.name]; - if (actionNode.type === 'Switch' || actionNode.type === 'If') { + if (actionNode.type === 'Switch' || actionNode.type === 'If' || actionNode.type === 'Scope') { return ( await Promise.all( (actionNode as ParentPathNode).actions.map((childActionNode) => getActionMockEntry(childActionNode, foundActionMocks)) @@ -252,7 +252,7 @@ async function getActionAssertion(actionNode: PathNode, nestedActionPath = ''): const actionAssertionTemplate = await fse.readFile(actionAssertionTemplatePath, 'utf-8'); const childActionAssertions = - actionNode.type === 'Switch' || actionNode.type === 'If' + actionNode.type === 'Switch' || actionNode.type === 'If' || actionNode.type === 'Scope' ? ( await Promise.all( (actionNode as ParentPathNode).actions.map((childActionNode) => @@ -284,7 +284,7 @@ function getExecutedActionChain(path: PathNode[]): PathNode[] { const actionChain: PathNode[] = []; for (const actionNode of path) { - if (actionNode.type === 'Switch' || actionNode.type === 'If') { + if (actionNode.type === 'Switch' || actionNode.type === 'If' || actionNode.type === 'Scope') { actionChain.push(...getExecutedActionChain((actionNode as ParentPathNode).actions)); } else { actionChain.push(actionNode); diff --git a/apps/vs-code-designer/src/app/utils/flowgraph.ts b/apps/vs-code-designer/src/app/utils/flowgraph.ts index c9dfe465785..01cb83edc78 100644 --- a/apps/vs-code-designer/src/app/utils/flowgraph.ts +++ b/apps/vs-code-designer/src/app/utils/flowgraph.ts @@ -40,7 +40,9 @@ export type SwitchPathNode = ParentPathNode & { isDefaultCase: boolean; }; -const unsupportedActions = new Set(['Scope', 'ForEach', 'Until']); +export type ScopePathNode = ParentPathNode; + +const unsupportedActions = new Set(['ForEach', 'Until', 'Terminate']); const validStatuses = new Set(['SUCCEEDED', 'FAILED']); export class FlowGraph { @@ -293,6 +295,35 @@ export class FlowGraph { } }; + 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']; @@ -312,6 +343,11 @@ export class FlowGraph { return; } + if (nodeType === 'Scope') { + dfsScope(nodeId, path); + return; + } + if (this.isTerminalNode(nodeId, status)) { paths.push(path.slice()); } else { @@ -377,6 +413,13 @@ export class FlowGraph { } 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 }); } @@ -411,6 +454,11 @@ export class FlowGraph { 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) { From 2049adf1c72b7259d05b7907aa9c50b044a9637b Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Wed, 30 Jul 2025 15:31:02 -0400 Subject: [PATCH 11/11] update test DynamicData to pass mocks directly --- .../workflows/unitTest/generateTests.ts | 165 ++++++++---------- .../assets/UnitTestTemplates/TestActionMock | 9 - .../UnitTestTemplates/TestActionMockEntry | 1 - .../src/assets/UnitTestTemplates/TestCaseData | 16 -- .../assets/UnitTestTemplates/TestCaseMethod | 16 +- 5 files changed, 71 insertions(+), 136 deletions(-) delete mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock delete mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry delete mode 100644 apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData 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 index 1b661e475f2..63968470320 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/generateTests.ts @@ -94,10 +94,10 @@ export async function generateTests(context: IActionContext, node: Uri | undefin const pathActions = scenario.path.slice(1); const actionChain = getExecutedActionChain(pathActions); - const actionMocks = (await Promise.all(pathActions.map((actionNode) => getActionMock(actionNode, foundActionMocks)))).flat(); - const actionMockEntries = (await Promise.all(pathActions.map((actionNode) => getActionMockEntry(actionNode, foundActionMocks)))).flat(); + 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); @@ -106,20 +106,29 @@ export async function generateTests(context: IActionContext, node: Uri | undefin testCaseMethodTemplate .replace(/<%= WorkflowName %>/g, workflowName) .replace(/<%= WorkflowNameCleaned %>/g, workflowNameCleaned) - .replace(/<%= PathDescriptionString %>/g, getPathDescription(actionChain)) + .replace(/<%= PathDescriptionString %>/g, pathDescription) .replace(/<%= PathName %>/g, pathName) - .replace(/<%= TriggerMockOutputClassName %>/g, triggerMockOutputClassName) - .replace(/<%= TriggerMockClassName %>/g, triggerMockClassName) - .replace(/<%= ActionMocksContent %>/g, actionMocks.join('\n\n')) - .replace(/<%= ActionMockEntries %>/g, actionMockEntries.join(',\n')) .replace(/<%= ActionAssertionsContent %>/g, actionAssertions.join('\n\n')) .replace(/<%= PathOverallStatus %>/g, toTestWorkflowStatus(scenario.overallStatus)) ); - const testCaseDataTemplateFileName = 'TestCaseData'; - const testCaseDataTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, testCaseDataTemplateFileName); - const testCaseDataTemplate = await fse.readFile(testCaseDataTemplatePath, 'utf-8'); - testCaseData.push(testCaseDataTemplate.replace(/<%= PathName %>/g, pathName)); + 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'; @@ -175,69 +184,51 @@ export async function generateTests(context: IActionContext, node: Uri | undefin } /** - * Gets all action mocks for a given action node, including nested actions if applicable. - * @param {PathNode} actionNode - The action node to get mocks for. - * @param {Record} foundActionMocks - The mockable actions. - * @returns {Promise} - A Promise that resolves to an array of action mock strings. + * 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. */ -async function getActionMock(actionNode: PathNode, foundActionMocks: Record): Promise { - const actionMockOutputClassName = foundActionMocks[actionNode.name]; - const actionMockClassName = actionMockOutputClassName?.replace(/(.*)Output$/, '$1Mock'); - - if (actionNode.type === 'Switch' || actionNode.type === 'If' || actionNode.type === 'Scope') { - return ( - await Promise.all((actionNode as ParentPathNode).actions.map((childActionNode) => getActionMock(childActionNode, foundActionMocks))) - ).flat(); - } +function getExecutedActionChain(path: PathNode[]): PathNode[] { + const actionChain: PathNode[] = []; - if (actionMockOutputClassName === undefined) { - return []; + 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); + } } - const actionMockTemplateFileName = 'TestActionMock'; - const actionMockTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, actionMockTemplateFileName); - const actionMockTemplate = await fse.readFile(actionMockTemplatePath, 'utf-8'); - - return [ - actionMockTemplate - .replace(/<%= ActionName %>/g, actionNode.name) - .replace(/<%= ActionNameCleaned %>/g, actionNode.name.replace(/[^a-zA-Z0-9_]/g, '')) - .replace(/<%= ActionMockStatus %>/g, toTestWorkflowStatus(actionNode.status)) - .replace(/<%= ActionMockOutputClassName %>/g, actionMockOutputClassName) - .replace(/<%= ActionMockClassName %>/g, actionMockClassName), - ]; + return actionChain; } /** - * Gets all action mock dictionary entries for a given action node, including nested actions if applicable. - * @param {PathNode} actionNode - The action node to get mock entries for. - * @param {Record} foundActionMocks - The mockable actions. - * @returns {Promise} - A Promise that resolves to an array of action mock dictionary entry strings. + * 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. */ -async function getActionMockEntry(actionNode: PathNode, foundActionMocks: Record): Promise { - const actionMockOutputClassName = foundActionMocks[actionNode.name]; - - if (actionNode.type === 'Switch' || actionNode.type === 'If' || actionNode.type === 'Scope') { - return ( - await Promise.all( - (actionNode as ParentPathNode).actions.map((childActionNode) => getActionMockEntry(childActionNode, foundActionMocks)) - ) - ).flat(); - } - - if (actionMockOutputClassName === undefined) { - return []; - } +function getMockableExecutedActions(actionChain: PathNode[], foundActionMocks: Record): PathNode[] { + return actionChain.filter((actionNode) => actionNode.name in foundActionMocks); +} - const actionMockEntryTemplateFileName = 'TestActionMockEntry'; - const actionMockEntryTemplatePath = path.join(__dirname, assetsFolderName, unitTestTemplatesFolderName, actionMockEntryTemplateFileName); - const actionMockEntryTemplate = await fse.readFile(actionMockEntryTemplatePath, 'utf-8'); +/** + * 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}`; +} - return [ - actionMockEntryTemplate - .replace(/<%= ActionName %>/g, actionNode.name) - .replace(/<%= ActionNameCleaned %>/g, actionNode.name.replace(/[^a-zA-Z0-9_]/g, '')), - ]; +/** + * 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(' -> '); } /** @@ -271,44 +262,24 @@ async function getActionAssertion(actionNode: PathNode, nestedActionPath = ''): ]; } -function toTestWorkflowStatus(status: string): string { - return `TestWorkflowStatus.${status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}`; -} - /** - * 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. + * 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 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; -} +function getTestDataActionMockEntry(actionNode: PathNode, foundActionMocks: Record): string { + const actionMockOutputClassName = foundActionMocks[actionNode.name]; + const actionMockClassName = actionMockOutputClassName?.replace(/(.*)Output$/, '$1Mock'); -/** - * 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(' -> '); + return `{ "${actionNode.name}", new ${actionMockClassName}(status: ${toTestWorkflowStatus(actionNode.status)}, outputs: new ${actionMockOutputClassName}()) }`; } /** - * 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. + * 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 getPathName(index: number, overallStatus: string): string { - return `Path${index}_${overallStatus}`; +function toTestWorkflowStatus(status: string): string { + return `TestWorkflowStatus.${status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}`; } diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock deleted file mode 100644 index 3eaa3faf3fc..00000000000 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMock +++ /dev/null @@ -1,9 +0,0 @@ - var actionMock<%= ActionNameCleaned %> = mockErrors != null && mockErrors.ContainsKey("<%= ActionName %>") - ? new <%= ActionMockClassName %>( - status: <%= ActionMockStatus %>, - error: mockErrors["<%= ActionName %>"]) - : new <%= ActionMockClassName %>( - status: <%= ActionMockStatus %>, - outputs: mockOutputs != null && mockOutputs.ContainsKey("<%= ActionName %>") - ? (<%= ActionMockOutputClassName %>)mockOutputs["<%= ActionName %>"] - : new <%= ActionMockOutputClassName %>()); \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry deleted file mode 100644 index 633475fe415..00000000000 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestActionMockEntry +++ /dev/null @@ -1 +0,0 @@ - { "<%= ActionName %>", actionMock<%= ActionNameCleaned %> } \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData deleted file mode 100644 index e5709fbcd1c..00000000000 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseData +++ /dev/null @@ -1,16 +0,0 @@ - public static IEnumerable <%= PathName %>_TestData - { - get - { - return new[] - { - new object[] - { - // Mock outputs - new Dictionary() { }, - // Mock errors - new Dictionary() { } - } - }; - } - } \ 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 index 91309973f1c..06c27a661dc 100644 --- a/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod +++ b/apps/vs-code-designer/src/assets/UnitTestTemplates/TestCaseMethod @@ -3,19 +3,8 @@ /// [TestMethod] [DynamicData(nameof(<%= PathName %>_TestData))] - public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>(Dictionary mockOutputs, Dictionary mockErrors) + public async Task ExecuteWorkflow_<%= WorkflowNameCleaned %>_<%= PathName %>(TriggerMock triggerMock, Dictionary actionMocks) { - // PREPARE - // Generate mock trigger and action data from parameters - var triggerMockOutput = new <%= TriggerMockOutputClassName %>(); - var triggerMock = new <%= TriggerMockClassName %>(outputs: triggerMockOutput); - -<%= ActionMocksContent %> - var actionMocks = new Dictionary() - { -<%= ActionMockEntries %> - }; - // ACT // Create an instance of UnitTestExecutor, and run the workflow with the mock data var testMock = new TestMockDefinition( @@ -25,11 +14,12 @@ .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