Skip to content

feat(vscode): Add function to create and update solution file with Logic App .csproj #6721

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 10, 2025
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// saveBlankUnitTest.test.ts
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import * as vscode from 'vscode';
import * as fs from 'fs-extra';
import * as path from 'path';
import * as util from 'util';
import * as childProcess from 'child_process';

// Import the function under test and the utility modules
import { saveBlankUnitTest } from '../saveBlankUnitTest';
Expand Down Expand Up @@ -52,13 +53,16 @@ describe('saveBlankUnitTest', () => {
foundTriggerMocks: {},
};

let updateSolutionWithProjectSpy: any;

beforeEach(() => {
// Stub utility functions used in saveBlankUnitTest
vi.spyOn(workspaceUtils, 'getWorkspaceFolder').mockResolvedValue(dummyWorkspaceFolder);
vi.spyOn(projectRootUtils, 'tryGetLogicAppProjectRoot').mockResolvedValue(dummyProjectPath);
vi.spyOn(unitTestUtils, 'parseUnitTestOutputs').mockResolvedValue({} as any);
vi.spyOn(unitTestUtils, 'selectWorkflowNode').mockResolvedValue(dummyWorkflowNodeUri);
vi.spyOn(unitTestUtils, 'promptForUnitTestName').mockResolvedValue(dummyUnitTestName);
vi.spyOn(unitTestUtils, 'validateWorkflowPath').mockResolvedValue();
vi.spyOn(unitTestUtils, 'getUnitTestPaths').mockReturnValue(dummyPaths);
vi.spyOn(unitTestUtils, 'processAndWriteMockableOperations').mockResolvedValue(dummyMockOperations);

Expand Down Expand Up @@ -88,6 +92,11 @@ describe('saveBlankUnitTest', () => {
vi.spyOn(unitTestUtils, 'ensureCsproj').mockResolvedValue();
vi.spyOn(workspaceUtils, 'ensureDirectoryInWorkspace').mockResolvedValue();
vi.spyOn(ext.outputChannel, 'appendLog').mockImplementation(() => {});

// Stub the methods used in updateSolutionWithProject
updateSolutionWithProjectSpy = vi.spyOn(unitTestUtils, 'updateSolutionWithProject');
vi.spyOn(util, 'promisify').mockImplementation((fn) => fn);
vi.spyOn(childProcess, 'exec').mockResolvedValue(new childProcess.ChildProcess());
});

afterEach(() => {
Expand All @@ -105,6 +114,9 @@ describe('saveBlankUnitTest', () => {
expect(fs.ensureDir).toHaveBeenCalled();
// Verify that the backend process was invoked via callWithTelemetryAndErrorHandling
expect(azextUtils.callWithTelemetryAndErrorHandling).toHaveBeenCalled();

expect(updateSolutionWithProjectSpy).toHaveBeenCalledOnce();
expect(updateSolutionWithProjectSpy).not.toThrowError();
});

test('should not continue if not a valid workspace', async () => {
Expand All @@ -114,6 +126,7 @@ describe('saveBlankUnitTest', () => {
await saveBlankUnitTest(dummyContext, dummyNode, dummyUnitTestDefinition);
expect(unitTestUtils.promptForUnitTestName).toHaveBeenCalledTimes(0);
expect(unitTestUtils.logTelemetry).toHaveBeenCalledWith(dummyContext, expect.objectContaining({ multiRootWorkspaceValid: 'false' }));
expect(updateSolutionWithProjectSpy).not.toHaveBeenCalled();
});

test('should log an error and call handleError when an exception occurs', async () => {
Expand All @@ -124,5 +137,6 @@ describe('saveBlankUnitTest', () => {

// Verify that the error logging function was called
expect(unitTestUtils.handleError).toHaveBeenCalled();
expect(updateSolutionWithProjectSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
processAndWriteMockableOperations,
promptForUnitTestName,
selectWorkflowNode,
updateSolutionWithProject,
validateWorkflowPath,
} from '../../../utils/unitTests';
import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject';
import { ensureDirectoryInWorkspace, getWorkflowNode, getWorkspaceFolder } from '../../../utils/workspace';
Expand Down Expand Up @@ -73,6 +75,12 @@ export async function createUnitTest(
// Determine workflow node
const workflowNode = node ? (getWorkflowNode(node) as vscode.Uri) : await selectWorkflowNode(context, projectPath);

try {
validateWorkflowPath(projectPath, workflowNode.fsPath);
} catch (error) {
vscode.window.showErrorMessage(`Workflow validation failed: ${error.message}`);
return;
}
// Get workflow name and prompt for unit test name
const workflowName = path.basename(path.dirname(workflowNode.fsPath));
const unitTestName = await promptForUnitTestName(context, projectPath, workflowName);
Expand Down Expand Up @@ -287,6 +295,14 @@ async function generateUnitTestFromRun(
);
logTelemetry(context, { unitTestGenerationStatus: 'Success' });
context.telemetry.measurements.generateCodefulUnitTestMs = Date.now() - startTime;
try {
const csprojFilePath = path.join(paths.logicAppTestFolderPath, `${paths.logicAppName}.csproj`);

ext.outputChannel.appendLog(`Updating solution in tests folder: ${paths.testsDirectory}`);
await updateSolutionWithProject(paths.testsDirectory, csprojFilePath);
} catch (solutionError) {
ext.outputChannel.appendLog(`Failed to update solution: ${solutionError}`);
}
} catch (methodError) {
context.telemetry.properties.unitTestGenerationStatus = 'Failed';
const errorMessage = parseErrorBeforeTelemetry(methodError);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
promptForUnitTestName,
selectWorkflowNode,
processAndWriteMockableOperations,
updateSolutionWithProject,
validateWorkflowPath,
} from '../../../utils/unitTests';
import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject';
import { ensureDirectoryInWorkspace, getWorkflowNode, getWorkspaceFolder } from '../../../utils/workspace';
Expand Down Expand Up @@ -109,6 +111,12 @@ export async function saveBlankUnitTest(
workflowNodePath: workflowNode ? workflowNode.fsPath : '',
});

try {
validateWorkflowPath(projectPath, workflowNode.fsPath);
} catch (error) {
vscode.window.showErrorMessage(`Workflow validation failed: ${error.message}`);
return;
}
const workflowName = path.basename(path.dirname(workflowNode.fsPath));

// Prompt for unit test name
Expand All @@ -118,9 +126,11 @@ export async function saveBlankUnitTest(
});
ext.outputChannel.appendLog(localize('unitTestNameEntered', `Unit test name entered: ${unitTestName}`));

// Retrieve unitTestFolderPath and logic app name from helper
const { unitTestFolderPath, logicAppName, workflowTestFolderPath } = getUnitTestPaths(projectPath, workflowName, unitTestName);

const { unitTestFolderPath, logicAppName, workflowTestFolderPath, logicAppTestFolderPath, testsDirectory } = getUnitTestPaths(
projectPath,
workflowName,
unitTestName
);
// Retrieve necessary paths
// Indicate that we resolved the folder path
logTelemetry(context, {
Expand Down Expand Up @@ -152,6 +162,14 @@ export async function saveBlankUnitTest(
unitTestSaveStatus: 'Success',
unitTestProcessingTimeMs: (Date.now() - startTime).toString(),
});
try {
// Construct the path for the .csproj file using the logic app test folder
const csprojFilePath = path.join(logicAppTestFolderPath, `${logicAppName}.csproj`);
ext.outputChannel.appendLog(`Updating solution in tests folder: ${unitTestFolderPath}`);
await updateSolutionWithProject(testsDirectory, csprojFilePath);
} catch (solutionError) {
ext.outputChannel.appendLog(`Failed to update solution: ${solutionError}`);
}
} catch (error) {
// Handle errors using the helper function
logTelemetry(context, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
import * as fse from 'fs-extra';
import * as childProcess from 'child_process';
import * as util from 'util';
import path from 'path';
import * as localizeModule from '../../../../localize';
import { ext } from '../../../../extensionVariables';
Expand All @@ -21,6 +23,8 @@ import {
createCsFile,
createTestExecutorFile,
createTestSettingsConfigFile,
updateSolutionWithProject,
validateWorkflowPath,
} from '../../unitTests';

// ============================================================================
Expand Down Expand Up @@ -1515,3 +1519,61 @@ describe('createTestSettingsConfig', () => {
expect(writeFileSpyCalledWith[1]).toEqual(expect.stringContaining(`<WorkflowName>${workflowName}</WorkflowName>`));
});
});

describe('updateSolutionWithProject', () => {
let pathExistsSpy: any;
let execSpy: any;

beforeEach(() => {
vi.spyOn(ext.outputChannel, 'appendLog').mockImplementation(() => {});
vi.spyOn(util, 'promisify').mockImplementation((fn) => fn);
execSpy = vi.spyOn(childProcess, 'exec').mockResolvedValue(new childProcess.ChildProcess());
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should update the solution with the project when solution file exists', async () => {
pathExistsSpy = vi.spyOn(fse, 'pathExists').mockResolvedValue(true);

const testsDirectory = path.join(projectPath, 'Tests');
const logicAppCsprojPath = path.join(testsDirectory, `${fakeLogicAppName}.csproj`);

await updateSolutionWithProject(testsDirectory, logicAppCsprojPath);

expect(execSpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledWith(
`dotnet sln "${path.join(testsDirectory, 'Tests.sln')}" add "${fakeLogicAppName}.csproj"`,
expect.anything()
);
});

it('should create a new solution file when it does not exist', async () => {
pathExistsSpy = vi.spyOn(fse, 'pathExists').mockResolvedValue(false);

const testsDirectory = path.join(projectPath, 'Tests');
const logicAppCsprojPath = path.join(testsDirectory, `${fakeLogicAppName}.csproj`);

await updateSolutionWithProject(testsDirectory, logicAppCsprojPath);

expect(execSpy).toHaveBeenCalledTimes(2);
expect(execSpy).toHaveBeenCalledWith('dotnet new sln -n Tests', expect.anything());
expect(execSpy).toHaveBeenCalledWith(
`dotnet sln "${path.join(testsDirectory, 'Tests.sln')}" add "${fakeLogicAppName}.csproj"`,
expect.anything()
);
});
});

describe('validateWorkflowPath', () => {
it('should throw an error if the workflow node is not valid', () => {
const invalidWorkflowPath = path.join(projectPath, '..', fakeLogicAppName, 'workflow1', 'workflow.json');
expect(() => validateWorkflowPath(projectPath, invalidWorkflowPath)).toThrowError("doesn't belong to the Logic Apps Standard Project");
});

it('should not throw an error if the workflow node is valid', () => {
const validWorkflowPath = path.join(projectPath, fakeLogicAppName, 'workflow1', 'workflow.json');
expect(() => validateWorkflowPath(projectPath, validWorkflowPath)).not.toThrowError();
});
});
100 changes: 93 additions & 7 deletions apps/vs-code-designer/src/app/utils/unitTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.md in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { UnitTestResult } from '@microsoft/vscode-extension-logic-apps';
import { saveUnitTestEvent, testsDirectoryName, unitTestsFileName, workflowFileName } from '../../constants';
import { type IAzureQuickPickItem, type IActionContext, callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils';
import { exec } from 'child_process';
import axios from 'axios';
import * as fse from 'fs-extra';
import * as path from 'path';
import * as vscode from 'vscode';
import * as xml2js from 'xml2js';
import { getWorkflowsInLocalProject } from './codeless/common';
import { ext } from '../../extensionVariables';
import type { IAzureConnectorsContext } from '../commands/workflows/azureConnectorWizard';
import axios from 'axios';
import { type IActionContext, callWithTelemetryAndErrorHandling, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils';
import type { UnitTestResult } from '@microsoft/vscode-extension-logic-apps';
import { toPascalCase } from '@microsoft/logic-apps-shared';
import { saveUnitTestEvent, testsDirectoryName, unitTestsFileName, workflowFileName } from '../../constants';
import { ext } from '../../extensionVariables';
import { localize } from '../../localize';
import { getWorkflowsInLocalProject } from './codeless/common';
import type { IAzureConnectorsContext } from '../commands/workflows/azureConnectorWizard';
import { promisify } from 'util';

/**
* Saves the unit test definition for a workflow.
Expand Down Expand Up @@ -1233,3 +1235,87 @@ export async function isMockable(type: string): Promise<boolean> {
}
return false;
}

/**
* Creates a new solution file and adds the specified Logic App .csproj to it.
*
* This function performs the following steps in the tests directory:
* 1. Runs 'dotnet new sln -n Tests' to create a new solution file named Tests.sln.
* 2. Computes the relative path from the tests directory to the Logic App .csproj.
* 3. Runs 'dotnet sln Tests.sln add <relativePath>' to add the project to the solution.
*
* @param testsDirectory - The absolute path to the tests directory root.
* @param logicAppCsprojPath - The absolute path to the Logic App's .csproj file.
*/
export async function updateSolutionWithProject(testsDirectory: string, logicAppCsprojPath: string): Promise<void> {
const solutionName = 'Tests'; // This will create "Tests.sln"
const solutionFile = path.join(testsDirectory, `${solutionName}.sln`);
const execAsync = promisify(exec);

try {
// Create a new solution file if it doesn't already exist.
if (await fse.pathExists(solutionFile)) {
ext.outputChannel.appendLog(`Solution file already exists at ${solutionFile}.`);
} else {
ext.outputChannel.appendLog(`Creating new solution file at ${solutionFile}...`);
await execAsync(`dotnet new sln -n ${solutionName}`, { cwd: testsDirectory });
ext.outputChannel.appendLog(`Solution file created: ${solutionFile}`);
}

// Compute the relative path from the tests directory to the Logic App .csproj.
const relativeProjectPath = path.relative(testsDirectory, logicAppCsprojPath);
ext.outputChannel.appendLog(`Adding project '${relativeProjectPath}' to solution '${solutionFile}'...`);
await execAsync(`dotnet sln "${solutionFile}" add "${relativeProjectPath}"`, { cwd: testsDirectory });
ext.outputChannel.appendLog('Project added to solution successfully.');
} catch (err) {
ext.outputChannel.appendLog(`Error updating solution: ${err}`);
vscode.window.showErrorMessage(`Error updating solution: ${err}`);
}
}

/**
* Validates that the workflow file belongs to the expected project folder.
* Logs telemetry if the workflow is not within the project folder and throws an error.
* @param projectPath - The absolute file system path of the project.
* @param workflowPath - The workflow file path.
* @param telemetryContext - (Optional) The telemetry or action context for logging events.
* @throws {Error} Throws an error if the workflow file is not inside the project folder.
*/
export function validateWorkflowPath(projectPath: string, workflowPath: string, telemetryContext?: any): void {
if (!workflowPath) {
if (telemetryContext) {
logTelemetry(telemetryContext, {
validationError: 'undefinedWorkflowPath',
});
}
throw new Error(localize('error.undefinedWorkflowPath', 'The provided workflow path is undefined.'));
}

// Normalize both paths for fair comparison.
const normalizedProjectPath = path.normalize(projectPath).toLowerCase();
const normalizedWorkflowPath = path.normalize(workflowPath).toLowerCase();

// Use path.relative to determine if the workflow path is inside the project folder.
const relativePath = path.relative(normalizedProjectPath, normalizedWorkflowPath);

// If 'relativePath' suggests the file is outside of 'projectPath'...
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
// Log telemetry if provided.
if (telemetryContext) {
logTelemetry(telemetryContext, {
validationError: 'wrongWorkspace',
expectedProjectPath: normalizedProjectPath,
actualWorkflowPath: normalizedWorkflowPath,
});
}
throw new Error(
localize(
'error.wrongWorkspace',
// Insert paths into the final message
"The Logic Apps Standard workflow {0} doesn't belong to the Logic Apps Standard Project {1}. Please select the correct Logic Apps Standard project and try again.",
normalizedWorkflowPath,
normalizedProjectPath
)
);
}
}
4 changes: 4 additions & 0 deletions apps/vs-code-designer/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ vi.mock('fs-extra', () => ({
pathExists: vi.fn(() => Promise.resolve()),
}));

vi.mock('child_process');

vi.mock('util');

vi.mock('axios');

vi.mock('vscode', () => ({
Expand Down
Loading