diff --git a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/__test__/functionFileStep.test.ts b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/__test__/functionFileStep.test.ts new file mode 100644 index 00000000000..ba98966de7f --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/__test__/functionFileStep.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TargetFramework } from '@microsoft/vscode-extension-logic-apps'; + +vi.mock('fs-extra'); +vi.mock('vscode'); +vi.mock('../../../../../extensionVariables', () => ({ + ext: { outputChannel: { appendLog: vi.fn() } }, +})); +vi.mock('../../../../../localize', () => ({ + localize: (_key: string, msg: string) => msg, +})); + +import { FunctionFileStep } from '../functionFileStep'; + +describe('FunctionFileStep', () => { + describe('csTemplateFileName mapping', () => { + it('should map Net10 to FunctionsFileNet10', () => { + const step = new FunctionFileStep(); + const mapping = (step as any).csTemplateFileName; + expect(mapping[TargetFramework.Net10]).toBe('FunctionsFileNet10'); + }); + + it('should preserve Net8 mapping', () => { + const step = new FunctionFileStep(); + const mapping = (step as any).csTemplateFileName; + expect(mapping[TargetFramework.Net8]).toBe('FunctionsFileNet8'); + }); + + it('should preserve NetFx mapping', () => { + const step = new FunctionFileStep(); + const mapping = (step as any).csTemplateFileName; + expect(mapping[TargetFramework.NetFx]).toBe('FunctionsFileNetFx'); + }); + + it('should contain exactly three framework entries', () => { + const step = new FunctionFileStep(); + const mapping = (step as any).csTemplateFileName; + expect(Object.keys(mapping)).toHaveLength(3); + }); + }); + + describe('shouldPrompt', () => { + it('should always return true', () => { + const step = new FunctionFileStep(); + expect(step.shouldPrompt()).toBe(true); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts index d96458245e2..cc80f422392 100644 --- a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts +++ b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts @@ -21,6 +21,7 @@ export class FunctionFileStep extends AzureWizardPromptStep { - // Set the functionAppName and namespaceName properties from the context wizard - const functionAppName = context.functionAppName; - const namespace = context.functionAppNamespace; - const targetFramework = context.targetFramework; + const { functionAppName, functionAppNamespace: namespace, targetFramework, projectType } = context; const logicAppName = context.logicAppName || 'LogicApp'; - // const funcVersion = context.version ?? (await tryGetLocalFuncVersion()); - - // Define the functions folder path using the context property of the wizard const functionFolderPath = path.join(context.workspacePath, context.functionFolderName); - await fs.ensureDir(functionFolderPath); - - // Define the type of project in the workspace - const projectType = context.projectType; + const assetsPath = path.join(__dirname, assetsFolderName); - // Create the .cs file inside the functions folder - await this.createCsFile(functionFolderPath, functionAppName, namespace, projectType, targetFramework); + await fs.ensureDir(functionFolderPath); + await createCsFile(assetsPath, functionFolderPath, functionAppName, namespace, projectType, targetFramework); + await createProgramFile(assetsPath, functionFolderPath, namespace, projectType, targetFramework); - // Create the .cs files inside the functions folders for rule code projects if (projectType === ProjectType.rulesEngine) { - await this.createRulesFiles(functionFolderPath); + await createRulesFiles(assetsPath, functionFolderPath); } - // Create the .csproj file inside the functions folder - await this.createCsprojFile(functionFolderPath, functionAppName, logicAppName, projectType, targetFramework); - - // Generate the Visual Studio Code configuration files in the specified folder. + await createCsprojFile(assetsPath, functionFolderPath, functionAppName, logicAppName, projectType, targetFramework); await this.createVscodeConfigFiles(functionFolderPath, targetFramework); } - /** - * Creates the .cs file inside the functions folder. - * @param functionFolderPath - The path to the functions folder. - * @param methodName - The name of the method. - * @param namespace - The name of the namespace. - * @param projectType - The workspace projet type. - * @param targetFramework - The target framework. - */ - private async createCsFile( - functionFolderPath: string, - methodName: string, - namespace: string, - projectType: ProjectType, - targetFramework: TargetFramework - ): Promise { - const templateFile = - projectType === ProjectType.rulesEngine ? this.csTemplateFileName[ProjectType.rulesEngine] : this.csTemplateFileName[targetFramework]; - const templatePath = path.join(__dirname, assetsFolderName, this.templateFolderName[projectType], templateFile); - const templateContent = await fs.readFile(templatePath, 'utf-8'); - - const csFilePath = path.join(functionFolderPath, `${methodName}.cs`); - const csFileContent = templateContent.replace(/<%= methodName %>/g, methodName).replace(/<%= namespace %>/g, namespace); - await fs.writeFile(csFilePath, csFileContent); - } - - /** - * Creates the rules files for the project. - * @param {string} functionFolderPath - The path of the function folder. - * @returns A promise that resolves when the rules files are created. - */ - private async createRulesFiles(functionFolderPath: string): Promise { - const csTemplatePath = path.join(__dirname, assetsFolderName, 'RuleSetProjectTemplate', 'ContosoPurchase'); - const csRuleSetPath = path.join(functionFolderPath, 'ContosoPurchase.cs'); - await fs.copyFile(csTemplatePath, csRuleSetPath); - } - - /** - * Creates a .csproj file for a specific Azure Function. - * @param functionFolderPath - The path to the folder where the .csproj file will be created. - * @param methodName - The name of the Azure Function. - * @param projectType - The workspace projet type. - * @param targetFramework - The target framework. - */ - private async createCsprojFile( - functionFolderPath: string, - methodName: string, - logicAppName: string, - projectType: ProjectType, - targetFramework: TargetFramework - ): Promise { - const templateFile = - projectType === ProjectType.rulesEngine - ? this.csprojTemplateFileName[ProjectType.rulesEngine] - : this.csprojTemplateFileName[targetFramework]; - const templatePath = path.join(__dirname, assetsFolderName, this.templateFolderName[projectType], templateFile); - const templateContent = await fs.readFile(templatePath, 'utf-8'); - - const csprojFilePath = path.join(functionFolderPath, `${methodName}.csproj`); - let csprojFileContent: string; - if (targetFramework === TargetFramework.Net8 && projectType === ProjectType.customCode) { - csprojFileContent = templateContent.replace( - /\$\(MSBuildProjectDirectory\)\\..\\LogicApp<\/LogicAppFolderToPublish>/g, - `$(MSBuildProjectDirectory)\\..\\${logicAppName}` - ); - } else { - csprojFileContent = templateContent.replace( - /LogicApp<\/LogicAppFolder>/g, - `${logicAppName}` - ); - } - await fs.writeFile(csprojFilePath, csprojFileContent); - } - /** * Creates the Visual Studio Code configuration files in the .vscode folder of the specified functions app. * @param functionFolderPath The path to the functions folder. @@ -232,7 +133,7 @@ export class CreateFunctionAppFiles { if (debugConfig.type === 'logicapp') { return { ...debugConfig, - customCodeRuntime: targetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', + customCodeRuntime: getCustomCodeRuntime(targetFramework), isCodeless: true, }; } @@ -244,7 +145,7 @@ export class CreateFunctionAppFiles { type: 'logicapp', request: 'launch', funcRuntime: funcVersion === FuncVersion.v1 ? 'clr' : 'coreclr', - customCodeRuntime: targetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', + customCodeRuntime: getCustomCodeRuntime(targetFramework), isCodeless: true, }, ...debugConfigs.filter( diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppVSCodeContents.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppVSCodeContents.ts index 1250806b278..3b2b218026b 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppVSCodeContents.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppVSCodeContents.ts @@ -1,5 +1,5 @@ -import { latestGAVersion, ProjectLanguage, ProjectType, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; -import type { ILaunchJson, ISettingToAdd, IWebviewProjectContext } from '@microsoft/vscode-extension-logic-apps'; +import { latestGAVersion, ProjectLanguage, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import type { ILaunchJson, ISettingToAdd, IWebviewProjectContext, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; import { assetsFolderName, containerTemplatesFolderName, @@ -24,6 +24,7 @@ import { confirmEditJsonFile } from '../../../utils/fs'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { localize } from '../../../../localize'; import { ext } from '../../../../extensionVariables'; +import { getCustomCodeRuntime } from '../../../utils/debug'; import { isDebugConfigEqual } from '../../../utils/vsCodeConfig/launch'; export async function writeSettingsJson( @@ -31,13 +32,14 @@ export async function writeSettingsJson( additionalSettings: ISettingToAdd[], vscodePath: string ): Promise { - const settings: ISettingToAdd[] = additionalSettings.concat( + const settings: ISettingToAdd[] = [ + ...additionalSettings, { key: projectLanguageSetting, value: ProjectLanguage.JavaScript }, { key: funcVersionSetting, value: latestGAVersion }, // We want the terminal to open after F5, not the debug console because HTTP triggers are printed in the terminal. { prefix: 'debug', key: 'internalConsoleOptions', value: 'neverOpen' }, - { prefix: 'azureFunctions', key: 'suppressProject', value: true } - ); + { prefix: 'azureFunctions', key: 'suppressProject', value: true }, + ]; const settingsJsonPath: string = path.join(vscodePath, settingsFileName); await confirmEditJsonFile(context, settingsJsonPath, (data: Record): Record => { @@ -76,7 +78,7 @@ export function getDebugConfiguration(logicAppName: string, customCodeTargetFram type: 'logicapp', request: 'launch', funcRuntime: 'coreclr', - customCodeRuntime: customCodeTargetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', + customCodeRuntime: getCustomCodeRuntime(customCodeTargetFramework), isCodeless: true, }; } @@ -107,12 +109,8 @@ export async function writeLaunchJson( } export function insertLaunchConfig(existingConfigs: DebugConfiguration[] | undefined, newConfig: DebugConfiguration): DebugConfiguration[] { - // tslint:disable-next-line: strict-boolean-expressions - existingConfigs = existingConfigs || []; - // Remove configs that match the one we're about to add - existingConfigs = existingConfigs.filter((l1) => !isDebugConfigEqual(l1, newConfig)); - existingConfigs.push(newConfig); - return existingConfigs; + const configs = (existingConfigs ?? []).filter((existingConfig) => !isDebugConfigEqual(existingConfig, newConfig)); + return [...configs, newConfig]; } export async function createLogicAppVsCodeContents( diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject.test.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject.test.ts index a0cf4b118b5..018c0d2efa8 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject.test.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject.test.ts @@ -382,6 +382,28 @@ describe('createLogicAppProject', () => { ); }); + it('should create custom code project with Net10 target framework', async () => { + const customCodeOptions = { + ...mockOptions, + logicAppType: ProjectType.customCode, + targetFramework: 'net10.0', + }; + + const mockSetup = vi.fn().mockResolvedValue(undefined); + (CreateFunctionAppFiles as Mock).mockImplementation(() => ({ + setup: mockSetup, + })); + + await createLogicAppProject(mockContext, customCodeOptions, workspaceRootFolder); + + expect(mockSetup).toHaveBeenCalledWith( + expect.objectContaining({ + projectType: ProjectType.customCode, + targetFramework: 'net10.0', + }) + ); + }); + it('should pass correct function parameters to custom code project', async () => { const customCodeOptions = { ...mockOptions, @@ -725,6 +747,7 @@ describe('createLogicAppProject - Integration Tests', () => { // Create .cs file from template using correct path const csTemplateMap: Record = { + 'net10.0': 'FunctionsFileNet10', net8: 'FunctionsFileNet8', net472: 'FunctionsFileNetFx', rulesEngine: 'RulesFunctionsFile', @@ -737,6 +760,13 @@ describe('createLogicAppProject - Integration Tests', () => { const csContent = await processTemplate(csTemplatePath, { methodName, namespace }); await fse.writeFile(path.join(functionFolderPath, `${methodName}.cs`), csContent); + // Create Program.cs for .NET 10 custom code projects + if (targetFramework === 'net10.0' && projectType !== ProjectType.rulesEngine) { + const programTemplatePath = path.join(functionTemplatesPath, 'ProgramFileNet10'); + const programContent = await processTemplate(programTemplatePath, { namespace }); + await fse.writeFile(path.join(functionFolderPath, 'Program.cs'), programContent); + } + // Create rules files for rulesEngine if (projectType === ProjectType.rulesEngine) { const contosoPurchasePath = path.join(rulesTemplatesPath, 'ContosoPurchase'); @@ -749,7 +779,8 @@ describe('createLogicAppProject - Integration Tests', () => { // Create .csproj file from template using correct path const csprojTemplateMap: Record = { - net8: 'FunctionsProjNet8New', + 'net10.0': 'FunctionsProjNet10', + net8: 'FunctionsProjNet8', net472: 'FunctionsProjNetFx', rulesEngine: 'RulesFunctionsProj', }; @@ -761,7 +792,7 @@ describe('createLogicAppProject - Integration Tests', () => { let csprojContent = await fse.readFile(csprojTemplatePath, 'utf-8'); // Replace LogicApp folder references - if (targetFramework === 'net8' && projectType === ProjectType.customCode) { + if ((targetFramework === 'net8' || targetFramework === 'net10.0') && projectType === ProjectType.customCode) { csprojContent = csprojContent.replace( /\$\(MSBuildProjectDirectory\)\\..\\LogicApp<\/LogicAppFolderToPublish>/g, `$(MSBuildProjectDirectory)\\..\\${logicAppName}` @@ -1250,6 +1281,108 @@ local.settings.json` const settingsContent = await fse.readJSON(settingsPath); expect(settingsContent).toHaveProperty('azureFunctions.projectRuntime'); }); + + it('should create Program.cs for Net10 custom code project with correct namespace', async () => { + const options: IWebviewProjectContext = { + workspaceProjectPath: { fsPath: tempDir } as vscode.Uri, + workspaceName: 'TestWorkspace', + logicAppName: 'TestLogicApp', + logicAppType: ProjectType.customCode, + workflowName: 'MyWorkflow', + workflowType: 'Stateful', + functionFolderName: 'Functions', + functionName: 'MyFunction', + functionNamespace: 'MyCompany.Functions', + targetFramework: 'net10.0', + } as any; + + const functionAppFiles = createTestFunctionAppFiles(); + vi.mocked(CreateFunctionAppFiles).mockImplementation( + () => + ({ + setup: (ctx: IProjectWizardContext) => functionAppFiles.setup(ctx), + hideStepCount: true, + }) as any + ); + + await createLogicAppProject(mockContext, options, workspaceRootFolder); + + // Verify Program.cs was created + const functionsFolderPath = path.join(workspaceRootFolder, 'Functions'); + const programCsPath = path.join(functionsFolderPath, 'Program.cs'); + const programCsExists = await fse.pathExists(programCsPath); + expect(programCsExists).toBe(true); + + // Verify Program.cs content has namespace replaced + const programContent = await fse.readFile(programCsPath, 'utf-8'); + expect(programContent).toContain('namespace MyCompany.Functions'); + expect(programContent).not.toContain('<%= namespace %>'); + expect(programContent).toContain('HostBuilder'); + }); + + it('should not create Program.cs for Net8 custom code project', async () => { + const options: IWebviewProjectContext = { + workspaceProjectPath: { fsPath: tempDir } as vscode.Uri, + workspaceName: 'TestWorkspace', + logicAppName: 'TestLogicApp', + logicAppType: ProjectType.customCode, + workflowName: 'MyWorkflow', + workflowType: 'Stateful', + functionFolderName: 'Functions', + functionName: 'MyFunction', + functionNamespace: 'MyNamespace', + targetFramework: 'net8', + } as any; + + const functionAppFiles = createTestFunctionAppFiles(); + vi.mocked(CreateFunctionAppFiles).mockImplementation( + () => + ({ + setup: (ctx: IProjectWizardContext) => functionAppFiles.setup(ctx), + hideStepCount: true, + }) as any + ); + + await createLogicAppProject(mockContext, options, workspaceRootFolder); + + // Verify Program.cs was NOT created for Net8 + const functionsFolderPath = path.join(workspaceRootFolder, 'Functions'); + const programCsPath = path.join(functionsFolderPath, 'Program.cs'); + const programCsExists = await fse.pathExists(programCsPath); + expect(programCsExists).toBe(false); + }); + + it('should not create Program.cs for NetFx custom code project', async () => { + const options: IWebviewProjectContext = { + workspaceProjectPath: { fsPath: tempDir } as vscode.Uri, + workspaceName: 'TestWorkspace', + logicAppName: 'TestLogicApp', + logicAppType: ProjectType.customCode, + workflowName: 'MyWorkflow', + workflowType: 'Stateful', + functionFolderName: 'Functions', + functionName: 'MyFunction', + functionNamespace: 'MyNamespace', + targetFramework: 'net472', + } as any; + + const functionAppFiles = createTestFunctionAppFiles(); + vi.mocked(CreateFunctionAppFiles).mockImplementation( + () => + ({ + setup: (ctx: IProjectWizardContext) => functionAppFiles.setup(ctx), + hideStepCount: true, + }) as any + ); + + await createLogicAppProject(mockContext, options, workspaceRootFolder); + + // Verify Program.cs was NOT created for NetFx + const functionsFolderPath = path.join(workspaceRootFolder, 'Functions'); + const programCsPath = path.join(functionsFolderPath, 'Program.cs'); + const programCsExists = await fse.pathExists(programCsPath); + expect(programCsExists).toBe(false); + }); }); describe('Rules Engine Project Integration', () => { diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppVSCodeContents.test.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppVSCodeContents.test.ts index ce4df0a9104..f498ceaa352 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppVSCodeContents.test.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppVSCodeContents.test.ts @@ -31,6 +31,13 @@ describe('CreateLogicAppVSCodeContents', () => { isDevContainerProject: false, } as any; + const mockContextCustomCodeNet10: IWebviewProjectContext = { + logicAppName: 'TestLogicAppCustomCodeNet10', + logicAppType: ProjectType.customCode, + targetFramework: TargetFramework.Net10, + isDevContainerProject: false, + } as any; + const mockContextCustomCodeNetFx: IWebviewProjectContext = { logicAppName: 'TestLogicAppCustomCodeNetFx', logicAppType: ProjectType.customCode, @@ -118,6 +125,22 @@ describe('CreateLogicAppVSCodeContents', () => { expect(Object.keys(settingsData)).toHaveLength(4); }); + it('should create settings.json without deploySubpath for net10 custom code project', async () => { + await CreateLogicAppVSCodeContentsModule.createLogicAppVsCodeContents(mockContextCustomCodeNet10, logicAppFolderPath); + + const settingsJsonPath = path.join(logicAppFolderPath, '.vscode', 'settings.json'); + const settingsCall = vi.mocked(fsUtils.confirmEditJsonFile).mock.calls.find((call) => call[1] === settingsJsonPath); + const settingsCallback = settingsCall[2]; + const settingsData = settingsCallback({}); + + expect(settingsData).toHaveProperty('azureFunctions.suppressProject', true); + expect(settingsData).toHaveProperty('azureLogicAppsStandard.projectLanguage', 'JavaScript'); + expect(settingsData).toHaveProperty('azureLogicAppsStandard.projectRuntime', '~4'); + expect(settingsData).toHaveProperty('debug.internalConsoleOptions', 'neverOpen'); + expect(settingsData).not.toHaveProperty('azureLogicAppsStandard.deploySubpath'); + expect(Object.keys(settingsData)).toHaveLength(4); + }); + it('should create settings.json without deploySubpath for netfx custom code project', async () => { await CreateLogicAppVSCodeContentsModule.createLogicAppVsCodeContents(mockContextCustomCodeNetFx, logicAppFolderPath); @@ -199,6 +222,25 @@ describe('CreateLogicAppVSCodeContents', () => { }); }); + it('should create launch.json with logicapp configuration for .NET 10 custom code projects', async () => { + await CreateLogicAppVSCodeContentsModule.createLogicAppVsCodeContents(mockContextCustomCodeNet10, logicAppFolderPath); + + const launchJsonPath = path.join(logicAppFolderPath, '.vscode', 'launch.json'); + const launchCall = vi.mocked(fsUtils.confirmEditJsonFile).mock.calls.find((call) => call[1] === launchJsonPath); + const launchCallback = launchCall[2]; + const launchData = launchCallback({ configurations: [] }); + + const config = launchData.configurations[0]; + expect(config).toMatchObject({ + name: expect.stringContaining('Run/Debug logic app with local function TestLogicAppCustomCodeNet10'), + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + customCodeRuntime: 'coreclr', + isCodeless: true, + }); + }); + it('should create launch.json with clr runtime for NetFx rules engine projects', async () => { await CreateLogicAppVSCodeContentsModule.createLogicAppVsCodeContents(mockContextRulesEngine, logicAppFolderPath); @@ -314,6 +356,18 @@ describe('CreateLogicAppVSCodeContents', () => { }); }); + it('should return logicapp configuration with coreclr for Net10 custom code', () => { + const config = CreateLogicAppVSCodeContentsModule.getDebugConfiguration('TestLogicApp', TargetFramework.Net10); + + expect(config).toMatchObject({ + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + customCodeRuntime: 'coreclr', + isCodeless: true, + }); + }); + it('should return logicapp configuration with clr for NetFx custom code', () => { const config = CreateLogicAppVSCodeContentsModule.getDebugConfiguration('TestLogicApp', TargetFramework.NetFx); diff --git a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/__test__/functionAppFilesStep.test.ts b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/__test__/functionAppFilesStep.test.ts new file mode 100644 index 00000000000..39bad5dbff0 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/__test__/functionAppFilesStep.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TargetFramework, ProjectType } from '@microsoft/vscode-extension-logic-apps'; + +vi.mock('fs-extra', () => ({ + writeFile: vi.fn(() => Promise.resolve()), + ensureDir: vi.fn(() => Promise.resolve()), + readFile: vi.fn(() => Promise.resolve('')), + pathExists: vi.fn(() => Promise.resolve(false)), + existsSync: vi.fn(() => false), + readdir: vi.fn(), + stat: vi.fn(), + writeJson: vi.fn(() => Promise.resolve()), + copyFile: vi.fn(() => Promise.resolve()), + readJson: vi.fn(() => Promise.resolve({})), +})); +vi.mock('vscode'); +vi.mock('../../../../../constants', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + }; +}); +vi.mock('../../../../../extensionVariables', () => ({ + ext: { outputChannel: { appendLog: vi.fn() } }, +})); +vi.mock('../../../../../localize', () => ({ + localize: (_key: string, msg: string) => msg, +})); +vi.mock('../../../../utils/vsCodeConfig/launch', () => ({ + getDebugConfigs: vi.fn().mockReturnValue([]), + updateDebugConfigs: vi.fn(), +})); +vi.mock('../../../../utils/workspace', () => ({ + getContainingWorkspace: vi.fn(), + isMultiRootWorkspace: vi.fn().mockReturnValue(false), +})); +vi.mock('../../../../utils/funcCoreTools/funcVersion', () => ({ + tryGetLocalFuncVersion: vi.fn().mockResolvedValue('~4'), +})); +vi.mock('../../../../utils/debug', () => ({ + getCustomCodeRuntime: vi.fn((tf: string) => (tf === 'net472' ? 'clr' : 'coreclr')), + getDebugConfiguration: vi.fn().mockReturnValue({}), + usesPublishFolderProperty: vi.fn((pt: string, tf: string) => pt === 'customCode' && tf !== 'net472'), +})); + +import { FunctionAppFilesStep } from '../functionAppFilesStep'; +import { csTemplateFileNames, csprojTemplateFileNames } from '../../../../utils/functionProjectFiles'; +import * as fs from 'fs-extra'; +import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; + +describe('FunctionAppFilesStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('template name mappings', () => { + it('should map Net10 to FunctionsFileNet10 in csTemplateFileNames', () => { + expect(csTemplateFileNames[TargetFramework.Net10]).toBe('FunctionsFileNet10'); + }); + + it('should preserve Net8 mapping in csTemplateFileNames', () => { + expect(csTemplateFileNames[TargetFramework.Net8]).toBe('FunctionsFileNet8'); + }); + + it('should preserve NetFx mapping in csTemplateFileNames', () => { + expect(csTemplateFileNames[TargetFramework.NetFx]).toBe('FunctionsFileNetFx'); + }); + + it('should preserve rulesEngine mapping in csTemplateFileNames', () => { + expect(csTemplateFileNames[ProjectType.rulesEngine]).toBe('RulesFunctionsFile'); + }); + + it('should map Net10 to FunctionsProjNet10 in csprojTemplateFileNames', () => { + expect(csprojTemplateFileNames[TargetFramework.Net10]).toBe('FunctionsProjNet10'); + }); + + it('should preserve Net8 mapping in csprojTemplateFileNames', () => { + expect(csprojTemplateFileNames[TargetFramework.Net8]).toBe('FunctionsProjNet8'); + }); + + it('should preserve NetFx mapping in csprojTemplateFileNames', () => { + expect(csprojTemplateFileNames[TargetFramework.NetFx]).toBe('FunctionsProjNetFx'); + }); + + it('should preserve rulesEngine mapping in csprojTemplateFileNames', () => { + expect(csprojTemplateFileNames[ProjectType.rulesEngine]).toBe('RulesFunctionsProj'); + }); + }); + + describe('shouldPrompt', () => { + it('should always return true', () => { + const step = new FunctionAppFilesStep(); + expect(step.shouldPrompt()).toBe(true); + }); + }); + + describe('Program.cs generation via prompt', () => { + function createMockContext(overrides: Partial = {}): IProjectWizardContext { + return { + functionAppName: 'TestFunction', + functionAppNamespace: 'TestNamespace', + targetFramework: TargetFramework.Net10, + logicAppName: 'TestLogicApp', + version: '~4', + workspacePath: '/mock/workspace', + projectType: ProjectType.customCode, + shouldCreateLogicAppProject: true, + ...overrides, + } as IProjectWizardContext; + } + + beforeEach(() => { + vi.mocked(fs.ensureDir).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue('template with <%= namespace %> placeholder'); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.writeJson).mockResolvedValue(undefined); + vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.pathExists).mockResolvedValue(false); + }); + + it('should create Program.cs for Net10 custom code project', async () => { + const step = new FunctionAppFilesStep(); + const context = createMockContext({ + targetFramework: TargetFramework.Net10, + projectType: ProjectType.customCode, + }); + + await step.prompt(context); + + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const programCsCall = writeFileCalls.find((call) => String(call[0]).endsWith('Program.cs')); + expect(programCsCall).toBeDefined(); + expect(String(programCsCall![1])).not.toContain('<%= namespace %>'); + }); + + it('should replace namespace placeholder in Program.cs', async () => { + vi.mocked(fs.readFile).mockResolvedValue('namespace <%= namespace %>\n{\n class Program {}\n}'); + const step = new FunctionAppFilesStep(); + const context = createMockContext({ + targetFramework: TargetFramework.Net10, + projectType: ProjectType.customCode, + functionAppNamespace: 'MyCompany.Functions', + }); + + await step.prompt(context); + + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const programCsCall = writeFileCalls.find((call) => String(call[0]).endsWith('Program.cs')); + expect(programCsCall).toBeDefined(); + expect(String(programCsCall![1])).toContain('namespace MyCompany.Functions'); + expect(String(programCsCall![1])).not.toContain('<%= namespace %>'); + }); + + it('should not create Program.cs for Net8 custom code project', async () => { + const step = new FunctionAppFilesStep(); + const context = createMockContext({ + targetFramework: TargetFramework.Net8, + projectType: ProjectType.customCode, + }); + + await step.prompt(context); + + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const programCsCall = writeFileCalls.find((call) => String(call[0]).endsWith('Program.cs')); + expect(programCsCall).toBeUndefined(); + }); + + it('should not create Program.cs for NetFx custom code project', async () => { + const step = new FunctionAppFilesStep(); + const context = createMockContext({ + targetFramework: TargetFramework.NetFx, + projectType: ProjectType.customCode, + }); + + await step.prompt(context); + + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const programCsCall = writeFileCalls.find((call) => String(call[0]).endsWith('Program.cs')); + expect(programCsCall).toBeUndefined(); + }); + + it('should not create Program.cs for rulesEngine project even with Net10', async () => { + const step = new FunctionAppFilesStep(); + const context = createMockContext({ + targetFramework: TargetFramework.Net10, + projectType: ProjectType.rulesEngine, + }); + + await step.prompt(context); + + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const programCsCall = writeFileCalls.find((call) => String(call[0]).endsWith('Program.cs')); + expect(programCsCall).toBeUndefined(); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/__test__/targetFrameworkStep.test.ts b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/__test__/targetFrameworkStep.test.ts new file mode 100644 index 00000000000..b79a1f184d6 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/__test__/targetFrameworkStep.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ProjectType, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; + +vi.mock('../../../../../localize', () => ({ + localize: (_key: string, msg: string) => msg, +})); + +import { TargetFrameworkStep } from '../targetFrameworkStep'; + +describe('TargetFrameworkStep', () => { + describe('shouldPrompt', () => { + it('should return true for customCode projects', () => { + const step = new TargetFrameworkStep(); + const context = { projectType: ProjectType.customCode } as any; + expect(step.shouldPrompt(context)).toBe(true); + }); + + it('should return false for logicApp projects', () => { + const step = new TargetFrameworkStep(); + const context = { projectType: ProjectType.logicApp } as any; + expect(step.shouldPrompt(context)).toBe(false); + }); + + it('should return false for rulesEngine projects', () => { + const step = new TargetFrameworkStep(); + const context = { projectType: ProjectType.rulesEngine } as any; + expect(step.shouldPrompt(context)).toBe(false); + }); + }); + + describe('prompt', () => { + it('should offer .NET 8 and .NET 10 picks on non-Windows platforms', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + const step = new TargetFrameworkStep(); + let capturedPicks: any[] = []; + const context = { + projectType: ProjectType.customCode, + ui: { + showQuickPick: vi.fn((picks: any[]) => { + capturedPicks = picks; + return Promise.resolve(picks[0]); + }), + }, + } as any; + + await step.prompt(context); + + expect(capturedPicks).toHaveLength(2); + expect(capturedPicks[0].data).toBe(TargetFramework.Net8); + expect(capturedPicks[1].data).toBe(TargetFramework.Net10); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should include .NET Framework on Windows', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const step = new TargetFrameworkStep(); + let capturedPicks: any[] = []; + const context = { + projectType: ProjectType.customCode, + ui: { + showQuickPick: vi.fn((picks: any[]) => { + capturedPicks = picks; + return Promise.resolve(picks[0]); + }), + }, + } as any; + + await step.prompt(context); + + expect(capturedPicks).toHaveLength(3); + expect(capturedPicks[0].data).toBe(TargetFramework.NetFx); + expect(capturedPicks[1].data).toBe(TargetFramework.Net8); + expect(capturedPicks[2].data).toBe(TargetFramework.Net10); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should set context.targetFramework to the selected value', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + const step = new TargetFrameworkStep(); + const context = { + projectType: ProjectType.customCode, + ui: { + showQuickPick: vi.fn((picks: any[]) => { + // Simulate selecting .NET 10 + const net10Pick = picks.find((p: any) => p.data === TargetFramework.Net10); + return Promise.resolve(net10Pick); + }), + }, + } as any; + + await step.prompt(context); + expect(context.targetFramework).toBe(TargetFramework.Net10); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts index f1a0fdb1159..10c613498ce 100644 --- a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts +++ b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts @@ -21,7 +21,8 @@ import * as path from 'path'; import { getDebugConfigs, updateDebugConfigs } from '../../../utils/vsCodeConfig/launch'; import { getContainingWorkspace, isMultiRootWorkspace } from '../../../utils/workspace'; import { tryGetLocalFuncVersion } from '../../../utils/funcCoreTools/funcVersion'; -import { getDebugConfiguration } from '../../../utils/debug'; +import { getCustomCodeRuntime, getDebugConfiguration } from '../../../utils/debug'; +import { createCsFile, createProgramFile, createRulesFiles, createCsprojFile } from '../../../utils/functionProjectFiles'; /** * This class represents a prompt step that allows the user to set up an Azure Function project. @@ -29,23 +30,6 @@ import { getDebugConfiguration } from '../../../utils/debug'; export class FunctionAppFilesStep extends AzureWizardPromptStep { public hideStepCount = true; - private csTemplateFileName = { - [TargetFramework.NetFx]: 'FunctionsFileNetFx', - [TargetFramework.Net8]: 'FunctionsFileNet8', - [ProjectType.rulesEngine]: 'RulesFunctionsFile', - }; - - private csprojTemplateFileName = { - [TargetFramework.NetFx]: 'FunctionsProjNetFx', - [TargetFramework.Net8]: 'FunctionsProjNet8New', - [ProjectType.rulesEngine]: 'RulesFunctionsProj', - }; - - private templateFolderName = { - [ProjectType.customCode]: 'FunctionProjectTemplate', - [ProjectType.rulesEngine]: 'RuleSetProjectTemplate', - }; - /** * Determines whether the prompt should be displayed. * @returns {boolean} True if the prompt should be displayed, false otherwise. @@ -59,109 +43,26 @@ export class FunctionAppFilesStep extends AzureWizardPromptStep { - // Set the functionAppName and namespaceName properties from the context wizard - const functionAppName = context.functionAppName; - const namespace = context.functionAppNamespace; - const targetFramework = context.targetFramework; + const { functionAppName, functionAppNamespace: namespace, targetFramework, projectType } = context; const logicAppName = context.logicAppName || 'LogicApp'; const funcVersion = context.version ?? (await tryGetLocalFuncVersion()); - - // Define the functions folder path using the context property of the wizard const functionFolderPath = path.join(context.workspacePath, context.functionAppName); - await fs.ensureDir(functionFolderPath); - - // Define the type of project in the workspace - const projectType = context.projectType; + const assetsPath = path.join(__dirname, assetsFolderName); - // Create the .cs file inside the functions folder - await this.createCsFile(functionFolderPath, functionAppName, namespace, projectType, targetFramework); + await fs.ensureDir(functionFolderPath); + await createCsFile(assetsPath, functionFolderPath, functionAppName, namespace, projectType, targetFramework); + await createProgramFile(assetsPath, functionFolderPath, namespace, projectType, targetFramework); - // Create the .cs files inside the functions folders for rule code projects if (projectType === ProjectType.rulesEngine) { - await this.createRulesFiles(functionFolderPath); + await createRulesFiles(assetsPath, functionFolderPath); } - // Create the .csproj file inside the functions folder - await this.createCsprojFile(functionFolderPath, functionAppName, logicAppName, projectType, targetFramework); + await createCsprojFile(assetsPath, functionFolderPath, functionAppName, logicAppName, projectType, targetFramework); - // Generate the Visual Studio Code configuration files in the specified folder. const isNewLogicAppProject = context.shouldCreateLogicAppProject; await this.createVscodeConfigFiles(functionFolderPath, targetFramework, funcVersion, logicAppName, isNewLogicAppProject); } - /** - * Creates the .cs file inside the functions folder. - * @param functionFolderPath - The path to the functions folder. - * @param methodName - The name of the method. - * @param namespace - The name of the namespace. - * @param projectType - The workspace projet type. - * @param targetFramework - The target framework. - */ - private async createCsFile( - functionFolderPath: string, - methodName: string, - namespace: string, - projectType: ProjectType, - targetFramework: TargetFramework - ): Promise { - const templateFile = - projectType === ProjectType.rulesEngine ? this.csTemplateFileName[ProjectType.rulesEngine] : this.csTemplateFileName[targetFramework]; - const templatePath = path.join(__dirname, assetsFolderName, this.templateFolderName[projectType], templateFile); - const templateContent = await fs.readFile(templatePath, 'utf-8'); - - const csFilePath = path.join(functionFolderPath, `${methodName}.cs`); - const csFileContent = templateContent.replace(/<%= methodName %>/g, methodName).replace(/<%= namespace %>/g, namespace); - await fs.writeFile(csFilePath, csFileContent); - } - - /** - * Creates the rules files for the project. - * @param {string} functionFolderPath - The path of the function folder. - * @returns A promise that resolves when the rules files are created. - */ - private async createRulesFiles(functionFolderPath: string): Promise { - const csTemplatePath = path.join(__dirname, assetsFolderName, 'RuleSetProjectTemplate', 'ContosoPurchase'); - const csRuleSetPath = path.join(functionFolderPath, 'ContosoPurchase.cs'); - await fs.copyFile(csTemplatePath, csRuleSetPath); - } - - /** - * Creates a .csproj file for a specific Azure Function. - * @param functionFolderPath - The path to the folder where the .csproj file will be created. - * @param methodName - The name of the Azure Function. - * @param projectType - The workspace projet type. - * @param targetFramework - The target framework. - */ - private async createCsprojFile( - functionFolderPath: string, - methodName: string, - logicAppName: string, - projectType: ProjectType, - targetFramework: TargetFramework - ): Promise { - const templateFile = - projectType === ProjectType.rulesEngine - ? this.csprojTemplateFileName[ProjectType.rulesEngine] - : this.csprojTemplateFileName[targetFramework]; - const templatePath = path.join(__dirname, assetsFolderName, this.templateFolderName[projectType], templateFile); - const templateContent = await fs.readFile(templatePath, 'utf-8'); - - const csprojFilePath = path.join(functionFolderPath, `${methodName}.csproj`); - let csprojFileContent: string; - if (targetFramework === TargetFramework.Net8 && projectType === ProjectType.customCode) { - csprojFileContent = templateContent.replace( - /\$\(MSBuildProjectDirectory\)\\..\\LogicApp<\/LogicAppFolderToPublish>/g, - `$(MSBuildProjectDirectory)\\..\\${logicAppName}` - ); - } else { - csprojFileContent = templateContent.replace( - /LogicApp<\/LogicAppFolder>/g, - `${logicAppName}` - ); - } - await fs.writeFile(csprojFilePath, csprojFileContent); - } - /** * Creates the Visual Studio Code configuration files in the .vscode folder of the specified functions app. * @param functionFolderPath The path to the functions folder. @@ -227,7 +128,7 @@ export class FunctionAppFilesStep extends AzureWizardPromptStep { const placeHolder: string = localize('selectTargetFramework', 'Select a target framework.'); - const picks: IAzureQuickPickItem[] = [{ label: localize('Net8', '.NET 8'), data: TargetFramework.Net8 }]; + const picks: IAzureQuickPickItem[] = [ + { label: localize('Net8', '.NET 8'), data: TargetFramework.Net8 }, + { label: localize('Net10', '.NET 10'), data: TargetFramework.Net10 }, + ]; if (process.platform === Platform.windows) { picks.unshift({ label: localize('NetFx', '.NET Framework'), data: TargetFramework.NetFx }); } diff --git a/apps/vs-code-designer/src/app/commands/createWorkflow/createCodefulWorkflow/createCodefulWorkflowSteps/codefulWorkflowCreateStep.ts b/apps/vs-code-designer/src/app/commands/createWorkflow/createCodefulWorkflow/createCodefulWorkflowSteps/codefulWorkflowCreateStep.ts index e1cb5c184e6..e674f710846 100644 --- a/apps/vs-code-designer/src/app/commands/createWorkflow/createCodefulWorkflow/createCodefulWorkflowSteps/codefulWorkflowCreateStep.ts +++ b/apps/vs-code-designer/src/app/commands/createWorkflow/createCodefulWorkflow/createCodefulWorkflowSteps/codefulWorkflowCreateStep.ts @@ -169,7 +169,7 @@ export class CodefulWorkflowCreateStep extends WorkflowCreateStepBase { const target = vscode.Uri.file(context.projectPath); - await switchToDotnetProject(context, target, '8', true); + await switchToDotnetProject(context, target, '10', true); await this.updateHostJson(context, hostFileName); diff --git a/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts b/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts index 41166089d22..a89465f229f 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts @@ -56,7 +56,7 @@ export async function switchToDotnetProjectCommand(context: IProjectWizardContex export async function switchToDotnetProject( context: IProjectWizardContext, target: vscode.Uri, - localDotNetMajorVersion = '8', + localDotNetMajorVersion = '10', isCodeful = false ) { if (target === undefined || Object.keys(target).length === 0) { @@ -158,8 +158,16 @@ export async function switchToDotnetProject( await copyBundleProjectFiles(target); await updateBuildFile(context, target, dotnetVersion, isCodeful); - if (useBinaries) { + if (useBinaries && dotnetLocalVersion) { await createGlobalJsonFile(dotnetLocalVersion, target.fsPath); + } else if (useBinaries) { + ext.outputChannel.appendLog( + localize( + 'dotnetVersionNotFound', + 'Could not determine local .NET SDK version for major version {0}. Skipping global.json creation.', + localDotNetMajorVersion + ) + ); } const workspaceFolder: vscode.WorkspaceFolder | undefined = getContainingWorkspace(target.fsPath); diff --git a/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts b/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts index c6d2a1a8ead..bbc94b8b8ff 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts @@ -41,15 +41,18 @@ vi.mock('../../../extensionVariables', () => ({ describe('customCodeUtils', () => { let validNet8CsprojContent: string; + let validNet10CsprojContent: string; let validNetFxCsprojContent: string; let invalidCsprojContent: string; beforeAll(async () => { const realFs = await vi.importActual('fs-extra'); const assetsFolderPath = path.join(__dirname, '..', '..', '..', assetsFolderName); - const net8CsprojTemplatePath = path.join(assetsFolderPath, 'FunctionProjectTemplate', 'FunctionsProjNet8New'); + const net10CsprojTemplatePath = path.join(assetsFolderPath, 'FunctionProjectTemplate', 'FunctionsProjNet10'); + const net8CsprojTemplatePath = path.join(assetsFolderPath, 'FunctionProjectTemplate', 'FunctionsProjNet8'); const netFxCsprojTemplatePath = path.join(assetsFolderPath, 'FunctionProjectTemplate', 'FunctionsProjNetFx'); + validNet10CsprojContent = await realFs.readFile(net10CsprojTemplatePath, 'utf8'); validNet8CsprojContent = await realFs.readFile(net8CsprojTemplatePath, 'utf8'); validNetFxCsprojContent = await realFs.readFile(netFxCsprojTemplatePath, 'utf8'); invalidCsprojContent = ` @@ -156,6 +159,14 @@ describe('customCodeUtils', () => { expect(result).toBe(true); }); + it('should return true for a valid net10 csproj file', async () => { + vi.spyOn(fse, 'statSync').mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(fse, 'readdir').mockResolvedValue([testCsprojFile]); + vi.spyOn(fse, 'readFile').mockResolvedValue(validNet10CsprojContent); + const result = await isCustomCodeFunctionsProject(testFolderPath); + expect(result).toBe(true); + }); + it('should return true for a valid netfx csproj file', async () => { vi.spyOn(fse, 'statSync').mockReturnValue({ isDirectory: () => true } as any); vi.spyOn(fse, 'readdir').mockResolvedValue([testCsprojFile]); @@ -251,6 +262,25 @@ describe('customCodeUtils', () => { } as CustomCodeFunctionsProjectMetadata); }); + it('should return metadata for a valid net10 csproj file', async () => { + vi.spyOn(fse, 'readdir').mockResolvedValue([testCsFile, testCsprojFile]); + vi.spyOn(fse, 'readFile').mockImplementation(async (p: string) => { + if (p.endsWith('.csproj')) { + return validNet10CsprojContent; + } + return `namespace ${testNamespace} {}`; + }); + + const result = await getCustomCodeFunctionsProjectMetadata(testFolderPath); + expect(result).toEqual({ + projectPath: testFolderPath, + functionAppName: testFunctionName, + logicAppName: 'LogicApp', + targetFramework: TargetFramework.Net10, + namespace: testNamespace, + } as CustomCodeFunctionsProjectMetadata); + }); + it('should return metadata for a valid netfx csproj file', async () => { vi.spyOn(fse, 'readdir').mockResolvedValue([testCsFile, testCsprojFile]); vi.spyOn(fse, 'readFile').mockImplementation(async (p: string) => { diff --git a/apps/vs-code-designer/src/app/utils/__test__/debug.test.ts b/apps/vs-code-designer/src/app/utils/__test__/debug.test.ts index 4f4cef0d31f..9b83b0f0e37 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/debug.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/debug.test.ts @@ -1,9 +1,45 @@ -import { getDebugConfiguration } from '../debug'; +import { getCustomCodeRuntime, getDebugConfiguration, usesPublishFolderProperty } from '../debug'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { extensionCommand } from '../../../constants'; -import { FuncVersion, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; +import { FuncVersion, ProjectType, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; describe('debug', () => { + describe('getCustomCodeRuntime', () => { + it('should return coreclr for .NET 8', () => { + expect(getCustomCodeRuntime(TargetFramework.Net8)).toBe('coreclr'); + }); + + it('should return coreclr for .NET 10', () => { + expect(getCustomCodeRuntime(TargetFramework.Net10)).toBe('coreclr'); + }); + + it('should return clr for .NET Framework', () => { + expect(getCustomCodeRuntime(TargetFramework.NetFx)).toBe('clr'); + }); + }); + + describe('usesPublishFolderProperty', () => { + it('should return true for custom code with .NET 8', () => { + expect(usesPublishFolderProperty(ProjectType.customCode, TargetFramework.Net8)).toBe(true); + }); + + it('should return true for custom code with .NET 10', () => { + expect(usesPublishFolderProperty(ProjectType.customCode, TargetFramework.Net10)).toBe(true); + }); + + it('should return false for custom code with .NET Framework', () => { + expect(usesPublishFolderProperty(ProjectType.customCode, TargetFramework.NetFx)).toBe(false); + }); + + it('should return false for rules engine projects', () => { + expect(usesPublishFolderProperty(ProjectType.rulesEngine, TargetFramework.Net8)).toBe(false); + }); + + it('should return false for standard logic app projects', () => { + expect(usesPublishFolderProperty(ProjectType.logicApp, TargetFramework.Net8)).toBe(false); + }); + }); + describe('getDebugConfiguration', () => { beforeEach(() => { vi.clearAllMocks(); @@ -22,6 +58,19 @@ describe('debug', () => { }); }); + it('should return launch configuration for .NET 10 custom code with v4 function runtime', () => { + const result = getDebugConfiguration(FuncVersion.v4, 'TestLogicApp', TargetFramework.Net10); + + expect(result).toEqual({ + name: 'Run/Debug logic app with local function TestLogicApp', + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + customCodeRuntime: 'coreclr', + isCodeless: true, + }); + }); + it('should return launch configuration for .NET Framework custom code with v1 function runtime', () => { const result = getDebugConfiguration(FuncVersion.v1, 'TestLogicApp', TargetFramework.NetFx); diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/updateBuildFile.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/updateBuildFile.test.ts index a8d8e2b4fb2..c0b61e13e1d 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/updateBuildFile.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/updateBuildFile.test.ts @@ -121,6 +121,54 @@ describe('utils/codeless/updateBuildFile', () => { Version: '3.0.13', }); }); + + it('Should update the package version to 4.5.0 for .NET 8', () => { + const xmlBuildFile = { + Project: { + ItemGroup: [ + { + PackageReference: { + $: { + Include: 'Microsoft.NET.Sdk.Functions', + Version: '3.0.13', + }, + }, + }, + ], + }, + }; + + const updatedXmlBuildFile = updateFunctionsSDKVersion(xmlBuildFile, DotnetVersion.net8); + + expect(updatedXmlBuildFile.Project.ItemGroup[0].PackageReference.$).toMatchObject({ + Include: 'Microsoft.NET.Sdk.Functions', + Version: '4.5.0', + }); + }); + + it('Should update the package version to 4.5.0 for .NET 10', () => { + const xmlBuildFile = { + Project: { + ItemGroup: [ + { + PackageReference: { + $: { + Include: 'Microsoft.NET.Sdk.Functions', + Version: '3.0.13', + }, + }, + }, + ], + }, + }; + + const updatedXmlBuildFile = updateFunctionsSDKVersion(xmlBuildFile, DotnetVersion.net10); + + expect(updatedXmlBuildFile.Project.ItemGroup[0].PackageReference.$).toMatchObject({ + Include: 'Microsoft.NET.Sdk.Functions', + Version: '4.5.0', + }); + }); }); describe('addFolderToBuildPath', () => { diff --git a/apps/vs-code-designer/src/app/utils/codeless/updateBuildFile.ts b/apps/vs-code-designer/src/app/utils/codeless/updateBuildFile.ts index e02a1de5420..4ae9db78ba9 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/updateBuildFile.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/updateBuildFile.ts @@ -140,6 +140,7 @@ export function updateFunctionsSDKVersion(xmlBuildFile: Record, dot case DotnetVersion.net6: packageVersion = '4.1.3'; break; + case DotnetVersion.net10: case DotnetVersion.net8: packageVersion = '4.5.0'; break; diff --git a/apps/vs-code-designer/src/app/utils/customCodeUtils.ts b/apps/vs-code-designer/src/app/utils/customCodeUtils.ts index 215e6ad8637..a13d25ed15d 100644 --- a/apps/vs-code-designer/src/app/utils/customCodeUtils.ts +++ b/apps/vs-code-designer/src/app/utils/customCodeUtils.ts @@ -85,7 +85,7 @@ export async function isCustomCodeFunctionsProject(folderPath: string): Promise< } const csprojContent = await fse.readFile(path.join(folderPath, csprojFile), 'utf-8'); - return isCustomCodeNet8Csproj(csprojContent) || isCustomCodeNetFxCsproj(csprojContent); + return !isNullOrUndefined(getCustomCodeTargetFramework(csprojContent)); } /** @@ -119,49 +119,82 @@ export async function getCustomCodeFunctionsProjectMetadata(folderPath: string): } const csprojContentStr = await fse.readFile(path.join(folderPath, csprojFile), 'utf-8'); - return new Promise((resolve, _) => { + return new Promise((resolve) => { parseString(csprojContentStr, (err, result) => { if (err) { ext.outputChannel.appendLog(`Error parsing csproj file: ${err}`); resolve(undefined); + return; } - if (isCustomCodeNet8Csproj(csprojContentStr)) { + const targetFramework = getCustomCodeTargetFramework(csprojContentStr); + const functionAppName = path.basename(path.normalize(folderPath)); + if (targetFramework && usesLogicAppFolderToPublish(targetFramework)) { resolve({ projectPath: folderPath, - functionAppName: path.basename(path.normalize(folderPath)), + functionAppName, logicAppName: path.win32.basename(path.win32.normalize(result.Project.PropertyGroup[0].LogicAppFolderToPublish[0])), - targetFramework: TargetFramework.Net8, - namespace: namespace, + targetFramework, + namespace, }); + return; } - if (isCustomCodeNetFxCsproj(csprojContentStr)) { + if (targetFramework === TargetFramework.NetFx) { resolve({ projectPath: folderPath, - functionAppName: path.basename(path.normalize(folderPath)), + functionAppName, logicAppName: path.win32.basename(path.win32.normalize(result.Project.PropertyGroup[0].LogicAppFolder[0])), - targetFramework: TargetFramework.NetFx, - namespace: namespace, + targetFramework, + namespace, }); + return; } ext.outputChannel.appendLog( - `The csproj file in ${folderPath} does not match the expected format for a .Net 8 or .Net Framework custom code functions project.` + `The csproj file in ${folderPath} does not match the expected format for a .NET 8, .NET 10, or .NET Framework custom code functions project.` ); resolve(undefined); }); }); } -function isCustomCodeNet8Csproj(csprojContent: string): boolean { +function getCustomCodeTargetFramework(csprojContent: string): TargetFramework | undefined { + if (isCustomCodeNet10Csproj(csprojContent)) { + return TargetFramework.Net10; + } + + if (isCustomCodeNet8Csproj(csprojContent)) { + return TargetFramework.Net8; + } + + if (isCustomCodeNetFxCsproj(csprojContent)) { + return TargetFramework.NetFx; + } + + return undefined; +} + +function usesLogicAppFolderToPublish(targetFramework: TargetFramework): boolean { + return targetFramework !== TargetFramework.NetFx; +} + +function isCustomCodeNetCoreCsproj(csprojContent: string, targetFramework: TargetFramework.Net8 | TargetFramework.Net10): boolean { return ( - csprojContent.includes('net8') && + csprojContent.includes(`${targetFramework}`) && csprojContent.includes('Microsoft.Azure.Workflows.Webjobs.Sdk') && csprojContent.includes('') ); } +function isCustomCodeNet10Csproj(csprojContent: string): boolean { + return isCustomCodeNetCoreCsproj(csprojContent, TargetFramework.Net10); +} + +function isCustomCodeNet8Csproj(csprojContent: string): boolean { + return isCustomCodeNetCoreCsproj(csprojContent, TargetFramework.Net8); +} + function isCustomCodeNetFxCsproj(csprojContent: string): boolean { return ( csprojContent.includes('net472') && @@ -272,10 +305,11 @@ async function isCustomCodeFunctionsProjectForLogicApp(folderPath: string, logic const csprojFile = (await fse.readdir(folderPath)).find((file) => file.endsWith('.csproj')); const csprojContent = await fse.readFile(path.join(folderPath, csprojFile), 'utf-8'); - if (isCustomCodeNet8Csproj(csprojContent)) { + const targetFramework = getCustomCodeTargetFramework(csprojContent); + if (targetFramework && usesLogicAppFolderToPublish(targetFramework)) { return csprojContent.includes(`$(MSBuildProjectDirectory)\\..\\${logicAppName}`); } - if (isCustomCodeNetFxCsproj(csprojContent)) { + if (targetFramework === TargetFramework.NetFx) { return csprojContent.includes(`${logicAppName}`); } diff --git a/apps/vs-code-designer/src/app/utils/debug.ts b/apps/vs-code-designer/src/app/utils/debug.ts index e77826cce74..b6bd6ee9784 100644 --- a/apps/vs-code-designer/src/app/utils/debug.ts +++ b/apps/vs-code-designer/src/app/utils/debug.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { FuncVersion, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; +import { FuncVersion, ProjectType, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; import type { DebugConfiguration } from 'vscode'; import { debugSymbolDll, extensionBundleId, extensionCommand } from '../../constants'; @@ -17,6 +17,19 @@ export async function getDebugSymbolDll(): Promise { return path.join(bundleFolder, bundleVersionNumber, 'bin', debugSymbolDll); } +export function getCustomCodeRuntime(targetFramework: TargetFramework): 'coreclr' | 'clr' { + return targetFramework === TargetFramework.NetFx ? 'clr' : 'coreclr'; +} + +/** + * Determines whether the given project type and target framework use the modern + * LogicAppFolderToPublish csproj property (as opposed to the legacy LogicAppFolder). + * Modern .NET frameworks (Net8, Net10, etc.) use LogicAppFolderToPublish for custom code projects. + */ +export function usesPublishFolderProperty(projectType: ProjectType, targetFramework: TargetFramework): boolean { + return projectType === ProjectType.customCode && targetFramework !== TargetFramework.NetFx; +} + /** * Generates a debug configuration for a Logic App based on the function version and optional custom code framework. * @param version - The Azure Functions runtime version (v1, v2, v3, or v4) @@ -38,7 +51,7 @@ export const getDebugConfiguration = ( type: 'logicapp', request: 'launch', funcRuntime: version === FuncVersion.v1 ? 'clr' : 'coreclr', - customCodeRuntime: customCodeTargetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', + customCodeRuntime: getCustomCodeRuntime(customCodeTargetFramework), isCodeless: true, }; } diff --git a/apps/vs-code-designer/src/app/utils/dotnet/__test__/dotnet.test.ts b/apps/vs-code-designer/src/app/utils/dotnet/__test__/dotnet.test.ts new file mode 100644 index 00000000000..88efdbcf1f7 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/dotnet/__test__/dotnet.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FuncVersion, ProjectLanguage } from '@microsoft/vscode-extension-logic-apps'; +import { DotnetVersion } from '../../../../constants'; + +const { mockPathExists, mockGetProjFiles } = vi.hoisted(() => ({ + mockPathExists: vi.fn(), + mockGetProjFiles: vi.fn(), +})); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + AzExtFsExtra: { + pathExists: mockPathExists, + readFile: vi.fn(), + }, +})); + +vi.mock('../../../extensionVariables', () => ({ + ext: { outputChannel: { appendLog: vi.fn() }, dotNetCliPath: 'dotnet' }, +})); +vi.mock('../../../localize', () => ({ + localize: (_key: string, msg: string) => msg, +})); +vi.mock('../../telemetry', () => ({ + runWithDurationTelemetry: (_ctx: any, _name: string, fn: () => any) => fn(), +})); +vi.mock('../../workspace', () => ({ + findFiles: vi.fn().mockResolvedValue([]), +})); +vi.mock('../../funcCoreTools/cpUtils', () => ({ + executeCommand: vi.fn(), +})); +vi.mock('../../vsCodeConfig/settings', () => ({ + getGlobalSetting: vi.fn(), + updateGlobalSetting: vi.fn(), + updateWorkspaceSetting: vi.fn(), +})); +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + readdirSync: vi.fn().mockReturnValue([]), + chmodSync: vi.fn(), +})); +vi.mock('semver', () => ({ + clean: vi.fn(), + maxSatisfying: vi.fn(), +})); + +import { getTemplateKeyFromProjFile } from '../dotnet'; + +describe('dotnet utilities', () => { + const createContext = () => + ({ + telemetry: { properties: {}, measurements: {} }, + }) as any; + + beforeEach(() => { + vi.clearAllMocks(); + mockPathExists.mockResolvedValue(false); + mockGetProjFiles.mockResolvedValue([]); + }); + + describe('getTemplateKeyFromProjFile', () => { + it('should default to net10.0 for FuncVersion.v4 when no project path exists', async () => { + const result = await getTemplateKeyFromProjFile(createContext(), undefined, FuncVersion.v4, ProjectLanguage.CSharp); + expect(result).toBe(DotnetVersion.net10); + }); + + it('should default to net10.0 for FuncVersion.v4 when project path does not exist', async () => { + mockPathExists.mockResolvedValue(false); + const result = await getTemplateKeyFromProjFile(createContext(), '/nonexistent', FuncVersion.v4, ProjectLanguage.CSharp); + expect(result).toBe(DotnetVersion.net10); + }); + + it('should default to netcoreapp3.1 for FuncVersion.v3', async () => { + const result = await getTemplateKeyFromProjFile(createContext(), undefined, FuncVersion.v3, ProjectLanguage.CSharp); + expect(result).toBe(DotnetVersion.net3); + }); + + it('should default to netcoreapp2.1 for FuncVersion.v2', async () => { + const result = await getTemplateKeyFromProjFile(createContext(), undefined, FuncVersion.v2, ProjectLanguage.CSharp); + expect(result).toBe(DotnetVersion.net2); + }); + + it('should default to net48 for FuncVersion.v1', async () => { + const result = await getTemplateKeyFromProjFile(createContext(), undefined, FuncVersion.v1, ProjectLanguage.CSharp); + expect(result).toBe(DotnetVersion.net48); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/dotnet/__test__/executeDotnetTemplateCommand.test.ts b/apps/vs-code-designer/src/app/utils/dotnet/__test__/executeDotnetTemplateCommand.test.ts index 5d41b96f782..3e05c2cc528 100644 --- a/apps/vs-code-designer/src/app/utils/dotnet/__test__/executeDotnetTemplateCommand.test.ts +++ b/apps/vs-code-designer/src/app/utils/dotnet/__test__/executeDotnetTemplateCommand.test.ts @@ -69,6 +69,7 @@ function createActionContext(): IActionContext { // Static import - uses the mocked modules defined above import { getFramework, + getJsonCliFramework, getDotnetTemplateDir, getDotnetItemTemplatePath, getDotnetProjectTemplatePath, @@ -95,7 +96,18 @@ describe('executeDotnetTemplateCommand', () => { // allowing each test to independently verify version detection logic. describe('getFramework', () => { - it('should pick .NET 8 when available (highest priority)', async () => { + it('should pick .NET 10 when available (highest priority)', async () => { + const ctx = createActionContext(); + + mockExecuteCommand + .mockResolvedValueOnce('10.0.100\n') // --version + .mockResolvedValueOnce('10.0.100 [/usr/share/dotnet/sdk]\n8.0.100 [/usr/share/dotnet/sdk]\n'); // --list-sdks + + const result = await getFramework(ctx, '/workspace', true); + expect(result).toBe('net10.0'); + }); + + it('should pick .NET 8 when .NET 10 is not available', async () => { const ctx = createActionContext(); mockExecuteCommand @@ -139,15 +151,15 @@ describe('executeDotnetTemplateCommand', () => { expect(result).toBe('netcoreapp2.0'); }); - it('should pick .NET 9 as lower priority than 8', async () => { + it('should pick .NET 8 ahead of .NET 9 when .NET 10 is unavailable', async () => { const ctx = createActionContext(); mockExecuteCommand - .mockResolvedValueOnce('9.0.100\n') // --version - .mockResolvedValueOnce('9.0.100 [/usr/share/dotnet/sdk]\n'); // --list-sdks + .mockResolvedValueOnce('8.0.100\n') // --version + .mockResolvedValueOnce('9.0.100 [/usr/share/dotnet/sdk]\n8.0.100 [/usr/share/dotnet/sdk]\n'); // --list-sdks const result = await getFramework(ctx, '/workspace', true); - expect(result).toBe('net9.0'); + expect(result).toBe('net8.0'); }); it('should prefer GA over preview versions', async () => { @@ -196,6 +208,20 @@ describe('executeDotnetTemplateCommand', () => { expect(result).toBe('net8.0'); }); + it('should use binaries with .NET 10 when useBinariesDependencies returns true', async () => { + const ctx = createActionContext(); + + mockUseBinariesDependencies.mockResolvedValue(true); + mockGetLocalDotNetVersionFromBinaries.mockResolvedValue('10.0.100\n'); + mockExecuteCommand + .mockResolvedValueOnce('') // --version + .mockResolvedValueOnce(''); // --list-sdks + + const result = await getFramework(ctx, '/workspace', true); + expect(mockGetLocalDotNetVersionFromBinaries).toHaveBeenCalled(); + expect(result).toBe('net10.0'); + }); + it('should not use binaries when useBinariesDependencies returns false', async () => { const ctx = createActionContext(); @@ -209,6 +235,47 @@ describe('executeDotnetTemplateCommand', () => { expect(result).toBe('net8.0'); }); + it('should detect .NET 10 when version sources lack trailing newlines', async () => { + const ctx = createActionContext(); + + // Simulate outputs without trailing newlines — prior to the delimiter fix, + // these would concatenate into "2.0.10010.0.100 [path]" hiding .NET 10 + mockUseBinariesDependencies.mockResolvedValue(true); + mockGetLocalDotNetVersionFromBinaries.mockResolvedValue('2.0.100'); + mockExecuteCommand + .mockResolvedValueOnce('') // --version + .mockResolvedValueOnce('10.0.100 [/usr/share/dotnet/sdk]'); // --list-sdks (no trailing newline) + + const result = await getFramework(ctx, '/workspace', true); + expect(result).toBe('net10.0'); + }); + + it('should detect correct version when all sources lack trailing newlines', async () => { + const ctx = createActionContext(); + + mockUseBinariesDependencies.mockResolvedValue(true); + mockGetLocalDotNetVersionFromBinaries.mockResolvedValue('6.0.400'); + mockExecuteCommand + .mockResolvedValueOnce('8.0.100') // --version (no newline) + .mockResolvedValueOnce('8.0.100 [/sdk]'); // --list-sdks (no newline) + + const result = await getFramework(ctx, '/workspace', true); + expect(result).toBe('net8.0'); + }); + + it('should not create false match from concatenated version strings', async () => { + const ctx = createActionContext(); + + // "8.0.100" + "6.0.400" without delimiter could form "8.0.1006.0.400" + // which should NOT accidentally match .NET 10 + mockExecuteCommand + .mockResolvedValueOnce('8.0.100') // --version (no newline) + .mockResolvedValueOnce('6.0.400 [/sdk]'); // --list-sdks + + const result = await getFramework(ctx, '/workspace', true); + expect(result).toBe('net8.0'); + }); + it('should handle executeCommand failures gracefully', async () => { const ctx = createActionContext(); @@ -237,6 +304,36 @@ describe('executeDotnetTemplateCommand', () => { }); }); + describe('getJsonCliFramework', () => { + it('should return net8.0 as-is', () => { + expect(getJsonCliFramework('net8.0')).toBe('net8.0'); + }); + + it('should return net6.0 as-is', () => { + expect(getJsonCliFramework('net6.0')).toBe('net6.0'); + }); + + it('should return netcoreapp3.0 as-is', () => { + expect(getJsonCliFramework('netcoreapp3.0')).toBe('netcoreapp3.0'); + }); + + it('should return netcoreapp2.0 as-is', () => { + expect(getJsonCliFramework('netcoreapp2.0')).toBe('netcoreapp2.0'); + }); + + it('should fall back to net8.0 for net10.0', () => { + expect(getJsonCliFramework('net10.0')).toBe('net8.0'); + }); + + it('should fall back to net8.0 for net9.0', () => { + expect(getJsonCliFramework('net9.0')).toBe('net8.0'); + }); + + it('should fall back to net8.0 for unknown frameworks', () => { + expect(getJsonCliFramework('net99.0')).toBe('net8.0'); + }); + }); + describe('getDotnetTemplateDir', () => { it('should return correct directory path', () => { const result = getDotnetTemplateDir('~4', 'myTemplateKey'); diff --git a/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts b/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts index e1fa528564b..719f6ec52d0 100644 --- a/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts +++ b/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts @@ -131,7 +131,7 @@ export async function getTemplateKeyFromProjFile( switch (version) { case FuncVersion.v4: { - targetFramework = DotnetVersion.net8; + targetFramework = DotnetVersion.net10; break; } case FuncVersion.v3: { diff --git a/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts b/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts index 8e164766724..9a45866bc87 100644 --- a/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts +++ b/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts @@ -35,11 +35,12 @@ export async function executeDotnetTemplateCommand( ...args: string[] ): Promise { const framework: string = await getFramework(context, workingDirectory); + const jsonCliFramework = getJsonCliFramework(framework); const jsonDllPath: string = ext.context.asAbsolutePath( - path.join(assetsFolderName, 'dotnetJsonCli', framework, 'Microsoft.TemplateEngine.JsonCli.dll') + path.join(assetsFolderName, 'dotnetJsonCli', jsonCliFramework, 'Microsoft.TemplateEngine.JsonCli.dll') ); - return await executeCommand( + return executeCommand( undefined, workingDirectory, getDotNetCommand(), @@ -52,6 +53,20 @@ export async function executeDotnetTemplateCommand( ); } +/** + * Maps a detected .NET framework version to the corresponding dotnetJsonCli asset folder. + * The JsonCli DLLs are framework-agnostic and forward-compatible, so newer frameworks + * (e.g. net10.0) can reuse the net8.0 binaries. + */ +export function getJsonCliFramework(framework: string): string { + const supportedJsonCliFrameworks = ['net8.0', 'net6.0', 'netcoreapp3.0', 'netcoreapp2.0']; + if (supportedJsonCliFrameworks.includes(framework)) { + return framework; + } + // Fall back to net8.0 for newer frameworks (e.g. net10.0) + return 'net8.0'; +} + export function getDotnetItemTemplatePath(version: FuncVersion, projTemplateKey: string): string { return path.join(getDotnetTemplateDir(version, projTemplateKey), 'item.nupkg'); } @@ -87,25 +102,20 @@ export async function validateDotnetInstalled(context: IActionContext): Promise< */ export async function getFramework(context: IActionContext, workingDirectory: string | undefined, isCodeful = false): Promise { if (!cachedFramework || isCodeful) { - let versions = ''; const dotnetBinariesLocation = getDotNetCommand(); + const versionSources: string[] = []; - versions = (await useBinariesDependencies()) ? await getLocalDotNetVersionFromBinaries() : versions; - - try { - versions += await executeCommand(undefined, workingDirectory, dotnetBinariesLocation, '--version'); - } catch { - // ignore + if (await useBinariesDependencies()) { + versionSources.push((await getLocalDotNetVersionFromBinaries()) ?? ''); } - try { - versions += await executeCommand(undefined, workingDirectory, dotnetBinariesLocation, '--list-sdks'); - } catch { - // ignore - } + versionSources.push(await tryGetDotnetVersionOutput(dotnetBinariesLocation, workingDirectory, '--version')); + versionSources.push(await tryGetDotnetVersionOutput(dotnetBinariesLocation, workingDirectory, '--list-sdks')); + + const versions = versionSources.join('\n'); // Prioritize "LTS", then "Current", then "Preview" - const netVersions: string[] = ['8', '6', '3', '2', '9', '10']; + const netVersions: string[] = ['10', '8', '6', '3', '2', '9']; const semVersions: SemVer[] = netVersions.map((v) => semVerCoerce(v) as SemVer); @@ -145,3 +155,15 @@ export async function getFramework(context: IActionContext, workingDirectory: st return cachedFramework; } + +async function tryGetDotnetVersionOutput( + dotnetCommand: string, + workingDirectory: string | undefined, + commandArgument: '--version' | '--list-sdks' +): Promise { + try { + return await executeCommand(undefined, workingDirectory, dotnetCommand, commandArgument); + } catch { + return ''; + } +} diff --git a/apps/vs-code-designer/src/app/utils/functionProjectFiles.ts b/apps/vs-code-designer/src/app/utils/functionProjectFiles.ts new file mode 100644 index 00000000000..d7ea0c29b1f --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/functionProjectFiles.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { TargetFramework, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { usesPublishFolderProperty } from './debug'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +/** + * Maps target framework / project type to .cs template file names. + */ +export const csTemplateFileNames: Record = { + [TargetFramework.NetFx]: 'FunctionsFileNetFx', + [TargetFramework.Net8]: 'FunctionsFileNet8', + [TargetFramework.Net10]: 'FunctionsFileNet10', + [ProjectType.rulesEngine]: 'RulesFunctionsFile', +}; + +/** + * Maps target framework / project type to .csproj template file names. + */ +export const csprojTemplateFileNames: Record = { + [TargetFramework.NetFx]: 'FunctionsProjNetFx', + [TargetFramework.Net8]: 'FunctionsProjNet8', + [TargetFramework.Net10]: 'FunctionsProjNet10', + [ProjectType.rulesEngine]: 'RulesFunctionsProj', +}; + +/** + * Maps project type to template folder names under the assets directory. + */ +export const templateFolderNames: Record = { + [ProjectType.customCode]: 'FunctionProjectTemplate', + [ProjectType.rulesEngine]: 'RuleSetProjectTemplate', +}; + +/** + * Resolves the correct template file name based on project type and target framework. + * Rules engine projects use their own templates; custom code projects use framework-specific ones. + */ +function resolveTemplateFileName(templateMap: Record, projectType: ProjectType, targetFramework: TargetFramework): string { + return projectType === ProjectType.rulesEngine ? templateMap[ProjectType.rulesEngine] : templateMap[targetFramework]; +} + +/** + * Creates the .cs file inside the functions folder from a template. + * @param assetsPath - Base path to the assets directory. + * @param functionFolderPath - The path to the functions folder. + * @param methodName - The name of the method. + * @param namespace - The name of the namespace. + * @param projectType - The workspace project type. + * @param targetFramework - The target framework. + */ +export async function createCsFile( + assetsPath: string, + functionFolderPath: string, + methodName: string, + namespace: string, + projectType: ProjectType, + targetFramework: TargetFramework +): Promise { + const templateFile = resolveTemplateFileName(csTemplateFileNames, projectType, targetFramework); + const templatePath = path.join(assetsPath, templateFolderNames[projectType], templateFile); + const templateContent = await fs.readFile(templatePath, 'utf-8'); + + const csFilePath = path.join(functionFolderPath, `${methodName}.cs`); + const csFileContent = templateContent.replace(/<%= methodName %>/g, methodName).replace(/<%= namespace %>/g, namespace); + await fs.writeFile(csFilePath, csFileContent); +} + +/** + * Creates the Program.cs file for .NET 10 isolated worker model. + * Only generates for .NET 10 custom code projects (not rules engine). + * @param assetsPath - Base path to the assets directory. + * @param functionFolderPath - The path to the functions folder. + * @param namespace - The name of the namespace. + * @param projectType - The workspace project type. + * @param targetFramework - The target framework. + */ +export async function createProgramFile( + assetsPath: string, + functionFolderPath: string, + namespace: string, + projectType: ProjectType, + targetFramework: TargetFramework +): Promise { + if (targetFramework !== TargetFramework.Net10 || projectType === ProjectType.rulesEngine) { + return; + } + + const templatePath = path.join(assetsPath, 'FunctionProjectTemplate', 'ProgramFileNet10'); + const templateContent = await fs.readFile(templatePath, 'utf-8'); + const content = templateContent.replace(/<%= namespace %>/g, namespace); + await fs.writeFile(path.join(functionFolderPath, 'Program.cs'), content); +} + +/** + * Creates the ContosoPurchase.cs rules file for rules engine projects. + * @param assetsPath - Base path to the assets directory. + * @param functionFolderPath - The path to the functions folder. + */ +export async function createRulesFiles(assetsPath: string, functionFolderPath: string): Promise { + const csTemplatePath = path.join(assetsPath, 'RuleSetProjectTemplate', 'ContosoPurchase'); + const csRuleSetPath = path.join(functionFolderPath, 'ContosoPurchase.cs'); + await fs.copyFile(csTemplatePath, csRuleSetPath); +} + +/** + * Creates a .csproj file for a function app from a template. + * @param assetsPath - Base path to the assets directory. + * @param functionFolderPath - The path to the functions folder. + * @param methodName - The name of the Azure Function. + * @param logicAppName - The name of the logic app. + * @param projectType - The workspace project type. + * @param targetFramework - The target framework. + */ +export async function createCsprojFile( + assetsPath: string, + functionFolderPath: string, + methodName: string, + logicAppName: string, + projectType: ProjectType, + targetFramework: TargetFramework +): Promise { + const templateFile = resolveTemplateFileName(csprojTemplateFileNames, projectType, targetFramework); + const templatePath = path.join(assetsPath, templateFolderNames[projectType], templateFile); + const templateContent = await fs.readFile(templatePath, 'utf-8'); + + const csprojFilePath = path.join(functionFolderPath, `${methodName}.csproj`); + const csprojFileContent = usesPublishFolderProperty(projectType, targetFramework) + ? templateContent.replace( + /\$\(MSBuildProjectDirectory\)\\..\\LogicApp<\/LogicAppFolderToPublish>/g, + `$(MSBuildProjectDirectory)\\..\\${logicAppName}` + ) + : templateContent.replace(/LogicApp<\/LogicAppFolder>/g, `${logicAppName}`); + await fs.writeFile(csprojFilePath, csprojFileContent); +} diff --git a/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsFileNet10 b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsFileNet10 new file mode 100644 index 00000000000..34030996929 --- /dev/null +++ b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsFileNet10 @@ -0,0 +1,80 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace <%= namespace %> +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Extensions.Workflows; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Logging; + + /// + /// Represents the <%= methodName %> flow invoked function. + /// + public class <%= methodName %> + { + private readonly ILogger<<%= methodName %>> logger; + + public <%= methodName %>(ILoggerFactory loggerFactory) + { + logger = loggerFactory.CreateLogger<<%= methodName %>>(); + } + + /// + /// Executes the logic app workflow. + /// + /// The zip code. + /// The temperature scale (e.g., Celsius or Fahrenheit). + [Function("<%= methodName %>")] + public Task Run([WorkflowActionTrigger] int zipCode, string temperatureScale) + { + this.logger.LogInformation("Starting <%= methodName %> with Zip Code: " + zipCode + " and Scale: " + temperatureScale); + + // Generate random temperature within a range based on the temperature scale + Random rnd = new Random(); + var currentTemp = temperatureScale == "Celsius" ? rnd.Next(1, 30) : rnd.Next(40, 90); + var lowTemp = currentTemp - 10; + var highTemp = currentTemp + 10; + + // Create a Weather object with the temperature information + var weather = new Weather() + { + ZipCode = zipCode, + CurrentWeather = $"The current weather is {currentTemp} {temperatureScale}", + DayLow = $"The low for the day is {lowTemp} {temperatureScale}", + DayHigh = $"The high for the day is {highTemp} {temperatureScale}" + }; + + return Task.FromResult(weather); + } + + /// + /// Represents the weather information for <%= methodName %>. + /// + public class Weather + { + /// + /// Gets or sets the zip code. + /// + public int ZipCode { get; set; } + + /// + /// Gets or sets the current weather. + /// + public string CurrentWeather { get; set; } + + /// + /// Gets or sets the low temperature for the day. + /// + public string DayLow { get; set; } + + /// + /// Gets or sets the high temperature for the day. + /// + public string DayHigh { get; set; } + } + } +} diff --git a/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet10 b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet10 new file mode 100644 index 00000000000..d94404e6379 --- /dev/null +++ b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet10 @@ -0,0 +1,43 @@ + + + net10.0 + v4 + Library + AnyCPU + $(MSBuildProjectDirectory)\..\LogicApp + Always + false + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet8 b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet8 index 08825c71c88..737dc5ee526 100644 --- a/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet8 +++ b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet8 @@ -5,49 +5,20 @@ v4 Library AnyCPU - LogicApp + $(MSBuildProjectDirectory)\..\LogicApp Always false - true - + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - + \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet8New b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet8New deleted file mode 100644 index 737dc5ee526..00000000000 --- a/apps/vs-code-designer/src/assets/FunctionProjectTemplate/FunctionsProjNet8New +++ /dev/null @@ -1,24 +0,0 @@ - - - false - net8 - v4 - Library - AnyCPU - $(MSBuildProjectDirectory)\..\LogicApp - Always - false - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/FunctionProjectTemplate/ProgramFileNet10 b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/ProgramFileNet10 new file mode 100644 index 00000000000..04defbd9288 --- /dev/null +++ b/apps/vs-code-designer/src/assets/FunctionProjectTemplate/ProgramFileNet10 @@ -0,0 +1,25 @@ +// ----------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ----------------------------------------------------------- + +namespace <%= namespace %> +{ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + + internal static class Program + { + private static void Main(string[] args) + { + var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices(services => + { + services.AddApplicationInsightsTelemetryWorkerService(); + }) + .Build(); + + host.Run(); + } + } +} diff --git a/apps/vs-code-designer/src/assets/backupTemplates/dotnet/~4/net10.0/item.nupkg b/apps/vs-code-designer/src/assets/backupTemplates/dotnet/~4/net10.0/item.nupkg new file mode 100644 index 00000000000..1e68594c817 Binary files /dev/null and b/apps/vs-code-designer/src/assets/backupTemplates/dotnet/~4/net10.0/item.nupkg differ diff --git a/apps/vs-code-designer/src/assets/backupTemplates/dotnet/~4/net10.0/project.nupkg b/apps/vs-code-designer/src/assets/backupTemplates/dotnet/~4/net10.0/project.nupkg new file mode 100644 index 00000000000..ea569b5ade9 Binary files /dev/null and b/apps/vs-code-designer/src/assets/backupTemplates/dotnet/~4/net10.0/project.nupkg differ diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 4302cc18f1f..e01c52feeb0 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -346,6 +346,7 @@ export const DependencyDefaultPath = { export type DependencyDefaultPath = (typeof DependencyDefaultPath)[keyof typeof DependencyDefaultPath]; // .NET export const DotnetVersion = { + net10: 'net10.0', net8: 'net8.0', net6: 'net6.0', net3: 'netcoreapp3.1', diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx new file mode 100644 index 00000000000..bffddd26cef --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { createWorkspaceSlice, type CreateWorkspaceState } from '../../../../state/createWorkspaceSlice'; +import { ProjectType } from '@microsoft/vscode-extension-logic-apps'; + +vi.mock('../../createWorkspaceStyles', () => ({ + useCreateWorkspaceStyles: () => + new Proxy( + {}, + { + get: (_target, prop) => `mock-${String(prop)}`, + } + ), +})); + +import { DotNetFrameworkStep } from '../dotNetFrameworkStep'; + +const createTestStore = (overrides: Partial = {}) => { + const defaultState: CreateWorkspaceState = { + currentStep: 1, + packagePath: { fsPath: '', path: '' }, + workspaceProjectPath: { fsPath: '', path: '' }, + workspaceName: '', + logicAppType: ProjectType.customCode, + functionNamespace: 'TestNamespace', + functionName: 'TestFunction', + functionFolderName: 'TestFolder', + workflowType: '', + workflowName: '', + targetFramework: '', + logicAppName: 'TestLogicApp', + projectType: '', + openBehavior: '', + isLoading: false, + isComplete: false, + workspaceFileJson: '', + logicAppsWithoutCustomCode: undefined, + flowType: 'createWorkspace', + pathValidationResults: {}, + packageValidationResults: {}, + workspaceExistenceResults: {}, + isValidatingWorkspace: false, + isValidatingPackage: false, + separator: '/', + platform: null, + isDevContainerProject: false, + ...overrides, + }; + + return configureStore({ + reducer: { + createWorkspace: createWorkspaceSlice.reducer, + }, + preloadedState: { + createWorkspace: defaultState, + }, + }); +}; + +const renderWithStore = (overrides: Partial = {}) => { + const store = createTestStore(overrides); + return { + store, + ...render( + + + + ), + }; +}; + +describe('DotNetFrameworkStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering for customCode project type', () => { + it('should render the dotnet version dropdown', () => { + renderWithStore({ logicAppType: ProjectType.customCode }); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('should render .NET 8 and .NET 10 options on non-Windows', () => { + renderWithStore({ logicAppType: ProjectType.customCode, platform: null }); + const combobox = screen.getByRole('combobox'); + fireEvent.click(combobox); + expect(screen.getByText('.NET 8')).toBeInTheDocument(); + expect(screen.getByText('.NET 10')).toBeInTheDocument(); + }); + + it('should also render .NET Framework option on Windows', () => { + renderWithStore({ logicAppType: ProjectType.customCode, platform: 'win32' as any }); + const combobox = screen.getByRole('combobox'); + fireEvent.click(combobox); + expect(screen.getByText('.NET Framework')).toBeInTheDocument(); + expect(screen.getByText('.NET 8')).toBeInTheDocument(); + expect(screen.getByText('.NET 10')).toBeInTheDocument(); + }); + + it('should not render .NET Framework option on non-Windows', () => { + renderWithStore({ logicAppType: ProjectType.customCode, platform: 'darwin' as any }); + const combobox = screen.getByRole('combobox'); + fireEvent.click(combobox); + expect(screen.queryByText('.NET Framework')).not.toBeInTheDocument(); + }); + }); + + describe('selected framework display', () => { + it('should show .NET 10 label when net10.0 is selected', () => { + renderWithStore({ + logicAppType: ProjectType.customCode, + targetFramework: 'net10.0', + }); + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveTextContent('.NET 10'); + }); + + it('should show .NET 8 label when net8 is selected', () => { + renderWithStore({ + logicAppType: ProjectType.customCode, + targetFramework: 'net8', + }); + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveTextContent('.NET 8'); + }); + + it('should show description text when a framework is selected', () => { + renderWithStore({ + logicAppType: ProjectType.customCode, + targetFramework: 'net10.0', + }); + // Description text should appear below the dropdown + expect(screen.getByText(/modern development and performance/)).toBeInTheDocument(); + }); + }); + + describe('non-customCode project types', () => { + it('should return null for logicApp project type', () => { + const { container } = renderWithStore({ logicAppType: ProjectType.logicApp }); + expect(container.querySelector('[role="combobox"]')).toBeNull(); + }); + }); + + describe('rules engine project type', () => { + it('should render function configuration fields for rulesEngine', () => { + renderWithStore({ + logicAppType: ProjectType.rulesEngine, + functionFolderName: 'RulesFolder', + functionNamespace: 'Rules.Namespace', + functionName: 'EvalRule', + }); + // Rules engine renders inputs but no dotnet version dropdown + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getAllByRole('textbox').length).toBeGreaterThan(0); + }); + }); + + describe('framework selection dispatch', () => { + it('should dispatch setTargetFramework when an option is selected', () => { + const { store } = renderWithStore({ + logicAppType: ProjectType.customCode, + targetFramework: '', + }); + + const combobox = screen.getByRole('combobox'); + fireEvent.click(combobox); + + const net10Option = screen.getByText('.NET 10'); + fireEvent.click(net10Option); + + const state = store.getState().createWorkspace; + expect(state.targetFramework).toBe('net10.0'); + }); + }); +}); diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx index 0eaa604faf4..e95a48e9b8c 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx @@ -186,6 +186,15 @@ describe('ReviewCreateStep', () => { }); expect(screen.getByText('.NET 8')).toBeInTheDocument(); }); + + it('should display dot net framework correctly for net10.0', () => { + renderWithStore({ + flowType: 'createWorkspace', + logicAppType: ProjectType.customCode, + targetFramework: 'net10.0', + }); + expect(screen.getByText('.NET 10')).toBeInTheDocument(); + }); }); describe('rules engine configuration', () => { diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/dotNetFrameworkStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/dotNetFrameworkStep.tsx index bb304d63b0c..cbda7164285 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/dotNetFrameworkStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/dotNetFrameworkStep.tsx @@ -12,7 +12,13 @@ import { setTargetFramework, setFunctionNamespace, setFunctionName, setFunctionF import { useIntlMessages, workspaceMessages } from '../../../intl'; import { useSelector, useDispatch } from 'react-redux'; import { nameValidation, validateFunctionName, validateFunctionNamespace } from '../validation/helper'; -import { Platform, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { Platform, ProjectType, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; + +type TargetFrameworkOption = { + value: string; + label: string; + description: string; +}; export const DotNetFrameworkStep: React.FC = () => { const dispatch = useDispatch(); @@ -31,6 +37,30 @@ export const DotNetFrameworkStep: React.FC = () => { const [functionNameError, setFunctionNameError] = useState(undefined); const [functionFolderNameError, setFunctionFolderNameError] = useState(undefined); + const targetFrameworkOptions: TargetFrameworkOption[] = [ + ...(platform === Platform.windows + ? [ + { + value: TargetFramework.NetFx, + label: intlText.DOTNET_FRAMEWORK_OPTION, + description: intlText.DOTNET_FRAMEWORK_DESCRIPTION, + }, + ] + : []), + { + value: TargetFramework.Net8, + label: intlText.DOTNET_8, + description: intlText.DOTNET_8_DESCRIPTION, + }, + { + value: TargetFramework.Net10, + label: intlText.DOTNET_10, + description: intlText.DOTNET_10_DESCRIPTION, + }, + ]; + + const selectedTargetFramework = targetFrameworkOptions.find((option) => option.value === targetFramework); + const handleDotNetFrameworkChange: DropdownProps['onOptionSelect'] = (event, data) => { if (data.optionValue) { dispatch(setTargetFramework(data.optionValue)); @@ -95,22 +125,19 @@ export const DotNetFrameworkStep: React.FC = () => { - {platform === Platform.windows ? ( - - ) : null} - + ))} - {targetFramework && ( + {selectedTargetFramework && ( { display: 'block', }} > - {targetFramework === 'net472' && intlText.DOTNET_FRAMEWORK_DESCRIPTION} - {targetFramework === 'net8' && intlText.DOTNET_8_DESCRIPTION} + {selectedTargetFramework.description} )} diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/reviewCreateStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/reviewCreateStep.tsx index 4fd30a427e8..8a872ed0088 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/reviewCreateStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/reviewCreateStep.tsx @@ -8,7 +8,7 @@ import type { CreateWorkspaceState } from '../../../state/createWorkspaceSlice'; import { useIntlMessages, workspaceMessages } from '../../../intl'; import { useSelector } from 'react-redux'; import { Text } from '@fluentui/react-components'; -import { ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { ProjectType, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; export const ReviewCreateStep: React.FC = () => { const intlText = useIntlMessages(workspaceMessages); @@ -48,44 +48,20 @@ export const ReviewCreateStep: React.FC = () => { const shouldShowLogicAppSection = flowType === 'createWorkspace' || flowType === 'createLogicApp' || flowType === 'createWorkspaceFromPackage'; const shouldShowWorkflowSection = (flowType === 'createWorkspace' || flowType === 'createLogicApp') && !isUsingExistingLogicApp; - - const getWorkspaceFilePath = () => { - if (!workspaceProjectPath.fsPath || !workspaceName) { - return ''; - } - return `${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${workspaceName}.code-workspace`; - }; - - const getWorkspaceFolderPath = () => { - if (!workspaceProjectPath.fsPath || !workspaceName) { - return ''; - } - return `${workspaceProjectPath.fsPath}${separator}${workspaceName}`; - }; - - const getLogicAppLocationPath = () => { - if (!workspaceProjectPath.fsPath || !workspaceName || !logicAppName) { - return ''; - } - return `${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${logicAppName}`; - }; - - const getFunctionLocationPath = () => { - if (!workspaceProjectPath.fsPath || !workspaceName || !functionFolderName) { - return ''; - } - return `${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${functionFolderName}`; - }; + const workspaceBasePath = + workspaceProjectPath.fsPath && workspaceName ? `${workspaceProjectPath.fsPath}${separator}${workspaceName}` : ''; + const workspaceFilePath = workspaceBasePath ? `${workspaceBasePath}${separator}${workspaceName}.code-workspace` : ''; + const logicAppLocationPath = workspaceBasePath && logicAppName ? `${workspaceBasePath}${separator}${logicAppName}` : ''; + const functionLocationPath = workspaceBasePath && functionFolderName ? `${workspaceBasePath}${separator}${functionFolderName}` : ''; const getDotNetFrameworkDisplay = (framework: string) => { - switch (framework) { - case 'net472': - return '.NET Framework'; - case 'net8': - return '.NET 8'; - default: - return framework; - } + const frameworkDisplayMap: Record = { + [TargetFramework.NetFx]: intlText.DOTNET_FRAMEWORK_OPTION, + [TargetFramework.Net8]: intlText.DOTNET_8, + [TargetFramework.Net10]: intlText.DOTNET_10, + }; + + return frameworkDisplayMap[framework] ?? framework; }; const getLogicAppTypeDisplay = (type: string) => { @@ -145,8 +121,8 @@ export const ReviewCreateStep: React.FC = () => {
{intlText.PROJECT_SETUP}
{renderSettingRow(intlText.WORKSPACE_NAME_REVIEW, workspaceName)} - {renderSettingRow(intlText.WORKSPACE_FOLDER, getWorkspaceFolderPath())} - {renderSettingRow(intlText.WORKSPACE_FILE, getWorkspaceFilePath())} + {renderSettingRow(intlText.WORKSPACE_FOLDER, workspaceBasePath)} + {renderSettingRow(intlText.WORKSPACE_FILE, workspaceFilePath)} {renderSettingRow(intlText.USE_DEV_CONTAINER_LABEL, isDevContainerProject ? 'Yes' : 'No')}
)} @@ -155,7 +131,7 @@ export const ReviewCreateStep: React.FC = () => {
Logic App Details
{renderSettingRow(intlText.LOGIC_APP_NAME_REVIEW, logicAppName)} - {flowType !== 'createLogicApp' && renderSettingRow(intlText.LOGIC_APP_LOCATION, getLogicAppLocationPath())} + {flowType !== 'createLogicApp' && renderSettingRow(intlText.LOGIC_APP_LOCATION, logicAppLocationPath)} {renderSettingRow(intlText.LOGIC_APP_TYPE_REVIEW, getLogicAppTypeDisplay(logicAppType))}
)} @@ -165,7 +141,7 @@ export const ReviewCreateStep: React.FC = () => {
Custom Code Configuration
{renderSettingRow(intlText.DOTNET_FRAMEWORK_REVIEW, getDotNetFrameworkDisplay(targetFramework))} {renderSettingRow(intlText.CUSTOM_CODE_FOLDER, functionFolderName)} - {renderSettingRow(intlText.CUSTOM_CODE_LOCATION, getFunctionLocationPath())} + {renderSettingRow(intlText.CUSTOM_CODE_LOCATION, functionLocationPath)} {renderSettingRow(intlText.FUNCTION_WORKSPACE, functionNamespace)} {renderSettingRow(intlText.FUNCTION_NAME_REVIEW, functionName)} @@ -175,7 +151,7 @@ export const ReviewCreateStep: React.FC = () => {
Function Configuration
{renderSettingRow(intlText.RULES_ENGINE_FOLDER, functionFolderName)} - {renderSettingRow(intlText.RULES_ENGINE_LOCATION, getFunctionLocationPath())} + {renderSettingRow(intlText.RULES_ENGINE_LOCATION, functionLocationPath)} {renderSettingRow(intlText.FUNCTION_WORKSPACE, functionNamespace)} {renderSettingRow(intlText.FUNCTION_NAME_REVIEW, functionName)}
diff --git a/apps/vs-code-react/src/intl/messages.ts b/apps/vs-code-react/src/intl/messages.ts index 6932a661f33..e5314832a56 100644 --- a/apps/vs-code-react/src/intl/messages.ts +++ b/apps/vs-code-react/src/intl/messages.ts @@ -360,6 +360,16 @@ export const workspaceMessages = defineMessages({ id: 'q1dxkD', description: '.NET 8 description', }, + DOTNET_10: { + defaultMessage: '.NET 10', + id: 'JsTRX9', + description: '.NET 10 option', + }, + DOTNET_10_DESCRIPTION: { + defaultMessage: 'Use the latest .NET 10 for modern development and performance', + id: 'Q1tyGI', + description: '.NET 10 description', + }, FUNCTION_NAMESPACE: { defaultMessage: 'Function namespace', id: 'mr/BC/', diff --git a/libs/vscode-extension/src/lib/models/workflow.ts b/libs/vscode-extension/src/lib/models/workflow.ts index 0dcca04dcf4..ce2acc2399b 100644 --- a/libs/vscode-extension/src/lib/models/workflow.ts +++ b/libs/vscode-extension/src/lib/models/workflow.ts @@ -123,5 +123,6 @@ export type MismatchBehavior = (typeof MismatchBehavior)[keyof typeof MismatchBe export const TargetFramework = { NetFx: 'net472', Net8: 'net8', + Net10: 'net10.0', } as const; export type TargetFramework = (typeof TargetFramework)[keyof typeof TargetFramework];