diff --git a/apps/vs-code-designer/package.json b/apps/vs-code-designer/package.json index a5c9577df65..3e94d1b828f 100644 --- a/apps/vs-code-designer/package.json +++ b/apps/vs-code-designer/package.json @@ -56,7 +56,7 @@ }, "private": true, "scripts": { - "build:extension": "tsup && pnpm run copyFiles", + "build:extension": "tsup && pnpm run copyFiles && cd dist && npm install --ignore-scripts", "build:ui": "tsup --config tsup.e2e.test.config.ts", "copyFiles": "node extension-copy-svgs.js", "vscode:designer:pack": "pnpm run vscode:designer:pack:step1 && pnpm run vscode:designer:pack:step2", diff --git a/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts b/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts index 830cbae4385..2cf330d60d1 100644 --- a/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts +++ b/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts @@ -150,21 +150,25 @@ describe('devContainer Integration Tests', () => { }); describe('useBinariesDependencies - devContainer override', () => { - it('should return false in devContainer regardless of global setting', async () => { - // Use importActual to run the REAL useBinariesDependencies logic. - // Its dependencies (isDevContainerWorkspace, getGlobalSetting) are still mocked. - const { useBinariesDependencies: realUseBinariesDependencies } = - await vi.importActual('../app/utils/binaries'); - const { isDevContainerWorkspace } = await import('../app/utils/devContainerUtils'); - const settingsModule = await import('../app/utils/vsCodeConfig/settings'); - - vi.mocked(isDevContainerWorkspace).mockResolvedValue(true); - vi.mocked(settingsModule.getGlobalSetting).mockReturnValue(true); - - const result = await realUseBinariesDependencies(); - - expect(result).toBe(false); - }); + it( + 'should return false in devContainer regardless of global setting', + async () => { + // Use importActual to run the REAL useBinariesDependencies logic. + // Its dependencies (isDevContainerWorkspace, getGlobalSetting) are still mocked. + const { useBinariesDependencies: realUseBinariesDependencies } = + await vi.importActual('../app/utils/binaries'); + const { isDevContainerWorkspace } = await import('../app/utils/devContainerUtils'); + const settingsModule = await import('../app/utils/vsCodeConfig/settings'); + + vi.mocked(isDevContainerWorkspace).mockResolvedValue(true); + vi.mocked(settingsModule.getGlobalSetting).mockReturnValue(true); + + const result = await realUseBinariesDependencies(); + + expect(result).toBe(false); + }, + { timeout: 10000 } + ); it('should respect global setting in non-devContainer workspace', async () => { const { useBinariesDependencies: realUseBinariesDependencies } = diff --git a/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts b/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts index 64988c0b2c0..94668fe9256 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as vscode from 'vscode'; -import { pickCustomCodeNetHostProcess, pickCustomCodeNetHostProcessInternal } from '../pickCustomCodeWorkerProcess'; +import { + pickCustomCodeNetHostProcess, + pickCustomCodeNetHostProcessInternal, + pickCustomCodeWorkerChildProcess, +} from '../pickCustomCodeWorkerProcess'; import * as validatePreDebug from '../../debug/validatePreDebug'; import { IRunningFuncTask, runningFuncTaskMap } from '../../utils/funcCoreTools/funcHostTask'; import * as pickFuncProcessModule from '../pickFuncProcess'; @@ -143,6 +147,7 @@ describe('pickCustomCodeNetHostProcessInternal', () => { describe('pickCustomCodeWorkerChildProcess', async () => { const testFuncPid = 12345; + const testChildFuncPid = 54321; const testDotnetPid = 67890; const testLogicAppName = 'LogicApp'; const testLogicAppPath = path.join('path', 'to', testLogicAppName); @@ -177,8 +182,7 @@ describe('pickCustomCodeWorkerChildProcess', async () => { ]); // Re-import to get the mocked version - const { pickCustomCodeWorkerChildProcess: pickCustomCodeWorkerChildProcessMocked } = await import('../pickCustomCodeWorkerProcess'); - const result = await pickCustomCodeWorkerChildProcessMocked(testFuncTask, false); + const result = await pickCustomCodeWorkerChildProcess(testFuncTask, false); expect(result).toBe(testDotnetPid.toString()); }); @@ -190,27 +194,35 @@ describe('pickCustomCodeWorkerChildProcess', async () => { { command: 'other2', pid: 11112 }, ]); - const { pickCustomCodeWorkerChildProcess: pickCustomCodeWorkerChildProcessMocked } = await import('../pickCustomCodeWorkerProcess'); - const result = await pickCustomCodeWorkerChildProcessMocked(testFuncTask, false); + const result = await pickCustomCodeWorkerChildProcess(testFuncTask, false); expect(result).toBe(testDotnetPid.toString()); }); - it('should return the pid of a child process matching func on Unix', async () => { + it('should return the pid of a codeful child process matching dotnet on Unix', async () => { vi.stubGlobal('process', { platform: 'linux' }); - vi.spyOn(pickFuncProcessModule, 'getUnixChildren').mockResolvedValue([ - { command: 'func.exe', pid: testDotnetPid }, - { command: 'other', pid: 11111 }, - ]); + vi.spyOn(pickFuncProcessModule, 'getUnixChildren').mockImplementation((pid: Number) => { + if (pid === Number(testFuncPid)) { + return Promise.resolve([ + { command: 'func', pid: testChildFuncPid }, + { command: 'other', pid: 11111 }, + ]); + } else if (pid === Number(testChildFuncPid)) { + return Promise.resolve([ + { command: 'dotnet', pid: testDotnetPid }, + { command: 'other', pid: 22222 }, + ]); + } else { + return Promise.resolve([]); + } + }); - const { pickCustomCodeWorkerChildProcess: pickCustomCodeWorkerChildProcessMocked } = await import('../pickCustomCodeWorkerProcess'); - const result = await pickCustomCodeWorkerChildProcessMocked(testFuncTask, false); + const result = await pickCustomCodeWorkerChildProcess(testFuncTask, false /* isNetFxWorker */, false /* isCodeless */); expect(result).toBe(testDotnetPid.toString()); }); it('should return undefined if no matching child process is found', async () => { vi.stubGlobal('process', { platform: 'win32' }); - const { pickCustomCodeWorkerChildProcess: pickCustomCodeWorkerChildProcessMocked } = await import('../pickCustomCodeWorkerProcess'); - const result = await pickCustomCodeWorkerChildProcessMocked(testFuncTask, false); + const result = await pickCustomCodeWorkerChildProcess(testFuncTask, false); expect(result).toBeUndefined(); }); @@ -220,8 +232,7 @@ describe('pickCustomCodeWorkerChildProcess', async () => { const getUnixChildren = vi.fn(); vi.spyOn(pickFuncProcessModule, 'pickChildProcess').mockResolvedValue(undefined as any); - const { pickCustomCodeWorkerChildProcess: pickCustomCodeWorkerChildProcessMocked } = await import('../pickCustomCodeWorkerProcess'); - const result = await pickCustomCodeWorkerChildProcessMocked(testFuncTask, false); + const result = await pickCustomCodeWorkerChildProcess(testFuncTask, false); expect(result).toBeUndefined(); }); }); diff --git a/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.test.ts b/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.test.ts new file mode 100644 index 00000000000..6db36e2b75b --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetChildProcessesWithScript = vi.fn(); + +vi.mock('../../utils/findChildProcess/findChildProcess', () => ({ + getChildProcessesWithScript: mockGetChildProcessesWithScript, +})); + +const pickFuncProcessModule = await import('../pickFuncProcess'); + +describe('findChildProcess', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the func.exe descendant pid on Windows', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + mockGetChildProcessesWithScript.mockResolvedValue([ + { processId: 6312, name: 'conhost.exe', parentProcessId: 17884 }, + { processId: 31696, name: 'func.exe', parentProcessId: 17884 }, + { processId: 33904, name: 'node.exe', parentProcessId: 31696 }, + ]); + + const result = await pickFuncProcessModule.findChildProcess(17884); + + expect(result).toBe('31696'); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledWith(17884); + }); + + it('returns undefined on Windows when no func.exe descendant is found', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + mockGetChildProcessesWithScript.mockResolvedValue([ + { processId: 6312, name: 'conhost.exe', parentProcessId: 17884 }, + { processId: 33904, name: 'node.exe', parentProcessId: 31696 }, + ]); + + const result = await pickFuncProcessModule.findChildProcess(17884); + + expect(result).toBeUndefined(); + }); + + it('returns a matching child pid on Unix and does not fall back to the parent pid', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.spyOn(pickFuncProcessModule, 'getUnixChildren').mockResolvedValue([ + { command: 'bash', pid: 1000 }, + { command: 'func', pid: 2000 }, + { command: 'dotnet', pid: 3000 }, + ]); + + const result = await pickFuncProcessModule.findChildProcess(1234); + + expect(result).toBe('3000'); + }); + + it('returns undefined on Unix when no matching child process is found', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.spyOn(pickFuncProcessModule, 'getUnixChildren').mockResolvedValue([{ command: 'bash', pid: 1000 }]); + + const result = await pickFuncProcessModule.findChildProcess(1234); + + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/appSettings/uploadAppSettings.ts b/apps/vs-code-designer/src/app/commands/appSettings/uploadAppSettings.ts index 4e1ea0c16af..0083819b6b4 100644 --- a/apps/vs-code-designer/src/app/commands/appSettings/uploadAppSettings.ts +++ b/apps/vs-code-designer/src/app/commands/appSettings/uploadAppSettings.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 { localSettingsFileName, logicAppFilter, ProjectDirectoryPathKey, workflowCodefulEnabled } from '../../../constants'; +import { localSettingsFileName, logicAppFilter } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { getLocalSettingsJson } from '../../utils/appSettings/localSettings'; @@ -66,9 +66,6 @@ export async function uploadAppSettings( } ext.outputChannel.appendLog(localize('uploadingSettings', 'Uploading settings...'), { resourceName: client.fullName }); - if (localSettings.Values[workflowCodefulEnabled] === 'true') { - localSettings.Values[ProjectDirectoryPathKey] = 'C:\\home\\site\\wwwroot\\lib\\codeful'; - } await confirmOverwriteSettings(context, localSettings.Values, remoteSettings.properties, client.fullName); if (excludedAppSettings.length) { diff --git a/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts b/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts index 74d5f0d7400..25852a4a605 100644 --- a/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts @@ -27,6 +27,29 @@ export async function createWorkspaceFile(context: IActionContext, options: any) const webviewProjectContext: IWebviewProjectContext = options; + // Add telemetry properties for debugging + context.telemetry.properties.hasWorkspaceProjectPath = String(!!webviewProjectContext.workspaceProjectPath); + context.telemetry.properties.workspaceProjectPathType = typeof webviewProjectContext.workspaceProjectPath; + context.telemetry.properties.receivedOptionsKeys = Object.keys(options || {}).join(','); + + // Validate that workspaceProjectPath exists and has required properties + if (!webviewProjectContext.workspaceProjectPath || !webviewProjectContext.workspaceProjectPath.fsPath) { + const errorMessage = `[ConvertToWorkspace] Invalid workspaceProjectPath: ${JSON.stringify( + { + hasWorkspaceProjectPath: !!webviewProjectContext.workspaceProjectPath, + workspaceProjectPathType: typeof webviewProjectContext.workspaceProjectPath, + workspaceProjectPathValue: webviewProjectContext.workspaceProjectPath, + contextKeys: Object.keys(options || {}), + }, + null, + 2 + )}`; + ext.outputChannel.appendLog(errorMessage); + throw new Error( + `workspaceProjectPath is required and must have an fsPath property. Received: ${JSON.stringify(webviewProjectContext.workspaceProjectPath)}` + ); + } + const workspaceFolderPath = path.join(webviewProjectContext.workspaceProjectPath.fsPath, webviewProjectContext.workspaceName); await fse.ensureDir(workspaceFolderPath); diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppProjects.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppProjects.ts index 56002a2cd14..678b51cb7e5 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppProjects.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppProjects.ts @@ -62,7 +62,7 @@ export async function createLogicAppProject(context: IActionContext, options: an const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; mySubContext.logicAppName = options.logicAppName; mySubContext.projectPath = logicAppFolderPath; - mySubContext.projectType = webviewProjectContext.logicAppType as ProjectType; + mySubContext.projectType = webviewProjectContext.logicAppType; mySubContext.functionFolderName = options.functionFolderName; mySubContext.functionAppName = options.functionName; mySubContext.functionAppNamespace = options.functionNamespace; 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 3f0159040cc..7e5a63fd1d8 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 @@ -27,17 +27,22 @@ import path from 'path'; import * as fse from 'fs-extra'; import type { DebugConfiguration } from 'vscode'; import { confirmEditJsonFile } from '../../../utils/fs'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { localize } from '../../../../localize'; import { ext } from '../../../../extensionVariables'; import { isDebugConfigEqual } from '../../../utils/vsCodeConfig/launch'; import { binariesExist } from '../../../utils/binaries'; +import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject'; +import { + type CustomCodeFunctionsProjectMetadata, + getCustomCodeFunctionsProjectMetadata, + tryGetLogicAppCustomCodeFunctionsProjects, +} from '../../../utils/customCodeUtils'; async function writeSettingsJson(context: IWebviewProjectContext, additionalSettings: ISettingToAdd[], vscodePath: string): Promise { const { targetFramework, logicAppType } = context; const settings: ISettingToAdd[] = additionalSettings.concat( - { key: projectLanguageSetting, value: logicAppType === ProjectType.agentCodeful ? ProjectLanguage.CSharp : ProjectLanguage.JavaScript }, + { key: projectLanguageSetting, value: logicAppType === ProjectType.codeful ? ProjectLanguage.CSharp : 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' }, @@ -46,12 +51,15 @@ async function writeSettingsJson(context: IWebviewProjectContext, additionalSett const settingsJsonPath: string = path.join(vscodePath, settingsFileName); - if (logicAppType === ProjectType.agentCodeful) { + if (logicAppType === ProjectType.codeful) { const deploySubPathValue = path.posix.join('bin', 'Release', targetFramework ?? TargetFramework.NetFx, 'publish'); settings.push( { prefix: 'azureFunctions', key: 'deploySubpath', value: deploySubPathValue }, { prefix: 'azureFunctions', key: 'preDeployTask', value: 'publish' }, - { prefix: 'azureFunctions', key: 'projectSubpath', value: deploySubPathValue } + { prefix: 'azureFunctions', key: 'projectSubpath', value: deploySubPathValue }, + // Prevent OmniSharp from generating invalid solution files + { prefix: 'omnisharp', key: 'enableMsBuildLoadProjectsOnDemand', value: false }, + { prefix: 'omnisharp', key: 'disableMSBuildDiagnosticWarning', value: true } ); } await confirmEditJsonFile(context, settingsJsonPath, (data: Record): Record => { @@ -80,9 +88,9 @@ export async function writeExtensionsJson(webviewProjectContext: IWebviewProject await fse.writeJson(extensionsJsonPath, extensionsData, { spaces: 2 }); } -const getAgentCodefulTasks = (targetFramework: string) => { - const commonArgs: string[] = ['/property:GenerateFullPaths=true', '/consoleloggerparameters:NoSummary']; - const releaseArgs: string[] = ['--configuration', 'Release']; +const getCodefulTasks = (targetFramework: string) => { + const commonDotnetArgs: string[] = ['/property:GenerateFullPaths=true', '/consoleloggerparameters:NoSummary']; + const releaseDotnetArgs: string[] = ['--configuration', 'Release']; const funcBinariesExist = binariesExist(funcDependencyName); const debugSubpath = path.posix.join('bin', 'Debug', targetFramework); const binariesOptions = funcBinariesExist @@ -99,14 +107,14 @@ const getAgentCodefulTasks = (targetFramework: string) => { { label: 'clean', command: '${config:azureLogicAppsStandard.dotnetBinaryPath}', - args: ['clean', ...commonArgs], + args: ['clean', ...commonDotnetArgs], type: 'process', problemMatcher: '$msCompile', }, { label: 'build', command: '${config:azureLogicAppsStandard.dotnetBinaryPath}', - args: ['build', ...commonArgs], + args: ['build', ...commonDotnetArgs], type: 'process', dependsOn: 'clean', group: { @@ -118,14 +126,14 @@ const getAgentCodefulTasks = (targetFramework: string) => { { label: 'clean release', command: '${config:azureLogicAppsStandard.dotnetBinaryPath}', - args: ['clean', ...releaseArgs, ...commonArgs], + args: ['clean', ...releaseDotnetArgs, ...commonDotnetArgs], type: 'process', problemMatcher: '$msCompile', }, { label: dotnetPublishTaskLabel, command: '${config:azureLogicAppsStandard.dotnetBinaryPath}', - args: ['publish', ...releaseArgs, ...commonArgs], + args: ['publish', ...releaseDotnetArgs, ...commonDotnetArgs], type: 'process', dependsOn: 'clean release', problemMatcher: '$msCompile', @@ -153,17 +161,15 @@ async function writeTasksJson(context: IWebviewProjectContext, vscodePath: strin const templateContent = await fse.readFile(templatePath, 'utf8'); const tasksData = JSON.parse(templateContent); - if (logicAppType === ProjectType.agentCodeful && targetFramework) { - // Get the agent codeful-specific tasks - const agentCodefulTasks = getAgentCodefulTasks(targetFramework); - - // Replace tasks with agent codeful tasks - tasksData.tasks = agentCodefulTasks; + if (logicAppType === ProjectType.codeful && targetFramework) { + // Get the codeful-specific tasks + const codefulTasks = getCodefulTasks(targetFramework); + tasksData.tasks = codefulTasks; // Write the modified tasks.json file await fse.writeJson(tasksJsonPath, tasksData, { spaces: 2 }); } else { - // For non-agentCodeful projects, just copy the template + // For non-codeful projects, just copy the template await fse.copyFile(templatePath, tasksJsonPath); } } @@ -174,7 +180,22 @@ export async function writeDevContainerJson(devContainerPath: string): Promise { - const newDebugConfig: DebugConfiguration = getDebugConfiguration(logicAppName, customCodeTargetFramework); +export async function writeLaunchJson(context: IWebviewProjectContext, vscodePath: string, logicAppName: string): Promise { + const customCodeTargetFramework = + context.logicAppType === ProjectType.customCode || context.logicAppType === ProjectType.rulesEngine + ? (context.targetFramework ?? (await tryGetCustomCodeTargetFramework(context))) + : undefined; + const isCodeful = context.logicAppType === ProjectType.codeful; + const newDebugConfig: DebugConfiguration = getDebugConfiguration(logicAppName, customCodeTargetFramework, isCodeful); // otherwise manually edit json const launchJsonPath: string = path.join(vscodePath, launchFileName); @@ -211,6 +232,18 @@ export async function writeLaunchJson( }); } +async function tryGetCustomCodeTargetFramework(context: IWebviewProjectContext): Promise { + const workspaceFolder = path.join(context.workspaceProjectPath.fsPath, context.workspaceName); + const logicAppFolderPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); + const customCodeProjectPaths = await tryGetLogicAppCustomCodeFunctionsProjects(logicAppFolderPath); + let customCodeProjectsMetadata: CustomCodeFunctionsProjectMetadata[]; + if (customCodeProjectPaths && customCodeProjectPaths.length > 0) { + customCodeProjectsMetadata = await Promise.all(customCodeProjectPaths.map(getCustomCodeFunctionsProjectMetadata)); + } + // Currently only support one custom code functions project per logic app + return customCodeProjectsMetadata ? customCodeProjectsMetadata[0].targetFramework : undefined; +} + export function insertLaunchConfig(existingConfigs: DebugConfiguration[] | undefined, newConfig: DebugConfiguration): DebugConfiguration[] { // tslint:disable-next-line: strict-boolean-expressions existingConfigs = existingConfigs || []; @@ -221,7 +254,7 @@ export function insertLaunchConfig(existingConfigs: DebugConfiguration[] | undef } export async function createLogicAppVsCodeContents(webviewProjectContext: IWebviewProjectContext, logicAppFolderPath: string) { - const { logicAppType, logicAppName, targetFramework } = webviewProjectContext; + const { logicAppType, logicAppName } = webviewProjectContext; const vscodePath: string = path.join(logicAppFolderPath, vscodeFolderName); await fse.ensureDir(vscodePath); @@ -234,7 +267,7 @@ export async function createLogicAppVsCodeContents(webviewProjectContext: IWebvi await writeSettingsJson(webviewProjectContext, additionalSettings, vscodePath); await writeExtensionsJson(webviewProjectContext, vscodePath); await writeTasksJson(webviewProjectContext, vscodePath); - await writeLaunchJson(webviewProjectContext, vscodePath, logicAppName, targetFramework as TargetFramework); + await writeLaunchJson(webviewProjectContext, vscodePath, logicAppName); } export async function createDevContainerContents(webviewProjectContext: IWebviewProjectContext, workspaceFolder: string): Promise { diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts index f135b261803..be94cc511e6 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts @@ -31,6 +31,7 @@ import { workspaceTemplatesFolderName, } from '../../../../constants'; import { localize } from '../../../../localize'; +import { ext } from '../../../../extensionVariables'; import { createArtifactsFolder } from '../../../utils/codeless/artifacts'; import { addLocalFuncTelemetry } from '../../../utils/funcCoreTools/funcVersion'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; @@ -100,10 +101,10 @@ export async function createLogicAppAndWorkflow(webviewProjectContext: IWebviewP const { logicAppType, workflowType, functionName, workflowName, logicAppName } = webviewProjectContext; await fse.ensureDir(logicAppFolderPath); - if (logicAppType === ProjectType.agentCodeful) { - await createAgentCodefulWorkflowFile(logicAppFolderPath, logicAppName, workflowName, workflowType); + if (logicAppType === ProjectType.codeful) { + await createCodefulWorkflowFile(logicAppFolderPath, logicAppName, workflowName, workflowType); } else { - const codelessDefinition: StandardApp = getCodelessWorkflowTemplate(logicAppType as ProjectType, workflowType, functionName); + const codelessDefinition: StandardApp = getCodelessWorkflowTemplate(logicAppType, workflowType, functionName); const logicAppWorkflowFolderPath = path.join(logicAppFolderPath, workflowName); await fse.ensureDir(logicAppWorkflowFolderPath); @@ -112,38 +113,84 @@ export async function createLogicAppAndWorkflow(webviewProjectContext: IWebviewP } } -const createAgentCodefulWorkflowFile = async ( +/** + * Adds a workflow Build() call to an existing Program.cs file. + */ +export const addWorkflowToProgram = async (programFilePath: string, workflowName: string): Promise => { + const programContent = await fse.readFile(programFilePath, 'utf-8'); + + // Find the location to insert the new workflow builder + // Look for the "Build all workflows" comment or insert before host.Run() + const buildCallRegex = /(\s*)(\/\/ Build all workflows\s*\n(?:\s*\/\/.*\n)*\s*)(.*?)(\s*host\.Run\(\);)/s; + + const match = programContent.match(buildCallRegex); + if (match) { + // Insert the new workflow builder before host.Run() + const indent = match[1]; + const newBuildCall = `\n${indent}${workflowName}.AddWorkflow();`; + const updatedContent = programContent.replace(buildCallRegex, `$1$2$3${newBuildCall}\n$4`); + await fse.writeFile(programFilePath, updatedContent); + } else { + // Fallback: try to insert before host.Run() directly + const fallbackRegex = /(\s*)(host\.Run\(\);)/; + const fallbackMatch = programContent.match(fallbackRegex); + if (fallbackMatch) { + const indent = fallbackMatch[1]; + const newBuildCall = `${indent}${workflowName}.AddWorkflow();\n`; + const updatedContent = programContent.replace(fallbackRegex, `${newBuildCall}$1$2`); + await fse.writeFile(programFilePath, updatedContent); + } + } +}; + +export const createCodefulWorkflowFile = async ( logicAppFolderPath: string, logicAppName: string, workflowName: string, workflowType: WorkflowType ) => { - const agentFileName = workflowType === WorkflowType.statefulCodeful ? 'StatefulCodefulFile' : 'AgentCodefulFile'; + const workflowTemplateFileName = workflowType === WorkflowType.statefulCodeful ? 'StatefulCodefulWorkflow' : 'AgentCodefulWorkflow'; const targetDirectory = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); const lspDirectoryPath = path.join(targetDirectory, lspDirectory); - // Create the .cs file inside the functions folder - const templateCSPath = path.join(__dirname, assetsFolderName, 'CodefulProjectTemplate', agentFileName); - const templateCSContent = (await fse.readFile(templateCSPath, 'utf-8')).replace(/<%= flowName %>/g, `"${workflowName}"`); + // Create the workflow-specific .cs file + const capitalizedWorkflowName = (workflowName.charAt(0).toUpperCase() + workflowName.slice(1)).replace(/-/g, '_'); + const templateCSPath = path.join(__dirname, assetsFolderName, 'CodefulProjectTemplate', workflowTemplateFileName); + const templateCSContent = (await fse.readFile(templateCSPath, 'utf-8')) + .replace(/<%= logicAppNamespace %>/g, `${logicAppName}`) + .replace(/<%= flowNameClass %>/g, `${capitalizedWorkflowName}`) + .replace(/<%= flowName %>/g, `"${workflowName}"`); - const csFilePath = path.join(logicAppFolderPath, 'Program.cs'); + // Generate workflow file with workflow name, not Program.cs + const csFilePath = path.join(logicAppFolderPath, `${workflowName}.cs`); await fse.writeFile(csFilePath, templateCSContent); - // Create the .csproj file inside the functions folder - const templateProjPath = path.join(__dirname, assetsFolderName, 'CodefulProjectTemplate', 'CodefulProj'); - const templateProjContent = await fse.readFile(templateProjPath, 'utf-8'); - - const csprojFilePath = path.join(logicAppFolderPath, `${logicAppName}.csproj`); - - await fse.writeFile(csprojFilePath, templateProjContent); - - // Create nuget.config file - // Create the .cs file inside the functions folder - const templateNugetPath = path.join(__dirname, assetsFolderName, 'CodefulProjectTemplate', 'nuget'); - const templateNugetContent = (await fse.readFile(templateNugetPath, 'utf-8')).replace(/<%= lspDirectory %>/g, `"${lspDirectoryPath}"`); - - const nugetFilePath = path.join(logicAppFolderPath, 'nuget.config'); - await fse.writeFile(nugetFilePath, templateNugetContent); + // Create or update Program.cs with the shared entry point + const programFilePath = path.join(logicAppFolderPath, 'Program.cs'); + if (await fse.pathExists(programFilePath)) { + // Program.cs exists, add this workflow to it + await addWorkflowToProgram(programFilePath, capitalizedWorkflowName); + } else { + // Create new Program.cs + const templateProgramPath = path.join(__dirname, assetsFolderName, 'CodefulProjectTemplate', 'ProgramFile'); + const templateProgramContent = await fse.readFile(templateProgramPath, 'utf-8'); + const programContent = templateProgramContent + .replace(/<%= logicAppNamespace %>/g, `${logicAppName}`) + .replace(/<%= workflowBuilders %>/g, `${capitalizedWorkflowName}.AddWorkflow();`); + await fse.writeFile(programFilePath, programContent); + + // Create the .csproj file (only for first workflow) + const templateProjPath = path.join(__dirname, assetsFolderName, 'CodefulProjectTemplate', 'CodefulProj'); + const templateProjContent = await fse.readFile(templateProjPath, 'utf-8'); + const csprojFilePath = path.join(logicAppFolderPath, `${logicAppName}.csproj`); + await fse.writeFile(csprojFilePath, templateProjContent); + + // Create nuget.config file (only for first workflow) + const templateNugetPath = path.join(__dirname, assetsFolderName, 'CodefulProjectTemplate', 'nuget'); + const templateNugetContent = (await fse.readFile(templateNugetPath, 'utf-8')).replace(/<%= lspDirectory %>/g, `"${lspDirectoryPath}"`); + const nugetFilePath = path.join(logicAppFolderPath, 'nuget.config'); + await fse.writeFile(nugetFilePath, templateNugetContent); + } }; export async function createLocalConfigurationFiles( @@ -178,11 +225,12 @@ export async function createLocalConfigurationFiles( localSettingsJson.Values[azureWebJobsFeatureFlagsKey] = multiLanguageWorkerSetting; } - if (logicAppType === ProjectType.agentCodeful) { + // TODO(aeldridge): Update to point to codeful private bundle once it's published. + if (logicAppType === ProjectType.codeful) { localSettingsJson.Values[workflowCodefulEnabled] = 'true'; localSettingsJson.Values['AzureFunctionsJobHost__extensionBundle__id'] = 'Microsoft.Azure.Functions.ExtensionBundle.Workflows'; - localSettingsJson.Values['AzureFunctionsJobHost__extensionBundle__version'] = '[1.141.0.12]'; - localSettingsJson.Values['FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI'] = 'https://cdnforlogicappsv2.blob.core.windows.net/apseth-test'; + localSettingsJson.Values['AzureFunctionsJobHost__extensionBundle__version'] = '[1.160.24]'; + localSettingsJson.Values['FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI'] = 'https://cdnforlogicappsv2.blob.core.windows.net/la-sdk-private'; } const hostJsonPath: string = path.join(logicAppFolderPath, hostFileName); @@ -204,6 +252,22 @@ export async function createLocalConfigurationFiles( export async function createWorkspaceStructure(webviewProjectContext: IWebviewProjectContext): Promise { const { workspaceProjectPath, workspaceName, logicAppName, functionFolderName, logicAppType } = webviewProjectContext; + // Validate that workspaceProjectPath exists and has required properties + if (!workspaceProjectPath || !workspaceProjectPath.fsPath) { + const errorMessage = `[CreateWorkspaceStructure] Invalid workspaceProjectPath: ${JSON.stringify( + { + hasWorkspaceProjectPath: !!workspaceProjectPath, + workspaceProjectPathType: typeof workspaceProjectPath, + workspaceProjectPathValue: workspaceProjectPath, + contextKeys: Object.keys(webviewProjectContext || {}), + }, + null, + 2 + )}`; + ext.outputChannel.appendLog(errorMessage); + throw new Error(`workspaceProjectPath is required and must have an fsPath property. Received: ${JSON.stringify(workspaceProjectPath)}`); + } + //Create the workspace folder const workspaceFolder = path.join(workspaceProjectPath.fsPath, workspaceName); await fse.ensureDir(workspaceFolder); @@ -214,7 +278,7 @@ export async function createWorkspaceStructure(webviewProjectContext: IWebviewPr workspaceFolders.push({ name: logicAppName, path: `./${logicAppName}` }); // push functions folder - if (logicAppType !== ProjectType.logicApp && logicAppType !== ProjectType.agentCodeful) { + if (logicAppType !== ProjectType.logicApp && logicAppType !== ProjectType.codeful) { workspaceFolders.push({ name: functionFolderName, path: `./${functionFolderName}` }); } @@ -263,6 +327,31 @@ export async function createLogicAppWorkspace(context: IActionContext, options: const webviewProjectContext: IWebviewProjectContext = options; + // Add telemetry properties for debugging + context.telemetry.properties.fromPackage = String(fromPackage); + context.telemetry.properties.hasWorkspaceProjectPath = String(!!webviewProjectContext.workspaceProjectPath); + context.telemetry.properties.workspaceProjectPathType = typeof webviewProjectContext.workspaceProjectPath; + context.telemetry.properties.receivedOptionsKeys = Object.keys(options || {}).join(','); + + // Validate that workspaceProjectPath exists and has required properties + if (!webviewProjectContext.workspaceProjectPath || !webviewProjectContext.workspaceProjectPath.fsPath) { + // Log the received options for debugging + const debugInfo = { + hasWorkspaceProjectPath: !!webviewProjectContext.workspaceProjectPath, + workspaceProjectPathType: typeof webviewProjectContext.workspaceProjectPath, + workspaceProjectPathValue: webviewProjectContext.workspaceProjectPath, + optionsKeys: Object.keys(options || {}), + fromPackage, + fullOptionsSnapshot: JSON.stringify(options, null, 2), + }; + const errorMessage = `[CreateLogicAppWorkspace] Invalid workspaceProjectPath received: ${JSON.stringify(debugInfo, null, 2)}`; + ext.outputChannel.appendLog(errorMessage); + + throw new Error( + `workspaceProjectPath is required and must have an fsPath property. Received: ${JSON.stringify(webviewProjectContext.workspaceProjectPath)}. Full context keys: ${Object.keys(options || {}).join(', ')}` + ); + } + await createWorkspaceStructure(webviewProjectContext); // Create the workspace folder @@ -274,7 +363,7 @@ export async function createLogicAppWorkspace(context: IActionContext, options: const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; mySubContext.logicAppName = options.logicAppName; mySubContext.projectPath = logicAppFolderPath; - mySubContext.projectType = webviewProjectContext.logicAppType as ProjectType; + mySubContext.projectType = webviewProjectContext.logicAppType; mySubContext.functionFolderName = options.functionFolderName; mySubContext.functionAppName = options.functionName; mySubContext.functionAppNamespace = options.functionNamespace; @@ -282,6 +371,10 @@ export async function createLogicAppWorkspace(context: IActionContext, options: mySubContext.workspacePath = workspaceFolder; if (fromPackage) { + // Validate that packagePath exists and has required properties + if (!options.packagePath || !options.packagePath.fsPath) { + throw new Error('packagePath is required and must have an fsPath property when creating workspace from package'); + } mySubContext.packagePath = options.packagePath.fsPath; await unzipLogicAppPackageIntoWorkspace(mySubContext); } else { @@ -353,7 +446,7 @@ export async function createLogicAppProject(context: IActionContext, options: an const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; mySubContext.logicAppName = options.logicAppName; mySubContext.projectPath = logicAppFolderPath; - mySubContext.projectType = webviewProjectContext.logicAppType as ProjectType; + mySubContext.projectType = webviewProjectContext.logicAppType; mySubContext.functionFolderName = options.functionFolderName; mySubContext.functionAppName = options.functionName; mySubContext.functionAppNamespace = options.functionNamespace; diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject_TEST_COVERAGE.md b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject_TEST_COVERAGE.md index 2d364918548..37bfbf742ae 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject_TEST_COVERAGE.md +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppProject_TEST_COVERAGE.md @@ -264,7 +264,7 @@ it('should populate IFunctionWizardContext with correct values', async () => { ```typescript mySubContext.logicAppName = options.logicAppName; mySubContext.projectPath = logicAppFolderPath; -mySubContext.projectType = webviewProjectContext.logicAppType as ProjectType; +mySubContext.projectType = webviewProjectContext.logicAppType; // ... more properties ``` 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 8f46d88af54..f28a90c5f7a 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 @@ -19,6 +19,9 @@ vi.mock('fs-extra', () => ({ vi.mock('../../../../utils/fs', () => ({ confirmEditJsonFile: vi.fn(), })); +vi.mock('../../../../utils/binaries', () => ({ + binariesExist: vi.fn().mockReturnValue(false), +})); describe('CreateLogicAppVSCodeContents', () => { const mockContext: IWebviewProjectContext = { @@ -48,6 +51,13 @@ describe('CreateLogicAppVSCodeContents', () => { isDevContainerProject: false, } as any; + const mockContextCodeful: IWebviewProjectContext = { + logicAppName: 'TestLogicAppCodeful', + logicAppType: ProjectType.codeful, + targetFramework: TargetFramework.NetFx, + isDevContainerProject: false, + } as any; + const logicAppFolderPath = path.join('test', 'workspace', 'TestLogicApp'); let extensionsJsonFileContent: string; @@ -267,6 +277,45 @@ describe('CreateLogicAppVSCodeContents', () => { }); }); + it('should create settings.json with codeful-specific settings for codeful project', async () => { + await CreateLogicAppVSCodeContentsModule.createLogicAppVsCodeContents(mockContextCodeful, 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('azureLogicAppsStandard.projectLanguage', 'C#'); + expect(settingsData).toHaveProperty('azureLogicAppsStandard.projectRuntime', '~4'); + expect(settingsData).toHaveProperty('debug.internalConsoleOptions', 'neverOpen'); + expect(settingsData).toHaveProperty('azureFunctions.suppressProject', true); + expect(settingsData).toHaveProperty('azureFunctions.deploySubpath'); + expect(settingsData).toHaveProperty('azureFunctions.preDeployTask', 'publish'); + expect(settingsData).toHaveProperty('azureFunctions.projectSubpath'); + expect(settingsData).toHaveProperty('omnisharp.enableMsBuildLoadProjectsOnDemand', false); + expect(settingsData).toHaveProperty('omnisharp.disableMSBuildDiagnosticWarning', true); + expect(settingsData).not.toHaveProperty('azureLogicAppsStandard.deploySubpath'); + }); + + it('should create launch.json with logicapp configuration for codeful projects', async () => { + await CreateLogicAppVSCodeContentsModule.createLogicAppVsCodeContents(mockContextCodeful, 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 TestLogicAppCodeful'), + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + isCodeless: false, + }); + expect(config).not.toHaveProperty('customCodeRuntime'); + }); + it('should copy extensions.json from template', async () => { await CreateLogicAppVSCodeContentsModule.createLogicAppVsCodeContents(mockContext, logicAppFolderPath); @@ -355,5 +404,18 @@ describe('CreateLogicAppVSCodeContents', () => { isCodeless: true, }); }); + + it('should return logicapp configuration with isCodeless false for codeful project', () => { + const config = CreateLogicAppVSCodeContentsModule.getDebugConfiguration('TestLogicApp', undefined, true); + + expect(config).toMatchObject({ + name: expect.stringContaining('Run/Debug logic app TestLogicApp'), + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + isCodeless: false, + }); + expect(config).not.toHaveProperty('customCodeRuntime'); + }); }); }); diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts index fbe33e7e202..57b7b9e7c0f 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, type Mock } from 'vitest'; +import { WorkflowType } from '@microsoft/vscode-extension-logic-apps'; import * as CreateLogicAppWorkspaceModule from '../CreateLogicAppWorkspace'; import * as vscode from 'vscode'; import * as fse from 'fs-extra'; @@ -7,6 +8,7 @@ import * as funcVersionModule from '../../../../utils/funcCoreTools/funcVersion' import * as gitModule from '../../../../utils/git'; import * as artifactsModule from '../../../../utils/codeless/artifacts'; import * as fsUtils from '../../../../utils/fs'; +import * as vscodeConfigModule from '../../../../utils/vsCodeConfig/settings'; import { CreateFunctionAppFiles } from '../CreateFunctionAppFiles'; import * as CreateLogicAppVSCodeContentsModule from '../CreateLogicAppVSCodeContents'; import * as cloudToLocalUtilsModule from '../../../../utils/cloudToLocalUtils'; @@ -57,6 +59,437 @@ vi.mock('fs-extra', () => ({ mkdirSync: vi.fn(), })); +vi.mock('path', () => ({ + join: vi.fn(), +})); + +vi.mock('../../../../utils/vsCodeConfig/settings', () => ({ + getGlobalSetting: vi.fn(), +})); + +// Import actual path for test setup (not affected by mock) +const actualPath = await vi.importActual('path'); +const actualFs = await vi.importActual('fs'); + +// Import the module after mocks are set up +const mockModule = await import('../CreateLogicAppWorkspace'); + +describe('CreateLogicAppWorkspace - Codeful Workflows', () => { + const testProjectPath = '/test/project'; + const testProjectName = 'TestProject'; + const testWorkflowName = 'TestWorkflow'; + const testLspDirectory = '/test/lsp'; + + describe('StatefulCodefulWorkflow template content', () => { + it('should avoid unsupported member-expression startup patterns while keeping MSN Weather', () => { + const templateContent = actualFs.readFileSync( + new URL('../../../../../assets/CodefulProjectTemplate/StatefulCodefulWorkflow', import.meta.url), + 'utf-8' + ); + + expect(templateContent).toContain('WorkflowActions.ManagedConnectors.Msnweather("msnweather").CurrentWeather'); + expect(templateContent).toContain('location: () => "98058"'); + expect(templateContent).toContain('WorkflowActions.BuiltIn.Response(responseBody: () => $"{getCurrentWeatherAction.Body}")'); + expect(templateContent).not.toContain('WorkflowActions.BuiltIn.Compose'); + expect(templateContent).not.toContain('builder.TriggerOutput.Body'); + }); + }); + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Restore path.join to use actual implementation + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); + + // Default getGlobalSetting behavior + vi.mocked(vscodeConfigModule.getGlobalSetting).mockReturnValue(testLspDirectory); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('createAgentCodefulWorkflowFile', () => { + it('should create workflow file with workflow name (not Program.cs) for first workflow', async () => { + const agentCodefulTemplate = 'namespace <%= logicAppNamespace %>\npublic static class <%= flowNameClass %> { }'; + const programTemplate = 'namespace <%= logicAppNamespace %>\nclass Program { <%= workflowBuilders %> }'; + + vi.mocked(fse.readFile).mockImplementation((filePath: string) => { + if (filePath.includes('AgentCodefulWorkflow')) { + return Promise.resolve(agentCodefulTemplate); + } + if (filePath.includes('ProgramFile')) { + return Promise.resolve(programTemplate); + } + if (filePath.includes('CodefulProj') || filePath.includes('nuget')) { + return Promise.resolve('mock content'); + } + return Promise.reject(new Error('Unexpected file read')); + }); + + vi.mocked(fse.pathExists).mockResolvedValue(false); // Program.cs doesn't exist yet + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + // Access the private function through module internals (for testing purposes) + // In real implementation, we'd export it or test through public API + const createAgentCodefulWorkflowFile = (mockModule as any).createAgentCodefulWorkflowFile; + + if (createAgentCodefulWorkflowFile) { + await createAgentCodefulWorkflowFile(testProjectPath, testProjectName, testWorkflowName, WorkflowType.agentCodeful); + + // Verify all 4 files were created for first workflow + expect(vi.mocked(fse.writeFile)).toHaveBeenCalledTimes(4); + + const writtenFiles = vi.mocked(fse.writeFile).mock.calls.map((call: any) => call[0]); + + // Verify workflow .cs file was created + expect(writtenFiles).toEqual(expect.arrayContaining([expect.stringContaining(`${testWorkflowName}.cs`)])); + + // Verify Program.cs was created + expect(writtenFiles).toEqual(expect.arrayContaining([expect.stringContaining('Program.cs')])); + + // Verify .csproj was created + expect(writtenFiles).toEqual(expect.arrayContaining([expect.stringContaining(`${testProjectName}.csproj`)])); + + // Verify nuget.config was created + expect(writtenFiles).toEqual(expect.arrayContaining([expect.stringContaining('nuget.config')])); + + // Verify workflow file has correct namespace + const workflowWriteCall = vi.mocked(fse.writeFile).mock.calls.find((call: any) => call[0].includes(`${testWorkflowName}.cs`)); + expect(workflowWriteCall).toBeDefined(); + expect(workflowWriteCall[1]).toContain(`namespace ${testProjectName}`); + expect(workflowWriteCall[1]).toContain(`${testWorkflowName}`); + + // Verify Program.cs contains the workflow and correct namespace + const programWriteCall = vi.mocked(fse.writeFile).mock.calls.find((call: any) => call[0].includes('Program.cs')); + expect(programWriteCall).toBeDefined(); + expect(programWriteCall[1]).toContain(`namespace ${testProjectName}`); + expect(programWriteCall[1]).toContain(`${testWorkflowName}.AddWorkflow()`); + } + }); + + it('should add workflow to existing Program.cs when creating second workflow', async () => { + const agentCodefulTemplate = 'namespace <%= logicAppNamespace %>\npublic static class <%= flowNameClass %> { }'; + const existingProgramContent = `namespace ${testProjectName}\nclass Program { + // Build all workflows + FirstWorkflow.AddWorkflow(); + host.Run(); + }`; + + vi.mocked(fse.readFile).mockImplementation((filePath: string) => { + if (filePath.includes('AgentCodefulWorkflow')) { + return Promise.resolve(agentCodefulTemplate); + } + if (filePath.includes('Program.cs')) { + return Promise.resolve(existingProgramContent); + } + if (filePath.includes('CodefulProj') || filePath.includes('nuget')) { + return Promise.resolve('mock content'); + } + return Promise.reject(new Error('Unexpected file read')); + }); + + vi.mocked(fse.pathExists).mockResolvedValue(true); // Program.cs exists + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const createAgentCodefulWorkflowFile = (mockModule as any).createAgentCodefulWorkflowFile; + + if (createAgentCodefulWorkflowFile) { + await createAgentCodefulWorkflowFile(testProjectPath, testProjectName, testWorkflowName, WorkflowType.agentCodeful); + + // Verify new workflow file was created with correct namespace + const workflowWriteCall = vi.mocked(fse.writeFile).mock.calls.find((call: any) => call[0].includes(`${testWorkflowName}.cs`)); + expect(workflowWriteCall).toBeDefined(); + expect(workflowWriteCall[1]).toContain(`namespace ${testProjectName}`); + + // Verify Program.cs was updated with new workflow but namespace unchanged + const programWriteCall = vi.mocked(fse.writeFile).mock.calls.find((call: any) => call[0].includes('Program.cs')); + expect(programWriteCall).toBeDefined(); + expect(programWriteCall[1]).toContain(`namespace ${testProjectName}`); + expect(programWriteCall[1]).toContain('FirstWorkflow.AddWorkflow()'); + expect(programWriteCall[1]).toContain(`${testWorkflowName}.AddWorkflow()`); + } + }); + + it('should NOT create .csproj or nuget.config when adding second workflow', async () => { + const agentCodefulTemplate = 'public static class <%= flowNameClass %> { }'; + const existingProgramContent = `class Program { + // Build all workflows + FirstWorkflow.AddWorkflow(); + host.Run(); + }`; + + vi.mocked(fse.readFile).mockImplementation((filePath: string) => { + if (filePath.includes('AgentCodefulWorkflow')) { + return Promise.resolve(agentCodefulTemplate); + } + if (filePath.includes('Program.cs')) { + return Promise.resolve(existingProgramContent); + } + return Promise.reject(new Error('Unexpected file read')); + }); + + vi.mocked(fse.pathExists).mockResolvedValue(true); // Program.cs exists + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const createAgentCodefulWorkflowFile = (mockModule as any).createAgentCodefulWorkflowFile; + + if (createAgentCodefulWorkflowFile) { + await createAgentCodefulWorkflowFile(testProjectPath, testProjectName, 'SecondWorkflow', WorkflowType.agentCodeful); + + // Verify only 2 files were written: workflow .cs and Program.cs + expect(vi.mocked(fse.writeFile)).toHaveBeenCalledTimes(2); + + // Verify the files written are correct + const writtenFiles = vi.mocked(fse.writeFile).mock.calls.map((call: any) => call[0]); + expect(writtenFiles).toEqual(expect.arrayContaining([expect.stringContaining('SecondWorkflow.cs')])); + expect(writtenFiles).toEqual(expect.arrayContaining([expect.stringContaining('Program.cs')])); + + // Verify .csproj and nuget.config were NOT written + expect(writtenFiles).not.toEqual(expect.arrayContaining([expect.stringContaining('.csproj')])); + expect(writtenFiles).not.toEqual(expect.arrayContaining([expect.stringContaining('nuget.config')])); + } + }); + + it('should create StatefulCodeful workflow file correctly with namespace', async () => { + const statefulTemplate = 'namespace <%= logicAppNamespace %>\npublic static class <%= flowNameClass %> { stateful content }'; + const programTemplate = 'namespace <%= logicAppNamespace %>\nclass Program { <%= workflowBuilders %> }'; + + vi.mocked(fse.readFile).mockImplementation((filePath: string) => { + if (filePath.includes('StatefulCodefulWorkflow')) { + return Promise.resolve(statefulTemplate); + } + if (filePath.includes('ProgramFile')) { + return Promise.resolve(programTemplate); + } + if (filePath.includes('CodefulProj') || filePath.includes('nuget')) { + return Promise.resolve('mock content'); + } + return Promise.reject(new Error('Unexpected file read')); + }); + + vi.mocked(fse.pathExists).mockResolvedValue(false); + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const createAgentCodefulWorkflowFile = (mockModule as any).createAgentCodefulWorkflowFile; + + if (createAgentCodefulWorkflowFile) { + await createAgentCodefulWorkflowFile(testProjectPath, testProjectName, testWorkflowName, WorkflowType.statefulCodeful); + + // Verify correct template was used + expect(vi.mocked(fse.readFile)).toHaveBeenCalledWith(expect.stringContaining('StatefulCodefulWorkflow'), 'utf-8'); + + // Verify workflow file contains stateful content and correct namespace + const workflowWriteCall = vi.mocked(fse.writeFile).mock.calls.find((call: any) => call[0].includes(`${testWorkflowName}.cs`)); + expect(workflowWriteCall).toBeDefined(); + expect(workflowWriteCall[1]).toContain('stateful content'); + expect(workflowWriteCall[1]).toContain(`namespace ${testProjectName}`); + + // Verify Program.cs has correct namespace + const programWriteCall = vi.mocked(fse.writeFile).mock.calls.find((call: any) => call[0].includes('Program.cs')); + expect(programWriteCall).toBeDefined(); + expect(programWriteCall[1]).toContain(`namespace ${testProjectName}`); + } + }); + + it('should create .csproj and nuget.config files', async () => { + const agentCodefulTemplate = 'public static class <%= flowName %> { }'; + const programTemplate = 'class Program { <%= workflowBuilders %> }'; + const projTemplate = 'test proj'; + const nugetTemplate = '<%= lspDirectory %>'; + + vi.mocked(fse.readFile).mockImplementation((filePath: string) => { + if (filePath.includes('AgentCodefulWorkflow')) { + return Promise.resolve(agentCodefulTemplate); + } + if (filePath.includes('ProgramFile')) { + return Promise.resolve(programTemplate); + } + if (filePath.includes('CodefulProj')) { + return Promise.resolve(projTemplate); + } + if (filePath.includes('nuget')) { + return Promise.resolve(nugetTemplate); + } + return Promise.reject(new Error('Unexpected file read')); + }); + + vi.mocked(fse.pathExists).mockResolvedValue(false); + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const createAgentCodefulWorkflowFile = (mockModule as any).createAgentCodefulWorkflowFile; + + if (createAgentCodefulWorkflowFile) { + await createAgentCodefulWorkflowFile(testProjectPath, testProjectName, testWorkflowName, WorkflowType.agentCodeful); + + // Verify .csproj file was created + expect(vi.mocked(fse.writeFile)).toHaveBeenCalledWith( + expect.stringContaining(`${testProjectName}.csproj`), + expect.stringContaining('test proj') + ); + + // Verify nuget.config was created + const nugetCall = vi.mocked(fse.writeFile).mock.calls.find((call: any) => call[0].includes('nuget.config')); + expect(nugetCall).toBeDefined(); + expect(nugetCall[0]).toContain('nuget.config'); + } + }); + }); + + describe('addWorkflowToProgram', () => { + it('should insert workflow before host.Run() call', async () => { + const programContent = `class Program { + public static void Main() { + // Build all workflows + FirstWorkflow.AddWorkflow(); + + host.Run(); + } + }`; + + vi.mocked(fse.readFile).mockResolvedValue(programContent); + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const addWorkflowToProgram = (mockModule as any).addWorkflowToProgram; + + if (addWorkflowToProgram) { + await addWorkflowToProgram('/test/Program.cs', 'SecondWorkflow'); + + expect(vi.mocked(fse.writeFile)).toHaveBeenCalledOnce(); + const updatedContent = vi.mocked(fse.writeFile).mock.calls[0][1]; + + // Verify new workflow was added + expect(updatedContent).toContain('FirstWorkflow.AddWorkflow()'); + expect(updatedContent).toContain('SecondWorkflow.AddWorkflow()'); + expect(updatedContent).toContain('host.Run()'); + + // Verify SecondWorkflow comes after FirstWorkflow but before host.Run() + const firstIndex = updatedContent.indexOf('FirstWorkflow.AddWorkflow()'); + const secondIndex = updatedContent.indexOf('SecondWorkflow.AddWorkflow()'); + const hostRunIndex = updatedContent.indexOf('host.Run()'); + + expect(secondIndex).toBeGreaterThan(firstIndex); + expect(hostRunIndex).toBeGreaterThan(secondIndex); + } + }); + + it('should handle Program.cs without "Build all workflows" comment', async () => { + const programContent = `class Program { + public static void Main() { + host.Run(); + } + }`; + + vi.mocked(fse.readFile).mockResolvedValue(programContent); + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const addWorkflowToProgram = (mockModule as any).addWorkflowToProgram; + + if (addWorkflowToProgram) { + await addWorkflowToProgram('/test/Program.cs', 'NewWorkflow'); + + expect(vi.mocked(fse.writeFile)).toHaveBeenCalledOnce(); + const updatedContent = vi.mocked(fse.writeFile).mock.calls[0][1]; + + // Verify workflow was added before host.Run() + expect(updatedContent).toContain('NewWorkflow.AddWorkflow()'); + expect(updatedContent).toContain('host.Run()'); + + const workflowIndex = updatedContent.indexOf('NewWorkflow.AddWorkflow()'); + const hostRunIndex = updatedContent.indexOf('host.Run()'); + expect(hostRunIndex).toBeGreaterThan(workflowIndex); + } + }); + + it('should preserve existing formatting and indentation', async () => { + const programContent = `class Program { + public static void Main() { + // Build all workflows + FirstWorkflow.AddWorkflow(); + + host.Run(); + } + }`; + + vi.mocked(fse.readFile).mockResolvedValue(programContent); + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const addWorkflowToProgram = (mockModule as any).addWorkflowToProgram; + + if (addWorkflowToProgram) { + await addWorkflowToProgram('/test/Program.cs', 'ThirdWorkflow'); + + const updatedContent = vi.mocked(fse.writeFile).mock.calls[0][1]; + + // Verify indentation is preserved + expect(updatedContent).toMatch(/\s{12}FirstWorkflow\.AddWorkflow\(\);/); + expect(updatedContent).toMatch(/\s{12}ThirdWorkflow\.AddWorkflow\(\);/); + } + }); + + it('should handle multiple existing workflows', async () => { + const programContent = `class Program { + // Build all workflows + WorkflowOne.AddWorkflow(); + WorkflowTwo.AddWorkflow(); + WorkflowThree.AddWorkflow(); + + host.Run(); + }`; + + vi.mocked(fse.readFile).mockResolvedValue(programContent); + vi.mocked(fse.writeFile).mockResolvedValue(undefined); + + const addWorkflowToProgram = (mockModule as any).addWorkflowToProgram; + + if (addWorkflowToProgram) { + await addWorkflowToProgram('/test/Program.cs', 'WorkflowFour'); + + const updatedContent = vi.mocked(fse.writeFile).mock.calls[0][1]; + + // Verify all workflows are present + expect(updatedContent).toContain('WorkflowOne.AddWorkflow()'); + expect(updatedContent).toContain('WorkflowTwo.AddWorkflow()'); + expect(updatedContent).toContain('WorkflowThree.AddWorkflow()'); + expect(updatedContent).toContain('WorkflowFour.AddWorkflow()'); + expect(updatedContent).toContain('host.Run()'); + } + }); + }); + + describe('createAgentCodefulWorkflowFile - Error Handling', () => { + it('should handle template file read errors', async () => { + vi.mocked(fse.readFile).mockRejectedValue(new Error('Template file not found')); + vi.mocked(fse.pathExists).mockResolvedValue(false); + + const createAgentCodefulWorkflowFile = (mockModule as any).createAgentCodefulWorkflowFile; + + if (createAgentCodefulWorkflowFile) { + await expect( + createAgentCodefulWorkflowFile(testProjectPath, testProjectName, testWorkflowName, WorkflowType.agentCodeful) + ).rejects.toThrow('Template file not found'); + } + }); + + it('should handle file write errors', async () => { + const agentCodefulTemplate = 'public static class <%= flowName %> { }'; + + vi.mocked(fse.readFile).mockResolvedValue(agentCodefulTemplate); + vi.mocked(fse.pathExists).mockResolvedValue(false); + vi.mocked(fse.writeFile).mockRejectedValue(new Error('Permission denied')); + + const createAgentCodefulWorkflowFile = (mockModule as any).createAgentCodefulWorkflowFile; + + if (createAgentCodefulWorkflowFile) { + await expect( + createAgentCodefulWorkflowFile(testProjectPath, testProjectName, testWorkflowName, WorkflowType.agentCodeful) + ).rejects.toThrow('Permission denied'); + } + }); + }); +}); + describe('getHostContent', () => { it('should return host.json with correct structure', async () => { const hostJson = await CreateLogicAppWorkspaceModule.getHostContent(); @@ -113,7 +546,7 @@ describe('createLogicAppWorkspace', () => { // Mock options for standard Logic App (no custom code) const mockOptionsLogicApp: IWebviewProjectContext = { - workspaceProjectPath: { fsPath: path.join('test', 'workspace') } as vscode.Uri, + workspaceProjectPath: { fsPath: actualPath.join('test', 'workspace') } as vscode.Uri, workspaceName: 'TestWorkspace', logicAppName: 'TestLogicApp', logicAppType: ProjectType.logicApp, @@ -123,12 +556,12 @@ describe('createLogicAppWorkspace', () => { functionName: 'TestFunction', functionNamespace: 'TestNamespace', targetFramework: 'net6.0', - packagePath: { fsPath: path.join('test', 'package.zip') } as vscode.Uri, + packagePath: { fsPath: actualPath.join('test', 'package.zip') } as vscode.Uri, } as any; // Mock options for Custom Code Logic App const mockOptionsCustomCode: IWebviewProjectContext = { - workspaceProjectPath: { fsPath: path.join('test', 'workspace') } as vscode.Uri, + workspaceProjectPath: { fsPath: actualPath.join('test', 'workspace') } as vscode.Uri, workspaceName: 'TestWorkspaceCustomCode', logicAppName: 'TestLogicAppCustomCode', logicAppType: ProjectType.customCode, @@ -138,12 +571,12 @@ describe('createLogicAppWorkspace', () => { functionName: 'CustomFunction', functionNamespace: 'CustomNamespace', targetFramework: 'net8.0', - packagePath: { fsPath: path.join('test', 'package-custom.zip') } as vscode.Uri, + packagePath: { fsPath: actualPath.join('test', 'package-custom.zip') } as vscode.Uri, } as any; // Mock options for Rules Engine Logic App const mockOptionsRulesEngine: IWebviewProjectContext = { - workspaceProjectPath: { fsPath: path.join('test', 'workspace') } as vscode.Uri, + workspaceProjectPath: { fsPath: actualPath.join('test', 'workspace') } as vscode.Uri, workspaceName: 'TestWorkspaceRules', logicAppName: 'TestLogicAppRules', logicAppType: ProjectType.rulesEngine, @@ -153,16 +586,19 @@ describe('createLogicAppWorkspace', () => { functionName: 'RulesFunction', functionNamespace: 'RulesNamespace', targetFramework: 'net6.0', - packagePath: { fsPath: path.join('test', 'package-rules.zip') } as vscode.Uri, + packagePath: { fsPath: actualPath.join('test', 'package-rules.zip') } as vscode.Uri, } as any; - const workspaceFolder = path.join('test', 'workspace', 'TestWorkspace'); - const logicAppFolderPath = path.join(workspaceFolder, 'TestLogicApp'); - const workspaceFilePath = path.join(workspaceFolder, 'TestWorkspace.code-workspace'); + const workspaceFolder = actualPath.join('test', 'workspace', 'TestWorkspace'); + const logicAppFolderPath = actualPath.join(workspaceFolder, 'TestLogicApp'); + const workspaceFilePath = actualPath.join(workspaceFolder, 'TestWorkspace.code-workspace'); beforeEach(() => { vi.resetAllMocks(); + // Restore path.join to use actual implementation + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); + // Mock vscode functions vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(undefined); vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined); @@ -468,7 +904,7 @@ describe('createLogicAppWorkspace', () => { describe('updateWorkspaceFile', () => { const mockOptionsForUpdate: IWebviewProjectContext = { - workspaceFilePath: path.join('test', 'workspace', 'TestWorkspace.code-workspace'), + workspaceFilePath: actualPath.join('test', 'workspace', 'TestWorkspace.code-workspace'), logicAppName: 'TestLogicApp', logicAppType: ProjectType.logicApp, functionFolderName: 'Functions', @@ -476,7 +912,7 @@ describe('updateWorkspaceFile', () => { } as any; const mockOptionsCustomCode: IWebviewProjectContext = { - workspaceFilePath: path.join('test', 'workspace', 'TestWorkspaceCustomCode.code-workspace'), + workspaceFilePath: actualPath.join('test', 'workspace', 'TestWorkspaceCustomCode.code-workspace'), logicAppName: 'TestLogicAppCustomCode', logicAppType: ProjectType.customCode, functionFolderName: 'CustomCodeFunctions', @@ -484,7 +920,7 @@ describe('updateWorkspaceFile', () => { } as any; const mockOptionsRulesEngine: IWebviewProjectContext = { - workspaceFilePath: path.join('test', 'workspace', 'TestWorkspaceRules.code-workspace'), + workspaceFilePath: actualPath.join('test', 'workspace', 'TestWorkspaceRules.code-workspace'), logicAppName: 'TestLogicAppRules', logicAppType: ProjectType.rulesEngine, functionFolderName: 'RulesFunctions', @@ -493,6 +929,7 @@ describe('updateWorkspaceFile', () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); vi.mocked(fse.readJson).mockResolvedValue({ folders: [] }); vi.mocked(fse.writeJSON).mockResolvedValue(undefined); }); @@ -639,13 +1076,14 @@ describe('createWorkspaceStructure - Testing Actual Implementation', () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); vi.mocked(fse.ensureDir).mockResolvedValue(undefined); vi.mocked(fse.writeJSON).mockResolvedValue(undefined); }); it('should create workspace folder and file for standard logic app', async () => { const mockOptions: IWebviewProjectContext = { - workspaceProjectPath: { fsPath: path.join('test', 'workspace') } as vscode.Uri, + workspaceProjectPath: { fsPath: actualPath.join('test', 'workspace') } as vscode.Uri, workspaceName: 'MyWorkspace', logicAppName: 'MyLogicApp', logicAppType: ProjectType.logicApp, @@ -654,7 +1092,7 @@ describe('createWorkspaceStructure - Testing Actual Implementation', () => { await CreateLogicAppWorkspaceModule.createWorkspaceStructure(mockOptions); // Verify workspace folder creation - normalize path for cross-platform compatibility - expect(fse.ensureDir).toHaveBeenCalledWith(path.join('test', 'workspace', 'MyWorkspace')); + expect(fse.ensureDir).toHaveBeenCalledWith(actualPath.join('test', 'workspace', 'MyWorkspace')); // Verify workspace file structure - actual function logic const writeCall = vi.mocked(fse.writeJSON).mock.calls[0]; @@ -666,7 +1104,7 @@ describe('createWorkspaceStructure - Testing Actual Implementation', () => { it('should include functions folder for non-standard logic app types', async () => { const mockOptions: IWebviewProjectContext = { - workspaceProjectPath: { fsPath: path.join('test', 'workspace') } as vscode.Uri, + workspaceProjectPath: { fsPath: actualPath.join('test', 'workspace') } as vscode.Uri, workspaceName: 'MyWorkspace', logicAppName: 'MyLogicApp', logicAppType: ProjectType.customCode, @@ -706,10 +1144,11 @@ describe('createLocalConfigurationFiles', () => { workflowName: 'TestWorkflow', } as any; - const logicAppFolderPath = path.join('test', 'workspace', 'TestLogicApp'); + const logicAppFolderPath = actualPath.join('test', 'workspace', 'TestLogicApp'); beforeEach(() => { vi.resetAllMocks(); + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); vi.mocked(fse.writeFile).mockResolvedValue(undefined); vi.mocked(fse.copyFile).mockResolvedValue(undefined); }); @@ -924,11 +1363,12 @@ describe('createArtifactsFolder', () => { }); const mockContext: any = { - projectPath: path.join('test', 'workspace', 'TestLogicApp'), + projectPath: actualPath.join('test', 'workspace', 'TestLogicApp'), projectType: ProjectType.logicApp, }; beforeEach(() => { + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); vi.mocked(fse.mkdirSync).mockReturnValue(undefined); }); @@ -975,19 +1415,20 @@ describe('createRulesFiles - Testing Actual Implementation', () => { // Only file system operations are mocked, conditional logic and template processing is real const mockContextRulesEngine: any = { - projectPath: path.join('test', 'workspace', 'TestLogicApp'), + projectPath: actualPath.join('test', 'workspace', 'TestLogicApp'), projectType: ProjectType.rulesEngine, functionAppName: 'TestRulesApp', }; const mockContextLogicApp: any = { - projectPath: path.join('test', 'workspace', 'TestLogicApp'), + projectPath: actualPath.join('test', 'workspace', 'TestLogicApp'), projectType: ProjectType.logicApp, functionAppName: 'TestLogicApp', }; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); vi.mocked(fse.readFile).mockResolvedValue('Sample content with <%= methodName %>' as any); vi.mocked(fse.writeFile).mockResolvedValue(undefined); }); @@ -1070,11 +1511,12 @@ describe('createLibFolder - Testing Actual Implementation', () => { // Only file system operations are mocked, directory structure logic is real const mockContext: any = { - projectPath: path.join('test', 'workspace', 'TestLogicApp'), + projectPath: actualPath.join('test', 'workspace', 'TestLogicApp'), }; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(path.join).mockImplementation((...args: string[]) => actualPath.join(...args)); vi.mocked(fse.mkdirSync).mockReturnValue(undefined); }); 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..7c620c2ecd2 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 @@ -92,7 +92,6 @@ export class CodefulWorkflowCreateStep extends WorkflowCreateStepBase debugConfig.request !== 'attach' || debugConfig.processId !== `\${command:${extensionCommand.pickProcess}}` ), @@ -174,6 +173,21 @@ export class CodefulWorkflowCreateStep extends WorkflowCreateStepBase { + try { + // Prevent OmniSharp from generating solution files that can cause build failures + const { updateWorkspaceSetting } = await import('../../../../utils/vsCodeConfig/settings'); + await updateWorkspaceSetting('enableMsBuildLoadProjectsOnDemand', false, context.projectPath, 'omnisharp'); + await updateWorkspaceSetting('disableMSBuildDiagnosticWarning', true, context.projectPath, 'omnisharp'); + } catch (error) { + // Log but don't fail if OmniSharp settings can't be updated + console.warn('Failed to configure OmniSharp settings:', error); + } } private addNugetConfig(projectPath: string) { diff --git a/apps/vs-code-designer/src/app/commands/createWorkflow/createLogicAppWorkflow.ts b/apps/vs-code-designer/src/app/commands/createWorkflow/createLogicAppWorkflow.ts index e67ec0a7ccd..f4fc3bc16a0 100644 --- a/apps/vs-code-designer/src/app/commands/createWorkflow/createLogicAppWorkflow.ts +++ b/apps/vs-code-designer/src/app/commands/createWorkflow/createLogicAppWorkflow.ts @@ -1,10 +1,11 @@ import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IFunctionWizardContext, IWebviewProjectContext, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { type IFunctionWizardContext, type IWebviewProjectContext, ProjectType } from '@microsoft/vscode-extension-logic-apps'; import { addLocalFuncTelemetry } from '../../utils/funcCoreTools/funcVersion'; import * as fse from 'fs-extra'; import * as vscode from 'vscode'; import { createLogicAppAndWorkflow } from '../createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace'; import { localize } from '../../../localize'; +import { isCodefulProject } from '../../utils/codeful'; export async function createLogicAppWorkflow(context: IActionContext, options: any, logicAppFolderPath: string) { addLocalFuncTelemetry(context); @@ -15,6 +16,14 @@ export async function createLogicAppWorkflow(context: IActionContext, options: a // Check if it's actually a Logic App project } + // If logicAppType is not set in options, check if this is a codeful project + if (!webviewProjectContext.logicAppType) { + const isCodeful = await isCodefulProject(logicAppFolderPath); + if (isCodeful) { + webviewProjectContext.logicAppType = ProjectType.codeful; + } + } + // Check if we're in a workspace and get the workspace folder if (vscode.workspace.workspaceFile) { // Get the directory containing the .code-workspace file @@ -25,7 +34,7 @@ export async function createLogicAppWorkflow(context: IActionContext, options: a const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; mySubContext.logicAppName = options.logicAppName; mySubContext.projectPath = logicAppFolderPath; - mySubContext.projectType = webviewProjectContext.logicAppType as ProjectType; + mySubContext.projectType = webviewProjectContext.logicAppType; mySubContext.functionFolderName = options.functionFolderName; mySubContext.targetFramework = options.targetFramework; diff --git a/apps/vs-code-designer/src/app/commands/createWorkflow/createWorkflow.ts b/apps/vs-code-designer/src/app/commands/createWorkflow/createWorkflow.ts index 9f117ac2ee2..c8313ef68b0 100644 --- a/apps/vs-code-designer/src/app/commands/createWorkflow/createWorkflow.ts +++ b/apps/vs-code-designer/src/app/commands/createWorkflow/createWorkflow.ts @@ -11,11 +11,15 @@ import { createLogicAppWorkflow } from './createLogicAppWorkflow'; import { getWorkspaceRoot } from '../../utils/workspace'; import { isCodefulProject } from '../../utils/codeful'; import { tryGetLogicAppProjectRoot } from '../../utils/verifyIsProject'; +import * as path from 'path'; export const createWorkflow = async (context: IActionContext) => { const workspaceFolderPath = await getWorkspaceRoot(context); const projectRoot = await tryGetLogicAppProjectRoot(context, workspaceFolderPath, true); const isCodeful = await isCodefulProject(projectRoot); + const logicAppName = path.basename(projectRoot); + + const logicAppType = isCodeful ? ProjectType.codeful : ''; await createWorkspaceWebviewCommandHandler({ panelName: localize('createWorkflow', 'Create workflow'), @@ -26,7 +30,8 @@ export const createWorkflow = async (context: IActionContext) => { await createLogicAppWorkflow(context, data, projectRoot); }, extraInitializeData: { - logicAppType: isCodeful ? ProjectType.agentCodeful : '', + logicAppType, + logicAppName, }, }); }; diff --git a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts index a6a5b1ddf16..e86ac9b77a5 100644 --- a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts +++ b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts @@ -33,8 +33,8 @@ export async function debugLogicApp( }; await vscode.debug.startDebugging(workspaceFolder, logicAppLaunchConfig); + let functionLaunchConfig: vscode.DebugConfiguration; if (debugConfig.customCodeRuntime) { - let functionLaunchConfig: vscode.DebugConfiguration; if (debugConfig.customCodeRuntime === 'coreclr') { const customCodeNetHostProcessId = await pickCustomCodeNetHostProcessInternal( context, @@ -62,10 +62,23 @@ export async function debugLogicApp( context.telemetry.properties.errorMessage = errorMessage.replace('{0}', debugConfig.customCodeRuntime); throw new Error(localize('unsupportedCustomCodeRuntime', errorMessage, debugConfig.customCodeRuntime)); } + } else if (debugConfig.isCodeless === false) { + const codefulNetHostProcessId = await pickCustomCodeNetHostProcessInternal( + context, + workspaceFolder, + projectPath, + false /* isCodeless */ + ); + functionLaunchConfig = { + name: localize('attachToCustomCodeFunc', 'Debug local function'), + type: debugConfig.funcRuntime, + request: 'attach', + processId: codefulNetHostProcessId, + }; + } - if (functionLaunchConfig?.processId) { - await vscode.debug.startDebugging(workspaceFolder, functionLaunchConfig); - } - context.telemetry.properties.result = 'Succeeded'; + if (functionLaunchConfig?.processId) { + await vscode.debug.startDebugging(workspaceFolder, functionLaunchConfig); } + context.telemetry.properties.result = 'Succeeded'; } diff --git a/apps/vs-code-designer/src/app/commands/deploy/deploy.ts b/apps/vs-code-designer/src/app/commands/deploy/deploy.ts index 987644b37d5..0f05095315a 100644 --- a/apps/vs-code-designer/src/app/commands/deploy/deploy.ts +++ b/apps/vs-code-designer/src/app/commands/deploy/deploy.ts @@ -22,6 +22,8 @@ import { useSmbDeployment, libDirectory, customDirectory, + workflowAuthenticationMethodKey, + ProjectDirectoryPathKey, } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; @@ -60,6 +62,7 @@ import { uploadAppSettings } from '../appSettings/uploadAppSettings'; import { isNullOrUndefined, resolveConnectionsReferences } from '@microsoft/logic-apps-shared'; import { tryBuildCustomCodeFunctionsProject } from '../buildCustomCodeFunctionsProject'; import { publishCodefulProject } from '../publishCodefulProject'; +import { isCodefulProject } from '../../utils/codeful'; export async function deployProductionSlot( context: IActionContext, @@ -86,21 +89,35 @@ async function deploy( addLocalFuncTelemetry(actionContext); let deployProjectPathForWorkflowApp: string | undefined; - const settingsToExclude: string[] = [webhookRedirectHostUri, azureWebJobsStorageKey]; + const settingsToExclude: string[] = [ + webhookRedirectHostUri, + azureWebJobsStorageKey, + ProjectDirectoryPathKey, + workflowAuthenticationMethodKey, + 'REMOTEDEBUGGINGVERSION', + ]; const deployPaths = await getDeployFsPath(actionContext, target); const context: IDeployContext = Object.assign(actionContext, deployPaths, { defaultAppSetting: 'defaultFunctionAppToDeploy' }); const { originalDeployFsPath, effectiveDeployFsPath, workspaceFolder } = deployPaths; if (!isNullOrUndefined(workspaceFolder)) { const logicAppNode = workspaceFolder.uri; - const customFolderExists = await fse.pathExists(path.join(logicAppNode.fsPath, libDirectory, customDirectory)); - if (!customFolderExists) { - await tryBuildCustomCodeFunctionsProject(actionContext, logicAppNode); - } - const agentIsolatedExists = await fse.pathExists(path.join(logicAppNode.fsPath, libDirectory, 'codeful')); - if (!agentIsolatedExists) { + // Check if this is a codeful project and build/publish if needed + const isCodeful = await isCodefulProject(logicAppNode.fsPath); + if (isCodeful) { + context.telemetry.properties.isCodefulProject = 'true'; + ext.outputChannel.appendLog(localize('buildingCodefulProject', 'Building and publishing codeful Logic App project...')); + + // Build the codeful project await publishCodefulProject(actionContext, logicAppNode); + ext.outputChannel.appendLog(localize('codefulProjectPublished', 'Codeful project built and published successfully.')); + } else { + // For codeless projects, build custom code functions if they exist + const customFolderExists = await fse.pathExists(path.join(logicAppNode.fsPath, libDirectory, customDirectory)); + if (customFolderExists) { + await tryBuildCustomCodeFunctionsProject(actionContext, logicAppNode); + } } } diff --git a/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts b/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts index 48776ccce63..fcf0cf465be 100644 --- a/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts +++ b/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts @@ -161,7 +161,7 @@ export async function pickCustomCodeNetFxWorkerProcessInternal( export async function pickCustomCodeWorkerChildProcess( taskInfo: IRunningFuncTask, isNetFxWorker: boolean, - isCodeless = false + isCodeless = true ): Promise { const funcPid = Number(await pickChildProcess(taskInfo)); if (!funcPid) { @@ -186,6 +186,12 @@ export async function pickCustomCodeWorkerChildProcess( break; } } + } else if (isCodeless === false) { + // NOTE(aeldridge): Codeful .NET host is a child of the child func process, so need to look one level deeper + const childrenOfChild = + process.platform === Platform.windows ? await getWindowsChildren(Number(child.pid)) : await getUnixChildren(Number(child.pid)); + + child = childrenOfChild.reverse().find((c) => childRegex.test(c.command || '')); } return child ? child.pid.toString() : undefined; } diff --git a/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts b/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts index 55ad777d398..ed235354074 100644 --- a/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts +++ b/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts @@ -34,6 +34,7 @@ import parser from 'yargs-parser'; import { tryBuildCustomCodeFunctionsProject } from './buildCustomCodeFunctionsProject'; import { getProjFiles } from '../utils/dotnet/dotnet'; import { delay } from '../utils/delay'; +import { getChildProcessesWithScript } from '../utils/findChildProcess/findChildProcess'; type OSAgnosticProcess = { command: string | undefined; pid: number | string }; type ActualUnixPS = unixPsTree.PS & { COMM?: string }; @@ -86,9 +87,10 @@ export async function pickFuncProcessInternal( throw new UserCancelledError('preDebugValidate'); } - await tryBuildCustomCodeFunctionsProject(context, workspaceFolder.uri); - + // Stop previous func host BEFORE building/publishing to avoid file locks await waitForPrevFuncTaskToStop(workspaceFolder); + + await tryBuildCustomCodeFunctionsProject(context, workspaceFolder.uri); const projectFiles = await getProjFiles(context, ProjectLanguage.CSharp, projectPath); const isBundleProject: boolean = projectFiles.length > 0 ? false : true; @@ -296,10 +298,15 @@ async function startFuncTask( } export async function findChildProcess(processId: number): Promise { - const children: OSAgnosticProcess[] = - process.platform === Platform.windows ? await getWindowsChildren(processId) : await getUnixChildren(processId); + if (process.platform === Platform.windows) { + const children = await getChildProcessesWithScript(processId); + const child = [...children].reverse().find((c) => /func(\.exe|)$/i.test(c.name || '')); + return child ? child.processId.toString() : undefined; + } + + const children: OSAgnosticProcess[] = await getUnixChildren(processId); const child: OSAgnosticProcess | undefined = children.reverse().find((c) => /(dotnet|func)(\.exe|)$/i.test(c.command || '')); - return child ? child.pid.toString() : String(processId); + return child ? child.pid.toString() : undefined; } /** diff --git a/apps/vs-code-designer/src/app/commands/registerCommands.ts b/apps/vs-code-designer/src/app/commands/registerCommands.ts index e177cc0b917..e15e53b52ff 100644 --- a/apps/vs-code-designer/src/app/commands/registerCommands.ts +++ b/apps/vs-code-designer/src/app/commands/registerCommands.ts @@ -79,12 +79,10 @@ import { reportAnIssue } from '../utils/reportAnIssue'; import { localize } from '../../localize'; import { guid } from '@microsoft/logic-apps-shared'; import { openLanguageServerConnectionView } from './workflows/languageServer/connectionView'; -import { openRunHistory } from './workflows/openRunHistory'; import { enableDevContainer } from './enableDevContainer/enableDevContainer'; export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping(extensionCommand.openDesigner, openDesigner); - registerCommandWithTreeNodeUnwrapping(extensionCommand.openRunHistory, openRunHistory); registerCommandWithTreeNodeUnwrapping(extensionCommand.openFile, (context: IActionContext, node: FileTreeItem) => executeOnFunctions(openFile, context, context, node) ); diff --git a/apps/vs-code-designer/src/app/commands/shared/workspaceWebviewCommandHandler.ts b/apps/vs-code-designer/src/app/commands/shared/workspaceWebviewCommandHandler.ts index b0222a918d1..177e3a0d78e 100644 --- a/apps/vs-code-designer/src/app/commands/shared/workspaceWebviewCommandHandler.ts +++ b/apps/vs-code-designer/src/app/commands/shared/workspaceWebviewCommandHandler.ts @@ -83,6 +83,25 @@ export async function createWorkspaceWebviewCommandHandler(config: WorkspaceWebv }, [createCommand]: async (message: any) => { + // Log diagnostic data sent from webview if available + if (message?._diagnostics) { + ext.outputChannel.appendLog(`[CreateWorkspace Diagnostics] ${JSON.stringify(message._diagnostics, null, 2)}`); + } + + // Log received message data for debugging + const receivedDiagnostics = { + command: createCommand, + timestamp: new Date().toISOString(), + hasMessageData: !!message?.data, + messageDataType: typeof message?.data, + messageDataKeys: message?.data ? Object.keys(message.data) : [], + workspaceProjectPath: message?.data?.workspaceProjectPath, + workspaceName: message?.data?.workspaceName, + logicAppName: message?.data?.logicAppName, + logicAppType: message?.data?.logicAppType, + }; + ext.outputChannel.appendLog(`[CreateWorkspace Handler] ${JSON.stringify(receivedDiagnostics, null, 2)}`); + await callWithTelemetryAndErrorHandling(panelName.replace(/\s+/g, ''), async (activateContext: IActionContext) => { await createHandler(activateContext, message.data); }); diff --git a/apps/vs-code-designer/src/app/commands/workflows/enableAzureConnectors.ts b/apps/vs-code-designer/src/app/commands/workflows/enableAzureConnectors.ts index 83535637c5d..21c90a8413a 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/enableAzureConnectors.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/enableAzureConnectors.ts @@ -12,6 +12,7 @@ import type { ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps' import * as path from 'path'; import * as vscode from 'vscode'; import { getLogicAppProjectRoot } from '../../utils/codeless/connection'; +import { getAzureConnectorDetailsForLocalProject, invalidateAzureDetailsCache } from '../../utils/codeless/common'; import { getWorkspaceFolder } from '../../utils/workspace'; import { isString } from '@microsoft/logic-apps-shared'; @@ -36,6 +37,10 @@ export async function enableAzureConnectors(context: IActionContext, node: vscod await wizard.prompt(); await wizard.execute(); if (connectorsContext.enabled) { + // Invalidate stale cache and refetch Azure details with fresh auth token + invalidateAzureDetailsCache(projectPath); + getAzureConnectorDetailsForLocalProject(context, projectPath).catch(() => {}); + vscode.window.showInformationMessage( localize( 'logicapp.azureConnectorsEnabledForProject', diff --git a/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionView.test.ts b/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionView.test.ts new file mode 100644 index 00000000000..b859998e79d --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionView.test.ts @@ -0,0 +1,359 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Create mock functions +const mockGetConnectionsFromFile = vi.fn(); + +// Mock dependencies +vi.mock('../../../../utils/codeless/connection', () => ({ + getConnectionsFromFile: mockGetConnectionsFromFile, +})); + +// Import the module after mocks are set up +const mockModule = await import('../connectionView'); + +describe('ConnectionView - getConnectionKeyFromConnectionsJson', () => { + const testProjectPath = '/test/project'; + const testConnectionName = 'msnweather-7'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should return connection name when projectPath is undefined', async () => { + const getConnectionKeyFromConnectionsJson = (mockModule as any).ConnectionView?.prototype?.getConnectionKeyFromConnectionsJson; + + if (getConnectionKeyFromConnectionsJson) { + const mockContext = { workflowFilePath: '/test/workflow.json' }; + const result = await getConnectionKeyFromConnectionsJson.call(mockContext, undefined, testConnectionName); + + expect(result).toBe(testConnectionName); + expect(mockGetConnectionsFromFile).not.toHaveBeenCalled(); + } + }); + + it('should return connection name when connections file is not found', async () => { + mockGetConnectionsFromFile.mockResolvedValue(null); + + const getConnectionKeyFromConnectionsJson = (mockModule as any).ConnectionView?.prototype?.getConnectionKeyFromConnectionsJson; + + if (getConnectionKeyFromConnectionsJson) { + const mockContext = { workflowFilePath: '/test/workflow.json' }; + const result = await getConnectionKeyFromConnectionsJson.call(mockContext, testProjectPath, testConnectionName); + + expect(result).toBe(testConnectionName); + } + }); + + it('should find connection key by matching connection.id last part', async () => { + const connectionsJson = JSON.stringify({ + managedApiConnections: { + 'msnweather-1': { + connection: { + id: '/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.Web/connections/msnweather-7', + }, + connectionRuntimeUrl: 'https://example.com', + authentication: { type: 'ManagedServiceIdentity' }, + }, + 'office365-2': { + connection: { + id: '/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.Web/connections/office365-5', + }, + connectionRuntimeUrl: 'https://example.com', + authentication: { type: 'ManagedServiceIdentity' }, + }, + }, + }); + + mockGetConnectionsFromFile.mockResolvedValue(connectionsJson); + + const getConnectionKeyFromConnectionsJson = (mockModule as any).ConnectionView?.prototype?.getConnectionKeyFromConnectionsJson; + + if (getConnectionKeyFromConnectionsJson) { + const mockContext = { workflowFilePath: '/test/workflow.json', context: {} }; + const result = await getConnectionKeyFromConnectionsJson.call(mockContext, testProjectPath, 'msnweather-7'); + + expect(result).toBe('msnweather-1'); + } + }); + + it('should return connection name as fallback when no match found', async () => { + const connectionsJson = JSON.stringify({ + managedApiConnections: { + 'office365-2': { + connection: { + id: '/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.Web/connections/office365-5', + }, + }, + }, + }); + + mockGetConnectionsFromFile.mockResolvedValue(connectionsJson); + + const getConnectionKeyFromConnectionsJson = (mockModule as any).ConnectionView?.prototype?.getConnectionKeyFromConnectionsJson; + + if (getConnectionKeyFromConnectionsJson) { + const mockContext = { workflowFilePath: '/test/workflow.json', context: {} }; + const result = await getConnectionKeyFromConnectionsJson.call(mockContext, testProjectPath, 'nonexistent-connection'); + + expect(result).toBe('nonexistent-connection'); + } + }); + + it('should handle JSON parse errors gracefully', async () => { + mockGetConnectionsFromFile.mockResolvedValue('invalid json {{{'); + + const getConnectionKeyFromConnectionsJson = (mockModule as any).ConnectionView?.prototype?.getConnectionKeyFromConnectionsJson; + + if (getConnectionKeyFromConnectionsJson) { + const mockContext = { workflowFilePath: '/test/workflow.json', context: {} }; + const result = await getConnectionKeyFromConnectionsJson.call(mockContext, testProjectPath, testConnectionName); + + expect(result).toBe(testConnectionName); + } + }); + + it('should handle missing managedApiConnections property', async () => { + const connectionsJson = JSON.stringify({}); + + mockGetConnectionsFromFile.mockResolvedValue(connectionsJson); + + const getConnectionKeyFromConnectionsJson = (mockModule as any).ConnectionView?.prototype?.getConnectionKeyFromConnectionsJson; + + if (getConnectionKeyFromConnectionsJson) { + const mockContext = { workflowFilePath: '/test/workflow.json', context: {} }; + const result = await getConnectionKeyFromConnectionsJson.call(mockContext, testProjectPath, testConnectionName); + + expect(result).toBe(testConnectionName); + } + }); +}); + +describe('ConnectionView - getLoadingHtml', () => { + it('should return valid HTML with DOCTYPE declaration', () => { + const getLoadingHtml = (mockModule as any).ConnectionView?.prototype?.getLoadingHtml; + + if (getLoadingHtml) { + const mockContext = {}; + const html = getLoadingHtml.call(mockContext); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + } + }); + + it('should contain loading spinner with animation', () => { + const getLoadingHtml = (mockModule as any).ConnectionView?.prototype?.getLoadingHtml; + + if (getLoadingHtml) { + const mockContext = {}; + const html = getLoadingHtml.call(mockContext); + + expect(html).toContain('class="spinner"'); + expect(html).toContain('@keyframes spin'); + expect(html).toContain('animation: spin 1s linear infinite'); + } + }); + + it('should contain loading text', () => { + const getLoadingHtml = (mockModule as any).ConnectionView?.prototype?.getLoadingHtml; + + if (getLoadingHtml) { + const mockContext = {}; + const html = getLoadingHtml.call(mockContext); + + expect(html).toContain('Loading connections'); + } + }); + + it('should use VS Code theme variables for styling', () => { + const getLoadingHtml = (mockModule as any).ConnectionView?.prototype?.getLoadingHtml; + + if (getLoadingHtml) { + const mockContext = {}; + const html = getLoadingHtml.call(mockContext); + + expect(html).toContain('var(--vscode-foreground)'); + expect(html).toContain('var(--vscode-editor-background)'); + expect(html).toContain('var(--vscode-progressBar-background)'); + } + }); + + it('should have proper CSS for centering content', () => { + const getLoadingHtml = (mockModule as any).ConnectionView?.prototype?.getLoadingHtml; + + if (getLoadingHtml) { + const mockContext = {}; + const html = getLoadingHtml.call(mockContext); + + expect(html).toContain('display: flex'); + expect(html).toContain('justify-content: center'); + expect(html).toContain('align-items: center'); + expect(html).toContain('height: 100vh'); + } + }); +}); + +describe('resolveConnectionEdit', () => { + const resolveConnectionEdit = (mockModule as any).resolveConnectionEdit; + + describe('Case 1: Range covers just the string parameter', () => { + it('should replace a quoted string directly', () => { + const result = resolveConnectionEdit('"azureblob"', 'azureblob-1'); + expect(result).toEqual({ + offset: 0, + length: '"azureblob"'.length, + text: '"azureblob-1"', + }); + }); + + it('should handle empty quoted string', () => { + const result = resolveConnectionEdit('""', 'msnweather'); + expect(result).toEqual({ + offset: 0, + length: 2, + text: '"msnweather"', + }); + }); + }); + + describe('Case 2: Range covers method call WITH existing connection name', () => { + it('should find and replace the string parameter within a method call', () => { + const existingText = 'WorkflowActions.ManagedConnectors.Azureblob("azureblob").ListFolderV4()'; + const result = resolveConnectionEdit(existingText, 'azureblob-1'); + expect(result).toEqual({ + offset: existingText.indexOf('"azureblob"'), + length: '"azureblob"'.length, + text: '"azureblob-1"', + }); + }); + + it('should find string parameter in simple method call', () => { + const existingText = 'Msnweather("msnweather")'; + const result = resolveConnectionEdit(existingText, 'msnweather-2'); + expect(result).toEqual({ + offset: existingText.indexOf('"msnweather"'), + length: '"msnweather"'.length, + text: '"msnweather-2"', + }); + }); + + it('should replace the first string parameter when multiple exist', () => { + const existingText = 'Connector("conn1", "param2")'; + const result = resolveConnectionEdit(existingText, 'newconn'); + expect(result).toEqual({ + offset: existingText.indexOf('"conn1"'), + length: '"conn1"'.length, + text: '"newconn"', + }); + }); + }); + + describe('Case 3: Range covers method call with NO connection name (empty parens)', () => { + it('should insert connection name into empty parentheses', () => { + const existingText = 'Azureblob()'; + const result = resolveConnectionEdit(existingText, 'azureblob'); + expect(result).toEqual({ + offset: existingText.indexOf('(') + 1, + length: 0, + text: '"azureblob"', + }); + }); + + it('should insert into chained method call with no arguments', () => { + const existingText = 'WorkflowActions.ManagedConnectors.Msnweather()'; + const result = resolveConnectionEdit(existingText, 'msnweather'); + expect(result).toEqual({ + offset: existingText.indexOf('(') + 1, + length: 0, + text: '"msnweather"', + }); + }); + }); + + describe('Case 4: Stale range (user edited file after opening connection pane)', () => { + it('should find the string when user changed the connection name to a shorter one', () => { + // User changed "msnweather" to "mw" while connection pane was open + const existingText = 'WorkflowActions.ManagedConnectors.Msnweather("mw").CurrentWeather()'; + const result = resolveConnectionEdit(existingText, 'msnweather-1'); + expect(result).toEqual({ + offset: existingText.indexOf('"mw"'), + length: '"mw"'.length, + text: '"msnweather-1"', + }); + }); + + it('should find the string when user changed the connection name to a longer one', () => { + // User changed "mw" to "my-custom-weather-connection" while connection pane was open + const existingText = 'WorkflowActions.ManagedConnectors.Msnweather("my-custom-weather-connection").CurrentWeather()'; + const result = resolveConnectionEdit(existingText, 'msnweather'); + expect(result).toEqual({ + offset: existingText.indexOf('"my-custom-weather-connection"'), + length: '"my-custom-weather-connection"'.length, + text: '"msnweather"', + }); + }); + }); + + describe('Case 5: User emptied the string and quotes while pane was open', () => { + it('should insert into empty parens found on the full line when range text has no quotes or parens', () => { + // The stale range text no longer contains the method call — fall back to full line + const staleRangeText = 'ather().Curre'; + const fullLine = ' var weather = WorkflowActions.ManagedConnectors.Msnweather().CurrentWeather()'; + const result = resolveConnectionEdit(staleRangeText, 'msnweather', fullLine); + expect(result).not.toBeNull(); + expect(result?.text).toBe('"msnweather"'); + expect(result?.length).toBe(0); // insert, not replace + }); + }); + + describe('Edge cases', () => { + it('should return null when no string or parens found', () => { + const result = resolveConnectionEdit('some random text without quotes or parens', 'conn'); + expect(result).toBeNull(); + }); + + it('should handle connection names with hyphens and numbers', () => { + const result = resolveConnectionEdit('"msnweather-10"', 'msnweather-11'); + expect(result).toEqual({ + offset: 0, + length: '"msnweather-10"'.length, + text: '"msnweather-11"', + }); + }); + }); +}); + +describe('resolveCurrentConnectionId', () => { + const resolveCurrentConnectionId = (mockModule as any).resolveCurrentConnectionId; + + it('returns mapped connection id leaf when current id is a connections.json key', () => { + const connectionsData = JSON.stringify({ + managedApiConnections: { + 'msnweather-1': { + connection: { + id: '/subscriptions/sub-123/resourceGroups/rg/providers/Microsoft.Web/connections/msnweather-10', + }, + }, + }, + }); + + const result = resolveCurrentConnectionId(connectionsData, 'msnweather-1'); + expect(result).toBe('msnweather-10'); + }); + + it('returns original id when key is not found', () => { + const connectionsData = JSON.stringify({ managedApiConnections: {} }); + const result = resolveCurrentConnectionId(connectionsData, 'msnweather-1'); + expect(result).toBe('msnweather-1'); + }); + + it('returns original id when json is invalid', () => { + const result = resolveCurrentConnectionId('not-json', 'msnweather-1'); + expect(result).toBe('msnweather-1'); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionViewRace.test.ts b/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionViewRace.test.ts new file mode 100644 index 00000000000..a53a645ebf3 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionViewRace.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks for connection utilities (must be declared before dynamic import) ── +const mockAddConnectionData = vi.fn(); +const mockGetConnectionsAndSettingsToUpdate = vi.fn(); +const mockSaveConnectionReferences = vi.fn(); +const mockGetConnectionsFromFile = vi.fn(); +const mockGetLogicAppProjectRoot = vi.fn(); +const mockGetParametersFromFile = vi.fn(); +const mockSaveWorkflowParameter = vi.fn(); + +vi.mock('../../../../utils/codeless/connection', () => ({ + addConnectionData: mockAddConnectionData, + getConnectionsAndSettingsToUpdate: mockGetConnectionsAndSettingsToUpdate, + saveConnectionReferences: mockSaveConnectionReferences, + getConnectionsFromFile: mockGetConnectionsFromFile, + getLogicAppProjectRoot: mockGetLogicAppProjectRoot, + getParametersFromFile: mockGetParametersFromFile, +})); + +vi.mock('../../../../utils/codeless/parameter', () => ({ + saveWorkflowParameter: mockSaveWorkflowParameter, +})); + +vi.mock('../../../../utils/codeless/common', () => ({ + cacheWebviewPanel: vi.fn(), + getAzureConnectorDetailsForLocalProject: vi.fn(), + removeWebviewPanelFromCache: vi.fn(), +})); + +vi.mock('../../../../utils/codeless/startDesignTimeApi', () => ({ + startDesignTimeApi: vi.fn(), +})); + +vi.mock('../../../../utils/codeless/artifacts', () => ({ + getArtifactsInLocalProject: vi.fn(), +})); + +vi.mock('../../../../utils/appSettings/localSettings', () => ({ + getLocalSettingsJson: vi.fn().mockResolvedValue({ Values: {} }), +})); + +vi.mock('../../../../utils/bundleFeed', () => ({ + getBundleVersionNumber: vi.fn().mockResolvedValue('1.0.0'), +})); + +vi.mock('../../openDesigner/openDesignerBase', () => { + return { + OpenDesignerBase: class { + panelGroupKey = 'ls'; + panelName = 'test'; + context = { telemetry: { properties: {} } }; + panel = { dispose: vi.fn() }; + sendMsgToWebview = vi.fn(); + }, + }; +}); + +// ── Import the module under test (after all mocks are configured) ────────── +const mod = await import('../connectionView'); +const OpenConnectionView = mod.default; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * mockSaveConnection is used to replace the private saveConnection method + * on test instances, so we can test _handleWebviewMsg orchestration without + * needing deep mocks of vscode.workspace.textDocuments etc. + */ +const mockSaveConnection = vi.fn(); + +/** Create a minimal instance with just enough state for _handleWebviewMsg */ +function createInstance(): InstanceType { + const instance = Object.create(OpenConnectionView.prototype) as any; + instance.workflowFilePath = '/test/workflow.cs'; + instance.methodName = 'TestMethod'; + instance.range = { Start: { Line: 0, Character: 0 }, End: { Line: 0, Character: 10 } }; + instance.connectorName = 'test-connector'; + instance.connectorType = 'builtin'; + instance.currentConnectionId = ''; + instance.context = { telemetry: { properties: {} } }; + instance.panel = { dispose: vi.fn() }; + instance.panelMetadata = { azureDetails: {} }; + // Override the private saveConnection so tests focus on handler orchestration + instance.saveConnection = mockSaveConnection; + return instance; +} + +/** Helper to invoke the private _handleWebviewMsg via the prototype */ +function handleMsg(instance: any, message: any): Promise { + return (OpenConnectionView.prototype as any)._handleWebviewMsg.call(instance, message); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('ConnectionView – insert_connection handles local and managed connections atomically', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAddConnectionData.mockResolvedValue(undefined); + mockSaveConnection.mockResolvedValue(undefined); + }); + + describe('addConnection handler', () => { + it('should be a no-op since messages are intercepted on the React side', async () => { + const instance = createInstance(); + + await handleMsg(instance, { + command: 'add-connection', + connectionAndSetting: { connectionData: {}, connectionKey: 'agent-1', pathLocation: ['agentConnections'], settings: {} }, + }); + + expect(mockAddConnectionData).not.toHaveBeenCalled(); + expect(mockSaveConnection).not.toHaveBeenCalled(); + expect(instance.panel.dispose).not.toHaveBeenCalled(); + }); + }); + + describe('insert_connection handler', () => { + it('should write local connection data via addConnectionData when connectionAndSetting is present', async () => { + const instance = createInstance(); + const connectionAndSetting = { connectionData: {}, connectionKey: 'agent-1', pathLocation: ['agentConnections'], settings: {} }; + + await handleMsg(instance, { + command: 'insert-connection', + connection: { name: 'agent-1', id: 'agentConnections/agent-1' }, + connectionAndSetting, + }); + + expect(mockAddConnectionData).toHaveBeenCalledOnce(); + expect(mockSaveConnection).toHaveBeenCalled(); + expect(instance.panel.dispose).toHaveBeenCalled(); + }); + + it('should write local connection data before calling saveConnection', async () => { + const instance = createInstance(); + const callOrder: string[] = []; + + mockAddConnectionData.mockImplementation(async () => { + callOrder.push('addConnectionData'); + }); + mockSaveConnection.mockImplementation(async () => { + callOrder.push('saveConnection'); + }); + + await handleMsg(instance, { + command: 'insert-connection', + connection: { name: 'sp-1', id: 'serviceProviderConnections/sp-1' }, + connectionAndSetting: { connectionData: {}, connectionKey: 'sp-1', pathLocation: ['serviceProviderConnections'], settings: {} }, + }); + + expect(callOrder).toEqual(['addConnectionData', 'saveConnection']); + }); + + it('should handle managed API connections with connectionReferences and without connectionAndSetting', async () => { + const instance = createInstance(); + const connectionReferences = { 'azureblob-1': { connection: { id: '/subscriptions/sub1/connections/azureblob-1' } } }; + + await handleMsg(instance, { + command: 'insert-connection', + connection: { name: 'azureblob-1', id: '/subscriptions/sub1/connections/azureblob-1' }, + connectionReferences, + }); + + expect(mockAddConnectionData).not.toHaveBeenCalled(); + expect(mockSaveConnection).toHaveBeenCalledWith( + { name: 'azureblob-1', id: '/subscriptions/sub1/connections/azureblob-1' }, + { documentUri: '/test/workflow.cs', range: instance.range }, + connectionReferences, + undefined, + undefined + ); + expect(instance.panel.dispose).toHaveBeenCalled(); + }); + + it('should handle selecting an existing connection (no connectionAndSetting, no connectionReferences)', async () => { + const instance = createInstance(); + + await handleMsg(instance, { + command: 'insert-connection', + connection: { name: 'existing-sp', id: 'serviceProviderConnections/existing-sp' }, + }); + + expect(mockAddConnectionData).not.toHaveBeenCalled(); + expect(mockSaveConnection).toHaveBeenCalled(); + expect(instance.panel.dispose).toHaveBeenCalled(); + }); + + it('should dispose the panel after all operations complete', async () => { + const instance = createInstance(); + const callOrder: string[] = []; + + mockAddConnectionData.mockImplementation(async () => { + callOrder.push('addConnectionData'); + }); + mockSaveConnection.mockImplementation(async () => { + callOrder.push('saveConnection'); + }); + instance.panel.dispose = vi.fn(() => { + callOrder.push('dispose'); + }); + + await handleMsg(instance, { + command: 'insert-connection', + connection: { name: 'agent-1', id: 'agentConnections/agent-1' }, + connectionAndSetting: { connectionData: {}, connectionKey: 'agent-1', pathLocation: ['agentConnections'], settings: {} }, + }); + + expect(callOrder).toEqual(['addConnectionData', 'saveConnection', 'dispose']); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/languageServer/connectionView.ts b/apps/vs-code-designer/src/app/commands/workflows/languageServer/connectionView.ts index 326d122fdf1..c41fd68728e 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/languageServer/connectionView.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/languageServer/connectionView.ts @@ -6,7 +6,7 @@ import { ext } from '../../../../extensionVariables'; import { localize } from '../../../../localize'; import { cacheWebviewPanel, getAzureConnectorDetailsForLocalProject, removeWebviewPanelFromCache } from '../../../utils/codeless/common'; import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { AzureConnectorDetails, IDesignerPanelMetadata, Parameter } from '@microsoft/vscode-extension-logic-apps'; +import type { IDesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName, RouteName } from '@microsoft/vscode-extension-logic-apps'; import { OpenDesignerBase } from '../openDesigner/openDesignerBase'; import { startDesignTimeApi } from '../../../utils/codeless/startDesignTimeApi'; @@ -18,6 +18,7 @@ import { getParametersFromFile, saveConnectionReferences, } from '../../../utils/codeless/connection'; +import { getAuthorizationToken } from '../../../utils/codeless/getAuthorizationToken'; import path from 'path'; import { localSettingsFileName, managementApiPrefix, workflowAppApiVersion } from '../../../../constants'; import type { WebviewPanel } from 'vscode'; @@ -83,11 +84,28 @@ export default class OpenConnectionView extends OpenDesignerBase { } this.projectPath = await getLogicAppProjectRoot(this.context, this.workflowFilePath); + if (!this.projectPath) { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } - await startDesignTimeApi(this.projectPath); + // Create webview panel first for immediate visual feedback + this.panel = window.createWebviewPanel( + this.panelGroupKey, // Key used to reference the panel + this.panelName, // Title display in the tab + ViewColumn.Beside, // Editor column to show the new webview panel in. + this.getPanelOptions() + ); + this.panel.iconPath = { + light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'workflow.svg')), + dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'workflow.svg')), + }; + + // Show loading state in webview + this.panel.webview.html = this.getLoadingHtml(); + + // Start design time API and load metadata in parallel + const [_, panelMetadata] = await Promise.all([startDesignTimeApi(this.projectPath), this._getDesignerPanelMetadata()]); if (!ext.designTimeInstances.has(this.projectPath)) { throw new Error(localize('designTimeNotRunning', `Design time is not running for project ${this.projectPath}.`)); @@ -99,35 +117,28 @@ export default class OpenConnectionView extends OpenDesignerBase { this.baseUrl = `http://localhost:${designTimePort}${managementApiPrefix}`; this.workflowRuntimeBaseUrl = `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}`; - this.panel = window.createWebviewPanel( - this.panelGroupKey, // Key used to reference the panel - this.panelName, // Title display in the tab - ViewColumn.Beside, // Editor column to show the new webview panel in. - this.getPanelOptions() - ); - this.panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'workflow.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'workflow.svg')), - }; + this.panelMetadata = panelMetadata; + + // Pre-warm the auth token while the webview loads so that + // saveConnection → getConnectionsAndSettingsToUpdate → getAuthorizationToken + // returns instantly from cache when the user clicks a connection + if (this.panelMetadata.azureDetails?.tenantId) { + getAuthorizationToken(this.panelMetadata.azureDetails.tenantId).catch(() => {}); + } - this.panelMetadata = await this._getDesignerPanelMetadata(); const callbackUri: Uri = await (env as any).asExternalUri( Uri.parse(`${env.uriScheme}://ms-azuretools.vscode-azurelogicapps/authcomplete`) ); this.context.telemetry.properties.extensionBundleVersion = this.panelMetadata.extensionBundleVersion; this.oauthRedirectUrl = callbackUri.toString(true); - this.panel.webview.html = await this.getWebviewContent({ - connectionsData: this.panelMetadata.connectionsData, - parametersData: this.panelMetadata.parametersData || {}, - localSettings: this.panelMetadata.localSettings, - artifacts: this.panelMetadata.artifacts, - azureDetails: this.panelMetadata.azureDetails, - workflowDetails: this.panelMetadata.workflowDetails, - }); this.panelMetadata.mapArtifacts = this.mapArtifacts; this.panelMetadata.schemaArtifacts = this.schemaArtifacts; + // Register message handler BEFORE setting the React webview content. + // The React app sends "initialize" immediately on boot — if the handler + // isn't registered yet, the message is silently dropped and the UI stays + // stuck on "Loading connection data..." forever. this.panel.webview.onDidReceiveMessage(async (message) => await this._handleWebviewMsg(message), ext.context.subscriptions); this.panel.onDidDispose( @@ -140,11 +151,64 @@ export default class OpenConnectionView extends OpenDesignerBase { cacheWebviewPanel(this.panelGroupKey, this.panelName, this.panel); ext.context.subscriptions.push(this.panel); + + // Set the React content LAST — handler is ready to receive "initialize" + this.panel.webview.html = await this.getWebviewContent({ + connectionsData: this.panelMetadata.connectionsData, + parametersData: this.panelMetadata.parametersData || {}, + localSettings: this.panelMetadata.localSettings, + artifacts: this.panelMetadata.artifacts, + azureDetails: this.panelMetadata.azureDetails, + workflowDetails: this.panelMetadata.workflowDetails, + }); + } + + private getLoadingHtml(): string { + return ` + + + + + +
+
+
Loading connection view...
+
+ + `; } private async _handleWebviewMsg(message: any) { switch (message.command) { case ExtensionCommand.initialize: { + const resolvedCurrentConnectionId = resolveCurrentConnectionId(this.panelMetadata?.connectionsData, this.currentConnectionId); + this.sendMsgToWebview({ command: ExtensionCommand.initialize_frame, data: { @@ -161,7 +225,7 @@ export default class OpenConnectionView extends OpenDesignerBase { connector: { name: this.connectorName, type: this.connectorType, - currentConnectionId: this.currentConnectionId, + currentConnectionId: resolvedCurrentConnectionId, }, }, }); @@ -172,11 +236,15 @@ export default class OpenConnectionView extends OpenDesignerBase { break; } case ExtensionCommand.insert_connection: { - await callWithTelemetryAndErrorHandling('InsertConnectionView', async () => { - const { connection, connectionReferences } = message; + await callWithTelemetryAndErrorHandling('InsertConnectionView', async (activateContext: IActionContext) => { + const { connection, connectionReferences, connectionAndSetting } = message; + + // For local connections, the React side captures the connection data from the writeConnection callback and includes it here. + if (connectionAndSetting) { + await addConnectionData(activateContext, this.workflowFilePath, connectionAndSetting); + } await this.saveConnection( - this.methodName, connection, { documentUri: this.workflowFilePath, @@ -199,19 +267,12 @@ export default class OpenConnectionView extends OpenDesignerBase { ext.telemetryReporter.sendTelemetryEvent(eventName, { ...message.data }); break; } - case ExtensionCommand.addConnection: { - await callWithTelemetryAndErrorHandling('AddConnectionFromDesigner', async (activateContext: IActionContext) => { - await addConnectionData(activateContext, this.workflowFilePath, message.connectionAndSetting); - }); - break; - } default: break; } } private async saveConnection( - functionName: string, connection: Connection, insertionContext: { documentUri: string; range: Range }, connectionReferences: any, @@ -219,6 +280,8 @@ export default class OpenConnectionView extends OpenDesignerBase { workflowBaseManagementUri?: string ) { const projectPath = await getLogicAppProjectRoot(this.context, this.workflowFilePath); + + // Process connection references FIRST so connections.json is written const parametersFromDefinition = {} as any; if (connectionReferences) { @@ -234,6 +297,13 @@ export default class OpenConnectionView extends OpenDesignerBase { await saveConnectionReferences(this.context, projectPath, connectionsAndSettingsToUpdate); } + // NOW get the connection key from the just-written connections.json + // This ensures the .cs file uses the same key that saveConnectionReferences generated + const connectionKey = await this.getConnectionKeyFromConnectionsJson(projectPath, connection.name); + + const connectionId = connectionKey || connection.name; + updateConnectionIdInSource(connectionId, insertionContext); + if (parametersFromDefinition) { delete parametersFromDefinition.$connections; for (const parameterKey of Object.keys(parametersFromDefinition)) { @@ -244,16 +314,10 @@ export default class OpenConnectionView extends OpenDesignerBase { await this.mergeJsonParameters(this.workflowFilePath, parametersFromDefinition); await saveWorkflowParameter(this.context, this.workflowFilePath, parametersFromDefinition); } - - insertFunctionCallAtLocation(functionName, connection, insertionContext); } /** * Merges parameters from JSON. - * @param filePath The file path of the parameters JSON file. - * @param definitionParameters The parameters from the designer. - * @param panelParameterRecord The parameters from the panel - * @returns parameters from JSON file and designer. */ private async mergeJsonParameters(filePath: string, definitionParameters: any): Promise { const jsonParameters = await getParametersFromFile(this.context, filePath); @@ -266,23 +330,33 @@ export default class OpenConnectionView extends OpenDesignerBase { } private async _getDesignerPanelMetadata(): Promise { - const connectionsData: string = await getConnectionsFromFile(this.context, this.workflowFilePath); const projectPath: string | undefined = await getLogicAppProjectRoot(this.context, this.workflowFilePath); - const artifacts = await getArtifactsInLocalProject(projectPath); - const bundleVersionNumber = await getBundleVersionNumber(); - const parametersData: Record = await getParametersFromFile(this.context, this.workflowFilePath); - let localSettings: Record; - let azureDetails: AzureConnectorDetails; - - if (projectPath) { - azureDetails = await getAzureConnectorDetailsForLocalProject(this.context, projectPath); - localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values; - } else { + if (!projectPath) { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } - return { + // Critical data needed immediately for UI rendering + const criticalDataPromises = [ + getConnectionsFromFile(this.context, this.workflowFilePath), + getParametersFromFile(this.context, this.workflowFilePath), + getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName)).then((result) => result.Values), + ]; + + // Less critical data that can load slightly later + const deferredDataPromises = [ + getArtifactsInLocalProject(projectPath), + getBundleVersionNumber(), + getAzureConnectorDetailsForLocalProject(this.context, projectPath), + ]; + + // Load critical data first + const [connectionsData, parametersData, localSettings] = await Promise.all(criticalDataPromises); + + // Continue loading deferred data in background + const [artifacts, bundleVersionNumber, azureDetails] = await Promise.all(deferredDataPromises); + + const metadata = { panelId: this.panelName, appSettingNames: Object.keys(localSettings), connectionsData, @@ -297,38 +371,68 @@ export default class OpenConnectionView extends OpenDesignerBase { extensionBundleVersion: bundleVersionNumber, workflowDetails: {}, }; + + return metadata; + } + + /** + * Finds the key in connections.json that references the given connection name. + */ + private async getConnectionKeyFromConnectionsJson(projectPath: string | undefined, connectionName: string): Promise { + if (!projectPath) { + return connectionName; + } + + try { + const connectionsJsonString = await getConnectionsFromFile(this.context, this.workflowFilePath); + if (!connectionsJsonString) { + return connectionName; + } + + const connectionsJson = JSON.parse(connectionsJsonString); + const managedApiConnections = connectionsJson?.managedApiConnections || {}; + + for (const [key, connectionData] of Object.entries(managedApiConnections)) { + const connection = connectionData as any; + if (connection?.connection?.id) { + const idParts = connection.connection.id.split('/'); + const lastPart = idParts[idParts.length - 1]; + if (lastPart === connectionName) { + return key; + } + } + } + + return connectionName; + } catch { + return connectionName; + } } } -// Helper function to update function parameters at specific location -function insertFunctionCallAtLocation( - _functionName: string, - connection: Connection, - insertionContext: { documentUri: string; range: Range } -) { - // Find the document by URI +/** + * Updates the connection ID in the source file at the specified range with the new connection ID. + * @param {string} connectionId - The new connection ID to insert into the source file. + * @param {{ documentUri: string; range: Range }} insertionContext - The context containing the document URI and the range where the connection ID should be inserted. + */ +function updateConnectionIdInSource(connectionId: string, insertionContext: { documentUri: string; range: Range }) { const targetDocument = vscode.workspace.textDocuments.find((doc) => doc.uri.fsPath.toString() === insertionContext.documentUri); if (!targetDocument) { vscode.window.showErrorMessage('Target document not found. Please ensure the file is still open.'); return; } - // Check if the document is already visible in an active editor - const visibleEditors = vscode.window.visibleTextEditors; - // Check if the target document is already open in any visible editor - const existingEditor = visibleEditors.find((editor) => editor.document.uri.fsPath === targetDocument.uri.fsPath); + const visibleEditors = vscode.window.visibleTextEditors; + const targetEditor = visibleEditors.find((editor) => editor.document.uri.fsPath === targetDocument.uri.fsPath); - if (existingEditor) { - // Document is already open, just focus on it - vscode.window.showTextDocument(existingEditor.document, existingEditor.viewColumn, false).then( + if (targetEditor) { + vscode.window.showTextDocument(targetEditor.document, targetEditor.viewColumn, false).then( (editor) => { - performTextReplacement(editor, connection, insertionContext); - // Save the document after successful insertion - existingEditor.document.save().then( - () => { - vscode.window.showInformationMessage('File saved successfully'); - }, + performTextReplacement(editor, connectionId, insertionContext); + + targetEditor.document.save().then( + () => {}, (saveError: any) => { const errorMessage = saveError instanceof Error ? saveError.message : typeof saveError === 'string' ? saveError : 'Unknown error'; @@ -341,57 +445,178 @@ function insertFunctionCallAtLocation( vscode.window.showErrorMessage(`Failed to open target document: ${errorMessage}`); } ); - return; } } const performTextReplacement = ( editor: vscode.TextEditor, - connection: Connection, + connectionId: string, insertionContext: { documentUri: string; range: Range } ) => { - if (insertionContext.range) { - // Normalize the range format - handle both Start/Line and start/line formats - const range = insertionContext.range; - let startLine: number; - let startChar: number; - let endLine: number; - let endChar: number; - - if (range.Start && range.End) { - // Handle { Start: { Line: 55, Character: 85 }, End: { Line: 55, Character: 99 } } format - startLine = range.Start.Line; - startChar = range.Start.Character; - endLine = range.End.Line; - endChar = range.End.Character; - } else { - vscode.window.showErrorMessage('Invalid range format provided'); - return; - } + if (!insertionContext.range) { + vscode.window.showErrorMessage('No range provided for connection ID insertion'); + return; + } + + const range = insertionContext.range; + if (!range.Start || !range.End) { + vscode.window.showErrorMessage('Invalid range format provided'); + return; + } + + const startLine = range.Start.Line; + const startChar = range.Start.Character; + const endLine = range.End.Line; + const endChar = range.End.Character; + + const startPos = new vscode.Position(startLine, startChar); + const endPos = new vscode.Position(endLine, endChar); + const rangeToReplace = new vscode.Range(startPos, endPos); - // Use the normalized range to replace the text with the connection.id - const startPos = new vscode.Position(startLine, startChar); - const endPos = new vscode.Position(endLine, endChar); - const rangeToReplace = new vscode.Range(startPos, endPos); - - editor - .edit((editBuilder) => { - editBuilder.replace(rangeToReplace, `"${connection.name}"`); - }) - .then((success) => { - if (success) { - // Position cursor after the inserted connection ID - const newCursorPos = new vscode.Position(startLine, startChar + connection.name.length); - editor.selection = new vscode.Selection(newCursorPos, newCursorPos); + // Read the CURRENT text at the range to handle all cases correctly + const existingText = editor.document.getText(rangeToReplace); + + let editRange: vscode.Range; + let editText: string; + + if (existingText.startsWith('"') && existingText.endsWith('"')) { + // Case 1: Range covers just the string parameter (e.g., "azureblob") + editRange = rangeToReplace; + editText = `"${connectionId}"`; + } else { + // Range covers a wider method call — find the string param within it + const stringMatch = existingText.match(/"([^"]*)"/); + + if (stringMatch && stringMatch.index !== undefined) { + // Case 2 & 4: Method call with existing string param — replace just the string + // This also handles stale ranges (user edited file) as long as the string is findable + const matchStart = stringMatch.index; + const matchEnd = matchStart + stringMatch[0].length; + editRange = new vscode.Range( + new vscode.Position(startLine, startChar + matchStart), + new vscode.Position(startLine, startChar + matchEnd) + ); + editText = `"${connectionId}"`; + } else { + // Case 3: No string parameter — find empty parens and insert connection name + // Look for the first "(" in the text and insert after it + const parenIndex = existingText.indexOf('('); + if (parenIndex !== -1) { + const insertPos = new vscode.Position(startLine, startChar + parenIndex + 1); + editRange = new vscode.Range(insertPos, insertPos); + editText = `"${connectionId}"`; + } else { + // Fallback: search the full line near the range for the connector call + const fullLine = editor.document.lineAt(startLine).text; + const lineStringMatch = fullLine.match(/"([^"]*)"/); + if (lineStringMatch && lineStringMatch.index !== undefined) { + editRange = new vscode.Range( + new vscode.Position(startLine, lineStringMatch.index), + new vscode.Position(startLine, lineStringMatch.index + lineStringMatch[0].length) + ); + editText = `"${connectionId}"`; } else { - vscode.window.showErrorMessage('Failed to insert connection ID'); + // Case 5: User emptied the string and quotes — find empty parens on the line + const emptyParenMatch = fullLine.match(/\(\)/); + if (emptyParenMatch && emptyParenMatch.index !== undefined) { + const insertPos = new vscode.Position(startLine, emptyParenMatch.index + 1); + editRange = new vscode.Range(insertPos, insertPos); + editText = `"${connectionId}"`; + } else { + vscode.window.showErrorMessage('Could not find connection parameter to replace in the source file'); + return; + } } - }); - } else { - vscode.window.showErrorMessage('No range provided for connection ID insertion'); + } + } } + + editor + .edit((editBuilder) => { + editBuilder.replace(editRange, editText); + }) + .then((success) => { + if (success) { + const newCursorPos = new vscode.Position(editRange.start.line, editRange.start.character + editText.length); + editor.selection = new vscode.Selection(newCursorPos, newCursorPos); + } else { + vscode.window.showErrorMessage('Failed to insert connection ID'); + } + }); }; +/** + * Determines what text edit to perform for a connection name replacement. + * Exported for testing. + * @returns { offset, length, text } relative to the range start, or null if no edit possible. + */ +export function resolveConnectionEdit( + existingText: string, + connectionId: string, + fullLineText?: string +): { offset: number; length: number; text: string } | null { + if (existingText.startsWith('"') && existingText.endsWith('"')) { + // Case 1: Range covers just the string parameter + return { offset: 0, length: existingText.length, text: `"${connectionId}"` }; + } + + // Range covers wider text — find string param within + const stringMatch = existingText.match(/"([^"]*)"/); + if (stringMatch && stringMatch.index !== undefined) { + // Case 2 & 4: Replace existing string param + return { offset: stringMatch.index, length: stringMatch[0].length, text: `"${connectionId}"` }; + } + + // Case 3: No string param — insert into empty parens + const parenIndex = existingText.indexOf('('); + if (parenIndex !== -1) { + return { offset: parenIndex + 1, length: 0, text: `"${connectionId}"` }; + } + + // Fallback: search the full line for a string param or empty parens + if (fullLineText) { + const lineMatch = fullLineText.match(/"([^"]*)"/); + if (lineMatch && lineMatch.index !== undefined) { + return { offset: -1, lineOffset: lineMatch.index, length: lineMatch[0].length, text: `"${connectionId}"` } as any; + } + + // Case 5: User emptied the string and quotes — find empty parens on the line + const emptyParenMatch = fullLineText.match(/\(\)/); + if (emptyParenMatch && emptyParenMatch.index !== undefined) { + return { offset: -1, lineOffset: emptyParenMatch.index + 1, length: 0, text: `"${connectionId}"` } as any; + } + } + + return null; +} + +/** + * Resolves a connection reference key (for example "msnweather-1") + * to the actual connection id leaf (for example "msnweather-10") + * by looking up managedApiConnections in connections.json. + */ +export function resolveCurrentConnectionId(connectionsData: string | undefined, currentConnectionId: string): string { + if (!connectionsData || !currentConnectionId) { + return currentConnectionId; + } + + try { + const parsedConnections = JSON.parse(connectionsData); + const managedApiConnection = parsedConnections?.managedApiConnections?.[currentConnectionId]; + const connectionId = managedApiConnection?.connection?.id; + + if (typeof connectionId === 'string' && connectionId.length > 0) { + const idParts = connectionId.split('/'); + const idLeaf = idParts[idParts.length - 1]; + return idLeaf || currentConnectionId; + } + } catch { + // Fall back to original value when connections data isn't valid JSON. + } + + return currentConnectionId; +} + export async function openLanguageServerConnectionView( context: IActionContext, filePath: string, diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts b/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts index 2f9d7ba6eac..110390181b2 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts @@ -23,7 +23,7 @@ import { createUnitTestFromRun } from '../unitTest/codefulUnitTest/createUnitTes import { OpenMonitoringViewBase } from './openMonitoringViewBase'; import { getTriggerName, HTTP_METHODS } from '@microsoft/logic-apps-shared'; import { openUrl, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { AzureConnectorDetails, IDesignerPanelMetadata, Parameter } from '@microsoft/vscode-extension-logic-apps'; +import type { IDesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; import { promises, readFileSync } from 'fs'; import * as path from 'path'; @@ -61,34 +61,31 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { ViewColumn.Active, // Editor column to show the new webview panel in. this.getPanelOptions() ); - this.panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'workflow.svg')), - }; - this.panel.iconPath = { light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'workflow.svg')), dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), }; this.projectPath = await getLogicAppProjectRoot(this.context, this.workflowFilePath); - const connectionsData = await getConnectionsFromFile(this.context, this.workflowFilePath); - const parametersData = await getParametersFromFile(this.context, this.workflowFilePath); - this.baseUrl = `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}`; - if (this.projectPath) { - this.localSettings = (await getLocalSettingsJson(this.context, path.join(this.projectPath, localSettingsFileName))).Values; - } else { + if (!this.projectPath) { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } + this.baseUrl = `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}`; + + // Fetch panel metadata which does all operations in parallel internally this.panelMetadata = await this._getDesignerPanelMetadata(); + + // Reuse data from panelMetadata instead of fetching again + this.localSettings = this.panelMetadata.localSettings; + this.panel.webview.html = await this.getWebviewContent({ - connectionsData: connectionsData, - parametersData: parametersData, - localSettings: this.localSettings, - artifacts: await getArtifactsInLocalProject(this.projectPath), - azureDetails: await getAzureConnectorDetailsForLocalProject(this.context, this.projectPath), + connectionsData: this.panelMetadata.connectionsData, + parametersData: this.panelMetadata.parametersData, + localSettings: this.panelMetadata.localSettings, + artifacts: this.panelMetadata.artifacts, + azureDetails: this.panelMetadata.azureDetails, }); this.panelMetadata.mapArtifacts = this.mapArtifacts; this.panelMetadata.schemaArtifacts = this.schemaArtifacts; @@ -130,6 +127,7 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { isMonitoringView: this.isMonitoringView, runId: this.runId, hostVersion: ext.extensionVersion, + supportsUnitTest: this.isLocal && this.localSettings['WORKFLOW_CODEFUL_ENABLED'] !== 'true', }, }); break; @@ -189,23 +187,62 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { } private async _getDesignerPanelMetadata(): Promise { - const connectionsData: string = await getConnectionsFromFile(this.context, this.workflowFilePath); const projectPath: string | undefined = await getLogicAppProjectRoot(this.context, this.workflowFilePath); - const workflowContent: any = JSON.parse(readFileSync(this.workflowFilePath, 'utf8')); - const parametersData: Record = await getParametersFromFile(this.context, this.workflowFilePath); - const customCodeData: Record = await getCustomCodeFromFiles(this.workflowFilePath); - const bundleVersionNumber = await getBundleVersionNumber(); - - let localSettings: Record; - let azureDetails: AzureConnectorDetails; - - if (projectPath) { - azureDetails = await getAzureConnectorDetailsForLocalProject(this.context, projectPath); - localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values; - } else { + + if (!projectPath) { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } + // Parallelize all file reads and API calls for better performance + const [ + connectionsData, + parametersData, + customCodeData, + bundleVersionNumber, + azureDetails, + localSettingsResult, + artifacts, + workflowContent, + ] = await Promise.all([ + getConnectionsFromFile(this.context, this.workflowFilePath), + getParametersFromFile(this.context, this.workflowFilePath), + getCustomCodeFromFiles(this.workflowFilePath), + getBundleVersionNumber(), + getAzureConnectorDetailsForLocalProject(this.context, projectPath), + getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName)), + getArtifactsInLocalProject(projectPath), + // Handle workflow content based on file type + (async () => { + if (this.workflowFilePath.endsWith('.cs')) { + // For codeful workflows, fetch the workflow definition from the run data + try { + const runUrl = `${this.baseUrl}/workflows/${this.workflowName}/runs/${this.runId}?api-version=${this.apiVersion}`; + const runResponse: string = await sendRequest(this.context, { + url: runUrl, + method: HTTP_METHODS.GET, + }); + const runData = JSON.parse(runResponse); + + // Extract workflow definition from the run properties + if (runData.properties?.workflow?.properties?.definition) { + return { + definition: runData.properties.workflow.properties.definition, + kind: runData.properties.workflow.properties.kind || 'Stateful', + }; + } + } catch { + // Fallback to minimal structure on error + } + // Return minimal structure if API call failed or no definition found + return { definition: {}, kind: 'Stateful' }; + } + // For regular workflow.json files, read from file + return JSON.parse(readFileSync(this.workflowFilePath, 'utf8')); + })(), + ]); + + const localSettings = localSettingsResult.Values; + return { panelId: this.panelName, appSettingNames: Object.keys(localSettings), @@ -217,7 +254,7 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { accessToken: azureDetails.accessToken, workflowName: this.workflowName, workflowDetails: {}, - artifacts: await getArtifactsInLocalProject(projectPath), + artifacts, standardApp: getStandardAppData(this.workflowName, { ...workflowContent, definition: {} }), schemaArtifacts: this.schemaArtifacts, mapArtifacts: this.mapArtifacts, diff --git a/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts b/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts index 4caa23e4bb8..6552fe1aa67 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts @@ -33,12 +33,39 @@ import { openMonitoringView } from './openMonitoringView/openMonitoringView'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import type { AzureConnectorDetails, ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; -import { readFileSync } from 'fs'; +import { readFileSync, readdirSync } from 'fs'; import { basename, dirname, join } from 'path'; import * as path from 'path'; import * as vscode from 'vscode'; import { launchProjectDebugger } from '../../utils/vsCodeConfig/launch'; import { isRuntimeUp } from '../../utils/startRuntimeApi'; +import { delay } from '../../utils/delay'; +import { detectCodefulWorkflow, extractTriggerNameFromCodeful, extractHttpTriggerName, hasHttpRequestTrigger } from '../../utils/codeful'; +import { getCodefulWorkflowMetadata } from '../../languageServer/languageServer'; + +interface CodefulWorkflowData { + workflowName: string; + workflowKind: string; + triggerName?: string; + triggerType?: string; + triggerKind?: string; +} + +interface CallbackInfoUpdate { + workflowName: string; + callbackInfo?: ICallbackUrlResponse; +} + +interface OverviewWorkflowProperties { + name: string; + stateType: string; + operationOptions?: string; + statelessRunMode?: string; + callbackInfo?: ICallbackUrlResponse; + triggerName?: string; + definition: LogicAppsV2.WorkflowDefinition; + kind?: string; +} // TODO(aeldridge): We should split into remote and local open overview export async function openOverview(context: IAzureConnectorsContext, node: vscode.Uri | RemoteWorkflowTreeItem | undefined): Promise { @@ -52,36 +79,134 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod let getAccessToken: () => Promise; let isLocal: boolean; let callbackInfo: ICallbackUrlResponse | undefined; - let getCallbackInfo: (baseUrl: string) => Promise; + let getCallbackInfo: ((baseUrl: string) => Promise) | undefined; + let getCodefulCallbackInfoUpdates: ((baseUrl: string) => Promise) | undefined; let panelName = ''; + let panelTitle = ''; let corsNotice: string | undefined; let localSettings: Record = {}; let connectionData: Record = {}; let azureDetails: AzureConnectorDetails; - let triggerName: string; + let triggerName: string | undefined; + let workflowProps: OverviewWorkflowProperties | undefined; + let workflowPropertiesList: OverviewWorkflowProperties[] | undefined; + let isCodefulOverview = false; const workflowNode = getWorkflowNode(node); const panelGroupKey = ext.webViewKey.overview; if (workflowNode instanceof vscode.Uri) { workflowFilePath = workflowNode.fsPath; - workflowName = basename(dirname(workflowFilePath)); + const projectPath = await getLogicAppProjectRoot(context, workflowFilePath); if (!isNullOrUndefined(projectPath) && !(await isRuntimeUp(ext.workflowRuntimePort))) { await launchProjectDebugger(context, projectPath); } - - panelName = `${vscode.workspace.name}-${workflowName}-overview`; - workflowContent = JSON.parse(readFileSync(workflowFilePath, 'utf8')); getBaseUrl = () => (ext.workflowRuntimePort ? `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}` : undefined); baseUrl = getBaseUrl?.(); apiVersion = '2019-10-01-edge-preview'; isLocal = true; - triggerName = getTriggerName(workflowContent.definition); - getCallbackInfo = async (baseUrl: string) => - await getLocalWorkflowCallbackInfo(context, workflowContent.definition, baseUrl, workflowName, triggerName, apiVersion); - callbackInfo = await getCallbackInfo(baseUrl); + if (!baseUrl) { + ext.outputChannel.appendLog( + localize( + 'overviewCallbackUrlUnavailable', + 'Callback URL is not available because the workflow runtime is not running. Start debugging or run "func host start" to enable the Run Trigger button.' + ) + ); + } localSettings = projectPath ? (await getLocalSettingsJson(context, join(projectPath, localSettingsFileName))).Values || {} : {}; + + if (workflowFilePath.endsWith('.cs')) { + isCodefulOverview = true; + const fileContent = readFileSync(workflowFilePath, 'utf8'); + const codefulWorkflows = await getCodefulWorkflowDataList(context, workflowFilePath, fileContent, baseUrl, apiVersion); + if (codefulWorkflows.length === 0) { + throw new Error(localize('noCodefulWorkflowsFound', 'No codeful workflows were found in this project.')); + } + + workflowPropertiesList = await Promise.all( + codefulWorkflows.map(async (workflowData) => { + const hasHttpTrigger = isHttpRequestTrigger(workflowData); + const workflowTriggerName = + workflowData.triggerName ?? + (baseUrl + ? await getCodefulTriggerName( + context, + workflowData.workflowName, + workflowFilePath, + fileContent, + hasHttpTrigger, + baseUrl, + apiVersion + ).catch(() => getFallbackCodefulTriggerName(fileContent, hasHttpTrigger)) + : getFallbackCodefulTriggerName(fileContent, hasHttpTrigger)); + const codefulWorkflowContent = createCodefulWorkflowContent(workflowData, workflowTriggerName, hasHttpTrigger); + const codefulCallbackInfo = + baseUrl && workflowTriggerName + ? await getCodefulWorkflowCallbackInfo( + context, + baseUrl, + workflowData.workflowName, + workflowTriggerName, + apiVersion, + hasHttpTrigger + ) + : undefined; + + return createWorkflowProperties( + workflowData.workflowName, + codefulWorkflowContent, + localSettings, + codefulCallbackInfo, + workflowTriggerName + ); + }) + ); + + workflowProps = workflowPropertiesList[0]; + workflowName = workflowProps.name; + triggerName = workflowProps.triggerName; + callbackInfo = workflowProps.callbackInfo; + workflowContent = createCodefulWorkflowContent( + { + workflowName: workflowProps.name, + workflowKind: workflowProps.kind ?? 'Stateful', + triggerName: workflowProps.triggerName, + }, + workflowProps.triggerName, + getCodefulWorkflowHasHttpTrigger(workflowProps) + ); + getCodefulCallbackInfoUpdates = async (baseUrl: string) => + await Promise.all( + (workflowPropertiesList ?? []).map(async (workflow) => ({ + workflowName: workflow.name, + callbackInfo: workflow.triggerName + ? await getCodefulWorkflowCallbackInfo( + context, + baseUrl, + workflow.name, + workflow.triggerName, + apiVersion, + getCodefulWorkflowHasHttpTrigger(workflow) + ) + : undefined, + })) + ); + } else { + // Codeless workflow + workflowName = basename(dirname(workflowFilePath)); + workflowContent = JSON.parse(readFileSync(workflowFilePath, 'utf8')); + triggerName = getTriggerName(workflowContent.definition); + getCallbackInfo = async (baseUrl: string) => + await getLocalWorkflowCallbackInfo(context, workflowContent.definition, baseUrl, workflowName, triggerName, apiVersion); + callbackInfo = baseUrl ? await getCallbackInfo(baseUrl) : undefined; + } + + const projectName = projectPath ? basename(projectPath) : basename(dirname(workflowFilePath)); + panelName = isCodefulOverview + ? `${vscode.workspace.name}-${projectName}-codeful-overview` + : `${vscode.workspace.name}-${workflowName}-overview`; + panelTitle = isCodefulOverview ? `${projectName}-overview` : `${workflowName}-overview`; getAccessToken = async () => await getAuthorizationToken(localSettings[workflowTenantIdKey]); accessToken = await getAccessToken(); if (projectPath) { @@ -92,6 +217,7 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod } else if (workflowNode instanceof RemoteWorkflowTreeItem) { workflowName = workflowNode.name; panelName = `${workflowNode.id}-${workflowName}-overview`; + panelTitle = `${workflowName}-overview`; workflowContent = workflowNode.workflowFileContent; getAccessToken = async () => await getAuthorizationTokenFromNode(workflowNode); getBaseUrl = () => getWorkflowManagementBaseURI(workflowNode); @@ -123,7 +249,6 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod if (!existingPanel.active) { existingPanel.reveal(vscode.ViewColumn.Active); } - return; } @@ -132,7 +257,7 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod retainContextWhenHidden: true, }; const { name, kind, operationOptions, statelessRunMode } = getStandardAppData(workflowName, workflowContent); - const workflowProps = { + workflowProps ??= { name, stateType: getWorkflowStateType(name, kind, localSettings), operationOptions, @@ -140,11 +265,12 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod callbackInfo, triggerName, definition: workflowContent.definition, + kind, }; const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel( 'workflowOverview', - `${workflowName}-overview`, + panelTitle || `${workflowName}-overview`, vscode.ViewColumn.Active, options ); @@ -173,11 +299,14 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod corsNotice, accessToken: accessToken, workflowProperties: workflowProps, + workflowPropertiesList, project: ProjectName.overview, hostVersion: ext.extensionVersion, isLocal: isLocal, azureDetails: azureDetails, - kind: kind, + kind: workflowProps.kind ?? kind, + isCodeful: isCodefulOverview, + supportsUnitTest: isLocal && localSettings['WORKFLOW_CODEFUL_ENABLED'] !== 'true', connectionData: connectionData, }, }); @@ -198,29 +327,79 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod } }, 5000); + let lastCheckedBaseUrl: string | undefined; + let consecutiveCallbackErrors = 0; + const MAX_CALLBACK_ERRORS = 3; + baseUrlInterval = setInterval(async () => { const updatedBaseUrl = getBaseUrl(); + + // Only process if baseUrl changed if (updatedBaseUrl !== baseUrl) { baseUrl = updatedBaseUrl; + lastCheckedBaseUrl = baseUrl; panel.webview.postMessage({ command: ExtensionCommand.update_runtime_base_url, data: { baseUrl, }, }); + // Reset error count when baseUrl changes + consecutiveCallbackErrors = 0; } - const updatedCallbackInfo = await getCallbackInfo(baseUrl); - if (updatedCallbackInfo?.value !== callbackInfo?.value || updatedCallbackInfo?.basePath !== callbackInfo?.basePath) { - callbackInfo = updatedCallbackInfo; - panel.webview.postMessage({ - command: ExtensionCommand.update_callback_info, - data: { - callbackInfo, - }, - }); + // Only fetch callback info when baseUrl changes or we haven't fetched yet + if ( + baseUrl && + (lastCheckedBaseUrl !== baseUrl || (callbackInfo === undefined && consecutiveCallbackErrors < MAX_CALLBACK_ERRORS)) + ) { + lastCheckedBaseUrl = baseUrl; + try { + if (isCodefulOverview && getCodefulCallbackInfoUpdates) { + const callbackInfoUpdates = await getCodefulCallbackInfoUpdates(baseUrl); + for (const update of callbackInfoUpdates) { + const workflowProperty = workflowPropertiesList?.find((workflow) => workflow.name === update.workflowName); + if ( + update.callbackInfo?.value !== workflowProperty?.callbackInfo?.value || + update.callbackInfo?.basePath !== workflowProperty?.callbackInfo?.basePath + ) { + if (workflowProperty) { + workflowProperty.callbackInfo = update.callbackInfo; + } + panel.webview.postMessage({ + command: ExtensionCommand.update_callback_info, + data: { + workflowName: update.workflowName, + callbackInfo: update.callbackInfo, + }, + }); + } + } + callbackInfo = workflowPropertiesList?.[0]?.callbackInfo; + } else if (getCallbackInfo) { + const updatedCallbackInfo = await getCallbackInfo(baseUrl); + if (updatedCallbackInfo?.value !== callbackInfo?.value || updatedCallbackInfo?.basePath !== callbackInfo?.basePath) { + callbackInfo = updatedCallbackInfo; + panel.webview.postMessage({ + command: ExtensionCommand.update_callback_info, + data: { + callbackInfo, + }, + }); + } + } + // Reset error count on success + consecutiveCallbackErrors = 0; + } catch { + consecutiveCallbackErrors++; + if (consecutiveCallbackErrors >= MAX_CALLBACK_ERRORS) { + ext.outputChannel.appendLog( + `Stopped fetching callback URL after ${MAX_CALLBACK_ERRORS} consecutive errors. Trigger may not exist for workflow '${workflowName}'.` + ); + } + } } - }, 3000); + }, 5000); break; } @@ -251,23 +430,280 @@ async function getLocalWorkflowCallbackInfo( ): Promise { const requestTriggerName = getRequestTriggerName(definition); if (requestTriggerName) { + if (baseUrl) { + try { + const url = `${baseUrl}/workflows/${workflowName}/triggers/${requestTriggerName}/listCallbackUrl?api-version=${apiVersion}`; + const response: string = await sendRequest(context, { + url, + method: HTTP_METHODS.POST, + }); + return JSON.parse(response); + } catch (error) { + // API call failed, log error and return undefined + ext.outputChannel.appendLog( + localize( + 'callbackUrlApiFailed', + 'Failed to get callback URL for workflow "{0}": {1}', + workflowName, + error instanceof Error ? error.message : String(error) + ) + ); + return undefined; + } + } + } else { + // For non-request triggers, provide the run endpoint + const fallbackBaseUrl = baseUrl || `http://localhost:7071${managementApiPrefix}`; + return { + value: `${fallbackBaseUrl}/workflows/${workflowName}/triggers/${triggerName}/run?api-version=${apiVersion}`, + method: HTTP_METHODS.POST, + }; + } +} + +async function getCodefulWorkflowCallbackInfo( + context: IActionContext, + baseUrl: string, + workflowName: string, + triggerName: string, + apiVersion: string, + hasRequestTrigger: boolean +): Promise { + // For HTTP request triggers, try to get the callback URL from the API + if (hasRequestTrigger) { + if (!baseUrl) { + ext.outputChannel.appendLog( + localize( + 'codefulCallbackUrlNoBaseUrl', + 'Cannot get callback URL for codeful workflow "{0}" with request trigger: baseUrl is not available. Make sure the workflow runtime is running.', + workflowName + ) + ); + return undefined; + } + try { - const url = `${baseUrl}/workflows/${workflowName}/triggers/${requestTriggerName}/listCallbackUrl?api-version=${apiVersion}`; + const url = `${baseUrl}/workflows/${workflowName}/triggers/${triggerName}/listCallbackUrl?api-version=${apiVersion}`; const response: string = await sendRequest(context, { url, method: HTTP_METHODS.POST, }); return JSON.parse(response); - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { + ext.outputChannel.appendLog( + localize( + 'codefulCallbackUrlApiFailed', + 'Failed to get callback URL for codeful workflow "{0}" trigger "{1}": {2}. Make sure the workflow is built and published.', + workflowName, + triggerName, + error instanceof Error ? error.message : String(error) + ) + ); return undefined; } - } else { - return { - value: `${baseUrl}/workflows/${workflowName}/triggers/${triggerName}/run?api-version=${apiVersion}`, - method: HTTP_METHODS.POST, - }; } + + // For non-request triggers, provide the run endpoint + const fallbackBaseUrl = baseUrl || `http://localhost:7071${managementApiPrefix}`; + return { + value: `${fallbackBaseUrl}/workflows/${workflowName}/triggers/${triggerName}/run?api-version=${apiVersion}`, + method: HTTP_METHODS.POST, + }; +} + +async function getCodefulWorkflowDataList( + context: IActionContext, + workflowFilePath: string, + workflowContent: string, + baseUrl: string | undefined, + apiVersion: string +): Promise { + if (baseUrl) { + const runtimeWorkflows = await getRuntimeCodefulWorkflows(context, baseUrl, apiVersion); + if (runtimeWorkflows.length > 0) { + return runtimeWorkflows; + } + } + + const hasHttpTrigger = hasHttpRequestTrigger(workflowContent); + const fallbackTriggerName = getFallbackCodefulTriggerName(workflowContent, hasHttpTrigger); + const workflowNames = getCodefulWorkflowNames(workflowFilePath); + if (workflowNames.length > 0) { + return workflowNames.map((workflowName) => ({ + workflowName, + workflowKind: 'Stateful', + triggerName: fallbackTriggerName, + triggerType: hasHttpTrigger ? 'Request' : undefined, + triggerKind: hasHttpTrigger ? 'Http' : undefined, + })); + } + + const workflowInfo = detectCodefulWorkflow(workflowContent); + return workflowInfo + ? [ + { + workflowName: workflowInfo.workflowName, + workflowKind: workflowInfo.workflowType === 'agent' ? 'Agent' : 'Stateful', + triggerName: fallbackTriggerName, + triggerType: hasHttpTrigger ? 'Request' : undefined, + triggerKind: hasHttpTrigger ? 'Http' : undefined, + }, + ] + : []; +} + +async function getRuntimeCodefulWorkflows(context: IActionContext, baseUrl: string, apiVersion: string): Promise { + const workflowsUrl = `${baseUrl}/workflows?api-version=${apiVersion}`; + const maxRetries = 4; + const initialDelayMs = 1000; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const workflowsResponse = await sendRequest(context, { + url: workflowsUrl, + method: HTTP_METHODS.GET, + }); + const parsed = JSON.parse(workflowsResponse); + const workflows: { name: string; kind?: string; triggers?: Record }[] = Array.isArray( + parsed + ) + ? parsed + : (parsed?.value ?? []); + + if (workflows.length > 0) { + return workflows.map((workflow) => { + const [runtimeTriggerName, trigger] = Object.entries(workflow.triggers ?? {})[0] ?? []; + return { + workflowName: workflow.name, + workflowKind: workflow.kind ?? 'Stateful', + triggerName: runtimeTriggerName, + triggerType: trigger?.type, + triggerKind: trigger?.kind, + }; + }); + } + } catch (error) { + if (attempt === maxRetries - 1) { + ext.outputChannel.appendLog( + localize( + 'codefulWorkflowListApiFailed', + 'Failed to get codeful workflows from the runtime: {0}', + error instanceof Error ? error.message : String(error) + ) + ); + } + } + + if (attempt < maxRetries - 1) { + await delay(initialDelayMs * 2 ** attempt); + } + } + + return []; +} + +function getCodefulWorkflowNames(filePath: string): string[] { + const workflowNames: string[] = []; + const visitedFiles = new Set(); + const projectDir = dirname(filePath); + + const extractWorkflowsFromFile = (currentFilePath: string): void => { + if (visitedFiles.has(currentFilePath)) { + return; + } + visitedFiles.add(currentFilePath); + + try { + const fileContent = readFileSync(currentFilePath, 'utf8'); + const workflowRegex = /(?:CreateConversationalAgent|CreateStatefulWorkflow)\s*\(\s*["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = workflowRegex.exec(fileContent)) !== null) { + const workflowName = match[1]; + if (workflowName && !workflowNames.includes(workflowName)) { + workflowNames.push(workflowName); + } + } + + const files = readdirSync(projectDir); + for (const file of files) { + if (file.endsWith('.cs') && file !== basename(currentFilePath)) { + extractWorkflowsFromFile(join(projectDir, file)); + } + } + } catch (error) { + ext.outputChannel.appendLog( + localize( + 'codefulWorkflowNameParseFailed', + 'Failed to parse codeful workflow names from "{0}": {1}', + currentFilePath, + error instanceof Error ? error.message : String(error) + ) + ); + } + }; + + extractWorkflowsFromFile(filePath); + return workflowNames; +} + +function getFallbackCodefulTriggerName(workflowContent: string, hasHttpTrigger: boolean): string | undefined { + return hasHttpTrigger ? extractHttpTriggerName(workflowContent) : extractTriggerNameFromCodeful(workflowContent); +} + +function isHttpRequestTrigger(workflowData: CodefulWorkflowData): boolean { + return workflowData.triggerType?.toLowerCase() === 'request' && workflowData.triggerKind?.toLowerCase() === 'http'; +} + +function getCodefulWorkflowHasHttpTrigger(workflowProperties: OverviewWorkflowProperties): boolean { + const trigger = workflowProperties.triggerName ? workflowProperties.definition?.triggers?.[workflowProperties.triggerName] : undefined; + return trigger?.type?.toLowerCase() === 'request' && trigger?.kind?.toLowerCase() === 'http'; +} + +function createCodefulWorkflowContent(workflowData: CodefulWorkflowData, triggerName: string | undefined, hasHttpTrigger: boolean): any { + return { + definition: { + $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', + contentVersion: '1.0.0.0', + triggers: triggerName + ? { + [triggerName]: hasHttpTrigger + ? { + type: 'Request', + kind: 'Http', + inputs: { + schema: {}, + }, + } + : { + type: 'Unknown', + }, + } + : {}, + actions: {}, + outputs: {}, + }, + kind: workflowData.workflowKind ?? 'Stateful', + }; +} + +function createWorkflowProperties( + workflowName: string, + workflowContent: any, + localSettings: Record, + callbackInfo: ICallbackUrlResponse | undefined, + triggerName: string | undefined +): OverviewWorkflowProperties { + const { name, kind, operationOptions, statelessRunMode } = getStandardAppData(workflowName, workflowContent); + return { + name, + stateType: getWorkflowStateType(name, kind, localSettings), + operationOptions, + statelessRunMode, + callbackInfo, + triggerName, + definition: workflowContent.definition, + kind, + }; } function normalizeLocation(location: string): string { @@ -288,3 +724,34 @@ function getWorkflowStateType(workflowName: string, kind: string, settings: Reco ? localize('logicapps.statelessDebug', 'Stateless (debug mode)') : localize('logicapps.stateless', 'Stateless'); } + +async function getCodefulTriggerName( + context: IActionContext, + workflowName: string, + workflowFilePath: string, + workflowContent: string, + hasHttpTrigger: boolean, + baseUrl: string, + apiVersion: string +): Promise { + const triggersUrl = `${baseUrl}/workflows/${workflowName}/triggers?api-version=${apiVersion}`; + const response: string = await sendRequest(context, { + url: triggersUrl, + method: HTTP_METHODS.GET, + }); + const triggersData = JSON.parse(response); + + if (triggersData?.value?.length > 0) { + return triggersData.value[0].name; + } + + const triggerNameFromWorkflow = hasHttpTrigger ? extractHttpTriggerName(workflowContent) : extractTriggerNameFromCodeful(workflowContent); + if (triggerNameFromWorkflow) { + return triggerNameFromWorkflow; + } + + const lspMetadata = await getCodefulWorkflowMetadata(workflowFilePath); + if (lspMetadata?.triggerName) { + return lspMetadata.triggerName; + } +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/openRunHistory.ts b/apps/vs-code-designer/src/app/commands/workflows/openRunHistory.ts deleted file mode 100644 index e8c9b264fcf..00000000000 --- a/apps/vs-code-designer/src/app/commands/workflows/openRunHistory.ts +++ /dev/null @@ -1,184 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { isNullOrUndefined } from '@microsoft/logic-apps-shared'; -import { localSettingsFileName, managementApiPrefix, workflowTenantIdKey } from '../../../constants'; -import { ext } from '../../../extensionVariables'; -import { getLocalSettingsJson } from '../../utils/appSettings/localSettings'; -import { cacheWebviewPanel, removeWebviewPanelFromCache, tryGetWebviewPanel } from '../../utils/codeless/common'; -import { getLogicAppProjectRoot } from '../../utils/codeless/connection'; -import { getAuthorizationToken } from '../../utils/codeless/getAuthorizationToken'; -import { getWebViewHTML } from '../../utils/codeless/getWebViewHTML'; -import type { IAzureConnectorsContext } from './azureConnectorWizard'; -import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; -import { readFileSync, readdirSync } from 'fs'; -import { basename, dirname, join } from 'path'; -import * as path from 'path'; -import * as vscode from 'vscode'; - -export async function openRunHistory(context: IAzureConnectorsContext, workflowNode: vscode.Uri): Promise { - const programFilePath: string = workflowNode.fsPath; - const programFileName = basename(dirname(programFilePath)); - const apiVersion = '2019-10-01-edge-preview'; - let corsNotice: string | undefined; - const projectPath = await getLogicAppProjectRoot(context, programFilePath); - const localSettings: Record = projectPath - ? (await getLocalSettingsJson(context, join(projectPath, localSettingsFileName))).Values || {} - : {}; - const isWorkflowRuntimeRunning = !isNullOrUndefined(ext.workflowRuntimePort); - const panelGroupKey = ext.webViewKey.runHistory; - const workflowNames = getWorkflowNames(workflowNode.fsPath); - const baseUrl = `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}`; - const panelName = `${vscode.workspace.name}-${programFileName}-run-history`; - const getAccessToken = async () => await getAuthorizationToken(localSettings[workflowTenantIdKey]); - let accessToken = await getAccessToken(); - - const existingPanel: vscode.WebviewPanel | undefined = tryGetWebviewPanel(panelGroupKey, panelName); - if (existingPanel) { - if (!existingPanel.active) { - existingPanel.reveal(vscode.ViewColumn.Active); - } - - return; - } - - const options: vscode.WebviewOptions & vscode.WebviewPanelOptions = { - enableScripts: true, - retainContextWhenHidden: true, - }; - - const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel( - 'workflowRunHistory', - `${programFileName}-run-history`, - vscode.ViewColumn.Active, - options - ); - - panel.iconPath = { - light: vscode.Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'Codeless.svg')), - dark: vscode.Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'Codeless.svg')), - }; - - panel.webview.html = await getWebViewHTML('vs-code-react', panel); - - let interval: NodeJS.Timeout; - panel.webview.onDidReceiveMessage(async (message) => { - switch (message.command) { - case ExtensionCommand.initialize: { - panel.webview.postMessage({ - command: ExtensionCommand.initialize_frame, - data: { - apiVersion: apiVersion, - baseUrl: baseUrl, - corsNotice, - accessToken: accessToken, - workflowProperties: {}, - project: ProjectName.runHistory, - hostVersion: ext.extensionVersion, - isLocal: true, - isWorkflowRuntimeRunning: isWorkflowRuntimeRunning, - workflowNames, - }, - }); - // Just shipping the access Token every 5 seconds is easier and more - // performant that asking for it every time and waiting. - interval = setInterval(async () => { - const updatedAccessToken = await getAccessToken(); - - if (updatedAccessToken !== accessToken) { - accessToken = updatedAccessToken; - panel.webview.postMessage({ - command: ExtensionCommand.update_access_token, - data: { - accessToken, - }, - }); - } - }, 5000); - break; - } - default: - break; - } - }, ext.context.subscriptions); - - panel.onDidDispose( - () => { - removeWebviewPanelFromCache(panelGroupKey, panelName); - clearInterval(interval); - }, - null, - ext.context.subscriptions - ); - cacheWebviewPanel(panelGroupKey, panelName, panel); -} - -/** - * Extracts workflow names from a C# file by finding createConversationalWorkflow or createStatefulWorkflow calls. - * @param filePath - The absolute path to the C# file to parse - * @returns An array of workflow names found in the file - */ -const getWorkflowNames = (filePath: string): string[] => { - const workflowNames: string[] = []; - const visitedFiles = new Set(); - const projectDir = dirname(filePath); - - const extractWorkflowsFromFile = (currentFilePath: string): void => { - // Prevent circular references and re-processing - if (visitedFiles.has(currentFilePath)) { - return; - } - visitedFiles.add(currentFilePath); - - try { - const fileContent = readFileSync(currentFilePath, 'utf8'); - - // Regex to match CreateConversationalAgent or CreateStatefulWorkflow calls - // Matches patterns like: CreateConversationalAgent("TestFlow") or CreateStatefulWorkflow("TestFlow", trigger) - // Supports both double quotes and single quotes, handles whitespace variations, and allows additional arguments - const workflowRegex = /(?:CreateConversationalAgent|CreateStatefulWorkflow)\s*\(\s*["']([^"']+)["']/g; - - let match: RegExpExecArray | null; - while ((match = workflowRegex.exec(fileContent)) !== null) { - const flowName = match[1]; - if (flowName && !workflowNames.includes(flowName)) { - workflowNames.push(flowName); - } - } - - // Find referenced .cs files in the same project - // Match patterns like: MethodName() or ClassName.MethodName() that might be in other files - // Look for using statements to find referenced files - const usingRegex = /using\s+(?:static\s+)?([A-Za-z0-9_.]+)/g; - const referencedNamespaces = new Set(); - - while ((match = usingRegex.exec(fileContent)) !== null) { - referencedNamespaces.add(match[1]); - } - - // Search for .cs files in the project directory that might contain workflow initialization - const csFilesPattern = /(?:^|\s)(?:public\s+)?(?:static\s+)?(?:void|Task|async\s+Task)\s+\w+\s*\(/g; - if (csFilesPattern.test(fileContent)) { - // This file has method definitions, look for other .cs files in the directory - try { - const files = readdirSync(projectDir); - - for (const file of files) { - if (file.endsWith('.cs') && file !== basename(currentFilePath)) { - const otherFilePath = join(projectDir, file); - extractWorkflowsFromFile(otherFilePath); - } - } - } catch (dirError) { - console.error(`Error reading directory ${projectDir}:`, dirError); - } - } - } catch (error) { - console.error(`Error reading workflow names from file ${currentFilePath}:`, error); - } - }; - - extractWorkflowsFromFile(filePath); - return workflowNames; -}; diff --git a/apps/vs-code-designer/src/app/languageServer/__test__/completionFilter.test.ts b/apps/vs-code-designer/src/app/languageServer/__test__/completionFilter.test.ts new file mode 100644 index 00000000000..a9c28a1da77 --- /dev/null +++ b/apps/vs-code-designer/src/app/languageServer/__test__/completionFilter.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { shouldHideCompletionItem, filterCompletionItems, filterCompletionResult } from '../completionFilter'; +import type * as vscode from 'vscode'; + +// ---------- helpers ---------- + +/** Minimal CompletionItem stub with a string label. */ +function itemStr(label: string): vscode.CompletionItem { + return { label } as vscode.CompletionItem; +} + +/** Minimal CompletionItem stub with a CompletionItemLabel object. */ +function itemObj(label: string, description?: string): vscode.CompletionItem { + return { label: { label, description } } as unknown as vscode.CompletionItem; +} + +// ---------- shouldHideCompletionItem ---------- + +describe('shouldHideCompletionItem', () => { + const hiddenNames = ['WorkflowBuiltInActions', 'WorkflowManagedActions', 'WorkflowBuiltInTriggers', 'WorkflowManagedTriggers']; + + it.each(hiddenNames)('hides %s (string label)', (name) => { + expect(shouldHideCompletionItem(itemStr(name))).toBe(true); + }); + + it.each(hiddenNames)('hides %s (object label)', (name) => { + expect(shouldHideCompletionItem(itemObj(name, 'Microsoft.Azure.Workflows.Sdk'))).toBe(true); + }); + + it('keeps WorkflowTriggers visible', () => { + expect(shouldHideCompletionItem(itemStr('WorkflowTriggers'))).toBe(false); + }); + + it('keeps WorkflowActions visible', () => { + expect(shouldHideCompletionItem(itemStr('WorkflowActions'))).toBe(false); + }); + + it('keeps unrelated items visible', () => { + expect(shouldHideCompletionItem(itemStr('Console'))).toBe(false); + expect(shouldHideCompletionItem(itemStr('string'))).toBe(false); + }); +}); + +// ---------- filterCompletionItems ---------- + +describe('filterCompletionItems', () => { + it('removes only hidden items from an array', () => { + const items = [ + itemStr('WorkflowTriggers'), + itemStr('WorkflowBuiltInTriggers'), + itemStr('WorkflowManagedTriggers'), + itemStr('WorkflowActions'), + itemStr('WorkflowBuiltInActions'), + itemStr('WorkflowManagedActions'), + itemStr('Console'), + ]; + + const result = filterCompletionItems(items); + const labels = result.map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); + + expect(labels).toEqual(['WorkflowTriggers', 'WorkflowActions', 'Console']); + }); + + it('returns empty array when all items are hidden', () => { + const items = [itemStr('WorkflowBuiltInTriggers'), itemStr('WorkflowManagedActions')]; + expect(filterCompletionItems(items)).toEqual([]); + }); + + it('returns same items when none should be hidden', () => { + const items = [itemStr('var'), itemStr('int')]; + expect(filterCompletionItems(items)).toHaveLength(2); + }); +}); + +// ---------- filterCompletionResult ---------- + +describe('filterCompletionResult', () => { + it('passes through undefined', () => { + expect(filterCompletionResult(undefined)).toBeUndefined(); + }); + + it('passes through null', () => { + expect(filterCompletionResult(null)).toBeNull(); + }); + + it('filters a plain array', () => { + const arr = [itemStr('WorkflowBuiltInTriggers'), itemStr('Console')]; + const result = filterCompletionResult(arr); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(1); + }); + + it('filters a CompletionList and preserves isIncomplete', () => { + const list: vscode.CompletionList = { + isIncomplete: true, + items: [itemStr('WorkflowManagedActions'), itemStr('Console'), itemStr('WorkflowActions')], + }; + + const result = filterCompletionResult(list) as vscode.CompletionList; + + expect(result.isIncomplete).toBe(true); + expect(result.items).toHaveLength(2); + const labels = result.items.map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); + expect(labels).toEqual(['Console', 'WorkflowActions']); + }); + + it('handles CompletionList with all items hidden', () => { + const list: vscode.CompletionList = { + isIncomplete: false, + items: [itemStr('WorkflowBuiltInActions')], + }; + + const result = filterCompletionResult(list) as vscode.CompletionList; + expect(result.items).toHaveLength(0); + expect(result.isIncomplete).toBe(false); + }); +}); diff --git a/apps/vs-code-designer/src/app/languageServer/completionFilter.ts b/apps/vs-code-designer/src/app/languageServer/completionFilter.ts new file mode 100644 index 00000000000..f01acc7d0c0 --- /dev/null +++ b/apps/vs-code-designer/src/app/languageServer/completionFilter.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; + +/** + * SDK type names that should be hidden from IntelliSense. + * + * These are the "inner" types exposed as static members on the gateway + * classes `WorkflowTriggers` and `WorkflowActions`. Users should access + * them via `WorkflowTriggers.BuiltIn` / `WorkflowActions.ManagedConnectors` + * etc., so the standalone class names just add noise to autocomplete. + */ +const HIDDEN_COMPLETION_LABELS: ReadonlySet = new Set([ + 'WorkflowBuiltInActions', + 'WorkflowManagedActions', + 'WorkflowBuiltInTriggers', + 'WorkflowManagedTriggers', +]); + +/** + * Extracts the plain-text label from a `CompletionItem`. + * VS Code labels can be either a plain string or a `CompletionItemLabel` object + * with a `.label` property. + */ +function getLabel(item: vscode.CompletionItem): string { + if (typeof item.label === 'string') { + return item.label; + } + return item.label?.label ?? ''; +} + +/** + * Returns `true` when the completion item should be suppressed. + */ +export function shouldHideCompletionItem(item: vscode.CompletionItem): boolean { + return HIDDEN_COMPLETION_LABELS.has(getLabel(item)); +} + +/** + * Filters an array of completion items, removing the redundant SDK types. + */ +export function filterCompletionItems(items: vscode.CompletionItem[]): vscode.CompletionItem[] { + return items.filter((item) => !shouldHideCompletionItem(item)); +} + +/** + * Filters the result returned by the upstream completion provider. + * + * The result can be: + * - `undefined | null` – pass through unchanged + * - `CompletionItem[]` – filter the array directly + * - `CompletionList` – filter the `.items` array and preserve `.isIncomplete` + */ +export function filterCompletionResult( + result: vscode.CompletionItem[] | vscode.CompletionList | undefined | null +): vscode.CompletionItem[] | vscode.CompletionList | undefined | null { + if (!result) { + return result; + } + + // CompletionList shape (has `items` and `isIncomplete`) + if ('items' in result && Array.isArray(result.items)) { + const filtered = filterCompletionItems(result.items); + // Preserve the original object shape to keep `isIncomplete` and any extra metadata + return { ...result, items: filtered }; + } + + // Plain array shape + if (Array.isArray(result)) { + return filterCompletionItems(result); + } + + return result; +} diff --git a/apps/vs-code-designer/src/app/languageServer/languageServer.ts b/apps/vs-code-designer/src/app/languageServer/languageServer.ts index f71a4852114..00341b37f5e 100644 --- a/apps/vs-code-designer/src/app/languageServer/languageServer.ts +++ b/apps/vs-code-designer/src/app/languageServer/languageServer.ts @@ -19,8 +19,9 @@ import { getGlobalSetting } from '../utils/vsCodeConfig/settings'; import type { AzureConnectorDetails } from '@microsoft/vscode-extension-logic-apps'; import { getAzureConnectorDetailsForLocalProject } from '../utils/codeless/common'; import * as vscode from 'vscode'; +import { filterCompletionResult } from './completionFilter'; -export default class LogicAppsSeverLanguage { +export default class LogicAppsLanguageServer { protected lspServerPath: string; protected sdkNupkgPath: string; protected apiVersion = workflowAppApiVersion; @@ -32,6 +33,10 @@ export default class LogicAppsSeverLanguage { } public async start(): Promise { + if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { + return; + } + const workspaceFolder = await getWorkspaceFolderPath(this.context); this.projectPath = await tryGetLogicAppProjectRoot(this.context, workspaceFolder, true /* suppressPrompt */); const { lspServerPath, sdkNupkgPath } = await this.getSDKPaths(); @@ -143,6 +148,10 @@ export default class LogicAppsSeverLanguage { }, middleware: { provideHover: hoverMiddleware.provideHover, + provideCompletionItem: async (document, position, context, token, next) => { + const result = await next(document, position, context, token); + return filterCompletionResult(result); + }, sendRequest: generalMiddleware.sendRequest, sendNotification: generalMiddleware.sendNotification, }, @@ -174,7 +183,7 @@ export default class LogicAppsSeverLanguage { const sdkFolderPath = path.join(dependenciesPath, lspDirectory); const files = await fse.readdir(sdkFolderPath); const sdkNupkgFile = files.find((file) => { - return file.startsWith('Microsoft.Azure.Workflows.Sdk.Agents.') && file.endsWith('.nupkg'); + return file.startsWith('Microsoft.Azure.Workflows.Sdk.') && file.endsWith('.nupkg'); }); const sdkNupkgPath = path.join(sdkFolderPath, sdkNupkgFile); @@ -237,7 +246,40 @@ export default class LogicAppsSeverLanguage { export const startLanguageServerProtocol = async () => { await callWithTelemetryAndErrorHandling(onStartLanguageServerProtocol, async (context: IActionContext) => { - const languageServer = new LogicAppsSeverLanguage(context); + const languageServer = new LogicAppsLanguageServer(context); languageServer.start(); }); }; + +/** + * Gets workflow metadata from the LSP server including trigger names. + * Uses a custom LSP request to query the workflow structure. + * @param workflowFilePath - The absolute path to the codeful workflow .cs file + * @returns Workflow metadata including workflow name and trigger name, or undefined if not available + */ +export async function getCodefulWorkflowMetadata( + workflowFilePath: string +): Promise<{ workflowName: string; triggerName: string } | undefined> { + if (!ext.languageClient) { + console.warn('[LSP] Language client not initialized'); + return undefined; + } + + try { + // Use the custom LSP request to get workflow metadata + const response = await ext.languageClient.sendRequest<{ workflowName: string; triggerName: string } | null>( + 'custom/getWorkflowMetadata', + { uri: workflowFilePath } + ); + + if (!response) { + console.warn('[LSP] No workflow metadata returned from server'); + return undefined; + } + + return response; + } catch (error) { + console.error('[LSP] Failed to get workflow metadata:', error); + return undefined; + } +} diff --git a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts index 244dac6fc3f..a7bfb3f5371 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts @@ -4,6 +4,7 @@ import { getLatestVersionRange, addDefaultBundle, downloadExtensionBundle, + resetCachedBundleVersion } from '../bundleFeed'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as fse from 'fs-extra'; @@ -333,6 +334,7 @@ describe('getBundleVersionNumber', () => { beforeEach(() => { vi.clearAllMocks(); + resetCachedBundleVersion(); // Mock getExtensionBundleFolder to return a proper Windows path const mockCommandOutput = `C:\\mock\\bundle\\root\\ExtensionBundles\\${extensionBundleId}\\1.0.0\n`; mockedExecuteCommand.mockResolvedValue(mockCommandOutput); diff --git a/apps/vs-code-designer/src/app/utils/__test__/codeful.test.ts b/apps/vs-code-designer/src/app/utils/__test__/codeful.test.ts new file mode 100644 index 00000000000..654c26fee0c --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/__test__/codeful.test.ts @@ -0,0 +1,735 @@ +import * as fse from 'fs-extra'; +import * as path from 'path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + hasCodefulWorkflowSetting, + isCodefulProject, + detectStatefulCodefulWorkflow, + detectAgentCodefulWorkflow, + detectCodefulWorkflow, + extractTriggerNameFromCodeful, + hasHttpRequestTrigger, + extractHttpTriggerName, +} from '../codeful'; +import { localSettingsFileName } from '../../../constants'; + +vi.mock('fs-extra', () => ({ + default: { + pathExists: vi.fn(), + readFile: vi.fn(), + statSync: vi.fn(), + readdir: vi.fn(), + }, + pathExists: vi.fn(), + readFile: vi.fn(), + statSync: vi.fn(), + readdir: vi.fn(), +})); + +describe('codeful.ts', () => { + const mockedFse = vi.mocked(fse); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('hasCodefulWorkflowSetting', () => { + const testFolderPath = '/test/folder'; + const localSettingsPath = path.join(testFolderPath, localSettingsFileName); + + it('should return false when local settings file does not exist', async () => { + mockedFse.pathExists.mockResolvedValue(false); + + const result = await hasCodefulWorkflowSetting(testFolderPath); + + expect(result).toBe(false); + expect(mockedFse.pathExists).toHaveBeenCalledWith(localSettingsPath); + }); + + it('should return true when WORKFLOW_CODEFUL_ENABLED is true', async () => { + const localSettings = { + IsEncrypted: false, + Values: { + WORKFLOW_CODEFUL_ENABLED: true, + }, + }; + + mockedFse.pathExists.mockResolvedValue(true); + mockedFse.readFile.mockResolvedValue(JSON.stringify(localSettings)); + + const result = await hasCodefulWorkflowSetting(testFolderPath); + + expect(result).toBe(true); + expect(mockedFse.readFile).toHaveBeenCalledWith(localSettingsPath, 'utf-8'); + }); + + it('should return false when WORKFLOW_CODEFUL_ENABLED is false', async () => { + const localSettings = { + IsEncrypted: false, + Values: { + WORKFLOW_CODEFUL_ENABLED: false, + }, + }; + + mockedFse.pathExists.mockResolvedValue(true); + mockedFse.readFile.mockResolvedValue(JSON.stringify(localSettings)); + + const result = await hasCodefulWorkflowSetting(testFolderPath); + + expect(result).toBe(false); + }); + + it('should return false when WORKFLOW_CODEFUL_ENABLED is undefined', async () => { + const localSettings = { + IsEncrypted: false, + Values: {}, + }; + + mockedFse.pathExists.mockResolvedValue(true); + mockedFse.readFile.mockResolvedValue(JSON.stringify(localSettings)); + + const result = await hasCodefulWorkflowSetting(testFolderPath); + + expect(result).toBeFalsy(); + }); + + it('should return false when JSON parsing fails', async () => { + mockedFse.pathExists.mockResolvedValue(true); + mockedFse.readFile.mockResolvedValue('invalid json {{{'); + + const result = await hasCodefulWorkflowSetting(testFolderPath); + + expect(result).toBe(false); + }); + + it('should return false when file read fails', async () => { + mockedFse.pathExists.mockResolvedValue(true); + mockedFse.readFile.mockRejectedValue(new Error('Read error')); + + const result = await hasCodefulWorkflowSetting(testFolderPath); + + expect(result).toBe(false); + }); + }); + + describe('isCodefulProject', () => { + const testFolderPath = '/test/project'; + + it('should return false when path is not a directory', async () => { + mockedFse.statSync.mockReturnValue({ + isDirectory: () => false, + } as any); + + const result = await isCodefulProject(testFolderPath); + + expect(result).toBe(false); + }); + + it('should return false when no .csproj file exists', async () => { + mockedFse.statSync.mockReturnValue({ + isDirectory: () => true, + } as any); + mockedFse.readdir.mockResolvedValue(['Program.cs', 'Startup.cs'] as any); + + const result = await isCodefulProject(testFolderPath); + + expect(result).toBe(false); + }); + + it('should return false when .csproj is not targeting net8', async () => { + const csprojContent = ` + + net6.0 + + + `; + + mockedFse.statSync.mockReturnValue({ + isDirectory: () => true, + } as any); + mockedFse.readdir.mockResolvedValue(['TestProject.csproj', 'Program.cs'] as any); + mockedFse.readFile.mockResolvedValue(csprojContent); + + const result = await isCodefulProject(testFolderPath); + + expect(result).toBe(false); + }); + + it('should return false when .csproj does not include Microsoft.Azure.Workflows.Sdk', async () => { + const csprojContent = ` + + net8 + + + `; + + mockedFse.statSync.mockReturnValue({ + isDirectory: () => true, + } as any); + mockedFse.readdir.mockResolvedValue(['TestProject.csproj'] as any); + mockedFse.readFile.mockResolvedValue(csprojContent); + + const result = await isCodefulProject(testFolderPath); + + expect(result).toBe(false); + }); + + it('should return true when .csproj is a valid codeful net8 project', async () => { + const csprojContent = ` + + + net8 + v4 + + + + + + `; + + mockedFse.statSync.mockReturnValue({ + isDirectory: () => true, + } as any); + mockedFse.readdir.mockResolvedValue(['TestProject.csproj', 'Program.cs'] as any); + mockedFse.readFile.mockResolvedValue(csprojContent); + + const result = await isCodefulProject(testFolderPath); + + expect(result).toBe(true); + }); + + it('should use the first .csproj file found', async () => { + const csprojContent = ` + + net8 + + + `; + + mockedFse.statSync.mockReturnValue({ + isDirectory: () => true, + } as any); + mockedFse.readdir.mockResolvedValue(['First.csproj', 'Second.csproj'] as any); + mockedFse.readFile.mockResolvedValue(csprojContent); + + await isCodefulProject(testFolderPath); + + expect(mockedFse.readFile).toHaveBeenCalledWith(path.join(testFolderPath, 'First.csproj'), 'utf-8'); + }); + }); + + describe('detectStatefulCodefulWorkflow', () => { + it('should detect workflow name with string literal', () => { + const fileContent = ` + public static class MyWorkflow + { + public static void AddWorkflow() + { + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow("TestWorkflow", builder => + { + builder.AddTrigger(trigger); + }); + } + } + `; + + const result = detectStatefulCodefulWorkflow(fileContent); + + expect(result).toBe('TestWorkflow'); + }); + + it('should detect workflow name with single quotes', () => { + const fileContent = ` + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow('MyWorkflow', builder => {}); + `; + + const result = detectStatefulCodefulWorkflow(fileContent); + + expect(result).toBe('MyWorkflow'); + }); + + it('should detect workflow name with variable identifier', () => { + const fileContent = ` + string workflowName = "DynamicWorkflow"; + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow(workflowName, builder => {}); + `; + + const result = detectStatefulCodefulWorkflow(fileContent); + + // The function extracts the variable name, not its value + expect(result).toBe('workflowName'); + }); + + it('should return undefined for template placeholder', () => { + const fileContent = ` + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow(<%= flowName %>, builder => {}); + `; + + const result = detectStatefulCodefulWorkflow(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when no CreateStatefulWorkflow call exists', () => { + const fileContent = ` + public static class MyClass + { + public void SomeMethod() {} + } + `; + + const result = detectStatefulCodefulWorkflow(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should handle whitespace variations', () => { + const fileContent = ` + WorkflowBuilderFactory.CreateStatefulWorkflow ( "SpacedWorkflow" , builder => {}); + `; + + const result = detectStatefulCodefulWorkflow(fileContent); + + expect(result).toBe('SpacedWorkflow'); + }); + it('should detect workflow name across multiple lines', () => { + const fileContent = ` + var workflow = WorkflowBuilderFactory + .CreateStatefulWorkflow( + "MultilineWorkflow", + builder => {} + ); + `; + + const result = detectStatefulCodefulWorkflow(fileContent); + + expect(result).toBe('MultilineWorkflow'); + }); + }); + + describe('detectAgentCodefulWorkflow', () => { + it('should detect agent workflow name with string literal', () => { + const fileContent = ` + public static class AgentWorkflow + { + public static void AddWorkflow() + { + var agent = WorkflowBuilderFactory.CreateConversationalAgent("TestAgent"); + } + } + `; + + const result = detectAgentCodefulWorkflow(fileContent); + + expect(result).toBe('TestAgent'); + }); + + it('should detect agent workflow name with single quotes', () => { + const fileContent = ` + var agent = WorkflowBuilderFactory.CreateConversationalAgent('MyAgent'); + `; + + const result = detectAgentCodefulWorkflow(fileContent); + + expect(result).toBe('MyAgent'); + }); + + it('should detect agent workflow name with variable identifier', () => { + const fileContent = ` + string agentName = "DynamicAgent"; + var agent = WorkflowBuilderFactory.CreateConversationalAgent(agentName); + `; + + const result = detectAgentCodefulWorkflow(fileContent); + + // The function extracts the variable name, not its value + expect(result).toBe('agentName'); + }); + + it('should return undefined for template placeholder', () => { + const fileContent = ` + var agent = WorkflowBuilderFactory.CreateConversationalAgent(<%= flowName %>); + `; + + const result = detectAgentCodefulWorkflow(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when no CreateConversationalAgent call exists', () => { + const fileContent = ` + public static class MyClass + { + public void SomeMethod() {} + } + `; + + const result = detectAgentCodefulWorkflow(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should handle whitespace variations', () => { + const fileContent = ` + WorkflowBuilderFactory.CreateConversationalAgent ( "SpacedAgent" ); + `; + + const result = detectAgentCodefulWorkflow(fileContent); + + expect(result).toBe('SpacedAgent'); + }); + it('should detect agent workflow name across multiple lines', () => { + const fileContent = ` + var agent = WorkflowBuilderFactory + .CreateConversationalAgent( + "MultilineAgent" + ); + `; + + const result = detectAgentCodefulWorkflow(fileContent); + + expect(result).toBe('MultilineAgent'); + }); + }); + + describe('detectCodefulWorkflow', () => { + it('should detect stateful workflow and return correct type', () => { + const fileContent = ` + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow("StatefulWorkflow", builder => {}); + `; + + const result = detectCodefulWorkflow(fileContent); + + expect(result).toEqual({ + workflowName: 'StatefulWorkflow', + workflowType: 'stateful', + }); + }); + + it('should detect agent workflow and return correct type', () => { + const fileContent = ` + var agent = WorkflowBuilderFactory.CreateConversationalAgent("AgentWorkflow"); + `; + + const result = detectCodefulWorkflow(fileContent); + + expect(result).toEqual({ + workflowName: 'AgentWorkflow', + workflowType: 'agent', + }); + }); + + it('should prioritize stateful workflow when both patterns exist', () => { + const fileContent = ` + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow("StatefulWorkflow", builder => {}); + var agent = WorkflowBuilderFactory.CreateConversationalAgent("AgentWorkflow"); + `; + + const result = detectCodefulWorkflow(fileContent); + + expect(result).toEqual({ + workflowName: 'StatefulWorkflow', + workflowType: 'stateful', + }); + }); + + it('should return undefined when no workflow patterns are found', () => { + const fileContent = ` + public static class MyClass + { + public void SomeMethod() {} + } + `; + + const result = detectCodefulWorkflow(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when template placeholders are used', () => { + const fileContent = ` + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow(<%= flowName %>, builder => {}); + `; + + const result = detectCodefulWorkflow(fileContent); + + expect(result).toBeUndefined(); + }); + }); + + describe('extractTriggerNameFromCodeful', () => { + it('should extract from variable name when trigger method is called', () => { + const fileContent = ` + var httpTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + `; + + const result = extractTriggerNameFromCodeful(fileContent); + + expect(result).toBe('httpTrigger'); + }); + + it('should return undefined when no trigger patterns are found', () => { + const fileContent = ` + public static class MyClass + { + public void SomeMethod() {} + } + `; + + const result = extractTriggerNameFromCodeful(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should extract variable name from different WorkflowTriggers patterns', () => { + const fileContent = ` + var recurrenceTrigger = WorkflowTriggers.BuiltIn.CreateRecurrenceTrigger(); + `; + + const result = extractTriggerNameFromCodeful(fileContent); + + expect(result).toBe('recurrenceTrigger'); + }); + + it('should ignore trigger names in single-line comments', () => { + const fileContent = ` + // var oldTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + // trigger.WithName("weather_trigger"); + var httpTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + `; + + const result = extractTriggerNameFromCodeful(fileContent); + + expect(result).toBe('httpTrigger'); + }); + + it('should ignore trigger names in multi-line block comments', () => { + const fileContent = ` + /* + var oldTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + trigger.WithName("weather_trigger"); + */ + var httpTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + `; + + const result = extractTriggerNameFromCodeful(fileContent); + + expect(result).toBe('httpTrigger'); + }); + + it('should ignore trigger names in inline block comments', () => { + const fileContent = ` + /* trigger.WithName("old_trigger"); */ + var httpTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + `; + + const result = extractTriggerNameFromCodeful(fileContent); + + expect(result).toBe('httpTrigger'); + }); + }); + + describe('hasHttpRequestTrigger', () => { + it('should return true when HTTP trigger is present', () => { + const fileContent = ` + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger("httpTrigger"); + `; + + const result = hasHttpRequestTrigger(fileContent); + + expect(result).toBe(true); + }); + + it('should return false when HTTP trigger is not present', () => { + const fileContent = ` + var trigger = WorkflowTriggers.BuiltIn.CreateRecurrenceTrigger(); + `; + + const result = hasHttpRequestTrigger(fileContent); + + expect(result).toBe(false); + }); + + it('should return true when HTTP trigger pattern exists anywhere in content', () => { + const fileContent = ` + public static class MyWorkflow + { + public static void AddWorkflow() + { + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + var workflow = WorkflowBuilderFactory.CreateStatefulWorkflow("Test", builder => + { + builder.AddTrigger(trigger); + }); + } + } + `; + + const result = hasHttpRequestTrigger(fileContent); + + expect(result).toBe(true); + }); + + it('should return false for empty string', () => { + const result = hasHttpRequestTrigger(''); + + expect(result).toBe(false); + }); + + it('should return false when HTTP trigger is only in single-line comments', () => { + const fileContent = ` + // var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + var trigger = WorkflowTriggers.BuiltIn.CreateRecurrenceTrigger(); + `; + + const result = hasHttpRequestTrigger(fileContent); + + expect(result).toBe(false); + }); + + it('should return false when HTTP trigger is only in multi-line block comments', () => { + const fileContent = ` + /* + var oldTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + */ + var trigger = WorkflowTriggers.BuiltIn.CreateRecurrenceTrigger(); + `; + + const result = hasHttpRequestTrigger(fileContent); + + expect(result).toBe(false); + }); + + it('should return true when HTTP trigger is active and also present in comments', () => { + const fileContent = ` + // var oldTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + `; + + const result = hasHttpRequestTrigger(fileContent); + + expect(result).toBe(true); + }); + }); + + describe('extractHttpTriggerName', () => { + it('should extract HTTP trigger name from CreateHttpTrigger call', () => { + const fileContent = ` + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger("manual", options); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBe('manual'); + }); + + it('should extract HTTP trigger name with single quotes', () => { + const fileContent = ` + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger('httpTrigger', options); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBe('httpTrigger'); + }); + + it('should handle whitespace variations', () => { + const fileContent = ` + WorkflowTriggers.BuiltIn.CreateHttpTrigger ( "myTrigger" , options ); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBe('myTrigger'); + }); + + it('should return "manual" when no HTTP trigger with explicit name is found', () => { + const fileContent = ` + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when no HTTP trigger exists', () => { + const fileContent = ` + public static class MyClass + { + public void SomeMethod() {} + } + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should extract from multiline code with line breaks', () => { + const fileContent = ` + var trigger = WorkflowTriggers + .BuiltIn + .CreateHttpTrigger( + "requestTrigger", + new HttpTriggerOptions() + ); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBe('requestTrigger'); + }); + + it('should ignore HTTP trigger names in single-line comments', () => { + const fileContent = ` + // var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger("old_trigger"); + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger("new_trigger"); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBe('new_trigger'); + }); + + it('should ignore HTTP trigger names in multi-line block comments', () => { + const fileContent = ` + /* + var oldTrigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger("commented_trigger"); + */ + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger("active_trigger"); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBe('active_trigger'); + }); + + it('should return undefined when CreateHttpTrigger is called without name parameter', () => { + const fileContent = ` + var trigger = WorkflowTriggers.BuiltIn.CreateHttpTrigger(); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when CreateHttpTrigger is called with empty parentheses across lines', () => { + const fileContent = ` + var trigger = WorkflowTriggers + .BuiltIn + .CreateHttpTrigger( + ); + `; + + const result = extractHttpTriggerName(fileContent); + + expect(result).toBeUndefined(); + }); + }); +}); 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..9d3d0b78a72 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 @@ -9,7 +9,7 @@ describe('debug', () => { vi.clearAllMocks(); }); - describe('with custom code target framework', () => { + describe('with custom code target framework (default isCodeless=true)', () => { it('should return launch configuration for .NET 8 custom code with v4 function runtime', () => { const result = getDebugConfiguration(FuncVersion.v4, 'TestLogicApp', TargetFramework.Net8); expect(result).toEqual({ @@ -62,6 +62,34 @@ describe('debug', () => { }); }); + describe('with codeful project (isCodeless=false)', () => { + it('should return launch configuration without customCodeRuntime for .NET 8 codeful with v4 runtime', () => { + const result = getDebugConfiguration(FuncVersion.v4, 'CodefulApp', TargetFramework.Net8, false); + + expect(result).toEqual({ + name: 'Run/Debug logic app CodefulApp', + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + isCodeless: false, + }); + expect(result).not.toHaveProperty('customCodeRuntime'); + }); + + it('should return launch configuration without customCodeRuntime for .NET Framework codeful with v1 runtime', () => { + const result = getDebugConfiguration(FuncVersion.v1, 'CodefulApp', TargetFramework.NetFx, false); + + expect(result).toEqual({ + name: 'Run/Debug logic app CodefulApp', + type: 'logicapp', + request: 'launch', + funcRuntime: 'clr', + isCodeless: false, + }); + expect(result).not.toHaveProperty('customCodeRuntime'); + }); + }); + describe('without custom code target framework', () => { it('should return attach configuration for v1 function runtime', () => { const result = getDebugConfiguration(FuncVersion.v1, 'TestLogicApp'); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts index 845a3bdfe26..a0e0d19e259 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts @@ -200,9 +200,9 @@ export const getLocalSettingsSchema = (isDesignTime: boolean, projectPath?: stri // Add codeful-specific settings if (isCodeful) { Object.assign(baseSettings.Values, { - AzureFunctionsJobHost__extensionBundle__version: '[1.141.0.12]', + AzureFunctionsJobHost__extensionBundle__version: '[1.160.24]', WORKFLOW_CODEFUL_ENABLED: 'true', - FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI: 'https://cdnforlogicappsv2.blob.core.windows.net/apseth-test', + FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI: 'https://cdnforlogicappsv2.blob.core.windows.net/la-sdk-private', }); } diff --git a/apps/vs-code-designer/src/app/utils/bundleFeed.ts b/apps/vs-code-designer/src/app/utils/bundleFeed.ts index 05a95f9eae9..44d3bf943fa 100644 --- a/apps/vs-code-designer/src/app/utils/bundleFeed.ts +++ b/apps/vs-code-designer/src/app/utils/bundleFeed.ts @@ -260,6 +260,12 @@ function getMaxVersion(version1, version2): string { return maxVersion; } +let cachedBundleVersion: string | null = null; + +export function resetCachedBundleVersion(): void { + cachedBundleVersion = null; +} + /** * Retrieves the highest version number of the extension bundle available in the bundle folder. * @@ -271,6 +277,11 @@ function getMaxVersion(version1, version2): string { * @throws {Error} If the extension bundle folder is missing or contains no subdirectories. */ export async function getBundleVersionNumber(): Promise { + // Return cached version if available (saves ~450ms on subsequent calls) + if (cachedBundleVersion) { + return cachedBundleVersion; + } + const bundleFolderRoot = await getExtensionBundleFolder(); const bundleFolder = path.join(bundleFolderRoot, extensionBundleId); let bundleVersionNumber = '0.0.0'; @@ -287,6 +298,8 @@ export async function getBundleVersionNumber(): Promise { } } + // Cache the result + cachedBundleVersion = bundleVersionNumber; return bundleVersionNumber; } diff --git a/apps/vs-code-designer/src/app/utils/codeful.ts b/apps/vs-code-designer/src/app/utils/codeful.ts index fd39f8d0448..f9859114472 100644 --- a/apps/vs-code-designer/src/app/utils/codeful.ts +++ b/apps/vs-code-designer/src/app/utils/codeful.ts @@ -11,7 +11,7 @@ import { localSettingsFileName } from '../../constants'; * folder path and checks for the WORKFLOW_CODEFUL_ENABLED flag in the Values section. * Returns false if the file doesn't exist, cannot be read, or doesn't contain valid JSON. */ -export const hasCodefulWorkflowSetting = async (folderPath: string) => { +export const hasCodefulWorkflowSetting = async (folderPath: string): Promise => { const localSettingsFilePath = path.join(folderPath, localSettingsFileName); if (!(await fse.pathExists(localSettingsFilePath))) { return false; @@ -20,7 +20,7 @@ export const hasCodefulWorkflowSetting = async (folderPath: string) => { try { const localSettingsData = await fse.readFile(localSettingsFilePath, 'utf-8'); const localSettings = JSON.parse(localSettingsData); - return localSettings.Values?.WORKFLOW_CODEFUL_ENABLED; + return localSettings.Values?.WORKFLOW_CODEFUL_ENABLED ? true : false; } catch { return false; } @@ -49,10 +49,160 @@ export const isCodefulProject = async (folderPath: string): Promise => * Checks if a C# project file (.csproj) is configured for a codeful .NET 8 Azure Logic Apps workflow. * * @param csprojContent - The content of the .csproj file as a string - * @returns `true` if the project targets .NET 8 and includes the Microsoft.Azure.Workflows.Sdk.Agents package, `false` otherwise + * @returns `true` if the project targets .NET 8 and includes the Microsoft.Azure.Workflows.Sdk package, `false` otherwise */ const isCodefulNet8Csproj = (csprojContent: string): boolean => { - return ( - csprojContent.includes('net8') && csprojContent.includes('Microsoft.Azure.Workflows.Sdk.Agents') - ); + return csprojContent.includes('net8') && csprojContent.includes('Microsoft.Azure.Workflows.Sdk'); +}; + +/** + * Detects if a C# file contains a CreateStatefulWorkflow call and extracts the workflow name. + * @param fileContent - The content of the C# file + * @returns The workflow name if detected, undefined otherwise + */ +export const detectStatefulCodefulWorkflow = (fileContent: string): string | undefined => { + // Pattern to match: WorkflowBuilderFactory.CreateStatefulWorkflow(, ...) + // This handles: variables, string literals, template placeholders like <%= flowName %> + // Using [\s\S]*? to match across line breaks + const pattern = /WorkflowBuilderFactory[\s\S]*?\.CreateStatefulWorkflow\s*\(\s*([^,)]+)/; + const match = fileContent.match(pattern); + + if (match && match[1]) { + // Extract the workflow name and clean it up (remove quotes, trim whitespace) + let workflowName = match[1].trim(); + + // Remove string quotes if present + workflowName = workflowName.replace(/^["']|["']$/g, ''); + + // If it's a template placeholder like <%= flowName %>, we can't determine the actual name + // In this case, return undefined since it's a template + if (workflowName.includes('<%=') || workflowName.includes('%>')) { + return undefined; + } + + return workflowName; + } + + return undefined; +}; + +/** + * Detects if a C# file contains a CreateConversationalAgent call and extracts the workflow name. + * @param fileContent - The content of the C# file + * @returns The workflow name if detected, undefined otherwise + */ +export const detectAgentCodefulWorkflow = (fileContent: string): string | undefined => { + // Pattern to match: WorkflowBuilderFactory.CreateConversationalAgent() + // This handles: variables, string literals, template placeholders like <%= flowName %> + // Using [\s\S]*? to match across line breaks + const pattern = /WorkflowBuilderFactory[\s\S]*?\.CreateConversationalAgent\s*\(\s*([^,)]+)/; + const match = fileContent.match(pattern); + + if (match && match[1]) { + // Extract the workflow name and clean it up (remove quotes, trim whitespace) + let workflowName = match[1].trim(); + + // Remove string quotes if present + workflowName = workflowName.replace(/^["']|["']$/g, ''); + + // If it's a template placeholder like <%= flowName %>, we can't determine the actual name + // In this case, return undefined since it's a template + if (workflowName.includes('<%=') || workflowName.includes('%>')) { + return undefined; + } + + return workflowName; + } + + return undefined; +}; + +/** + * Detects if a C# file is a codeful workflow file and extracts the workflow name. + * Checks for both stateful and agent workflow patterns. + * @param fileContent - The content of the C# file + * @returns An object with the workflow name and type if detected, undefined otherwise + */ +export const detectCodefulWorkflow = (fileContent: string): { workflowName: string; workflowType: 'stateful' | 'agent' } | undefined => { + const statefulWorkflowName = detectStatefulCodefulWorkflow(fileContent); + if (statefulWorkflowName) { + return { workflowName: statefulWorkflowName, workflowType: 'stateful' }; + } + + const agentWorkflowName = detectAgentCodefulWorkflow(fileContent); + if (agentWorkflowName) { + return { workflowName: agentWorkflowName, workflowType: 'agent' }; + } + + return undefined; +}; + +/** + * Extracts the trigger name from a codeful C# file. + * Looks for WorkflowTriggers patterns and extracts the trigger name. + * @param fileContent - The content of the C# file + * @returns The trigger name if found, undefined otherwise + */ +export const extractTriggerNameFromCodeful = (fileContent: string): string | undefined => { + // Remove single-line comments (//) and multi-line comments (/* */) to avoid matching commented code + const uncommentedContent = fileContent + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments + .replace(/\/\/.*/g, ''); // Remove // comments + + // Look for .WithName("triggerName") pattern which sets the trigger name + const withNamePattern = /\.WithName\s*\(\s*["']([^"']+)["']\s*\)/; + const withNameMatch = uncommentedContent.match(withNamePattern); + + if (withNameMatch && withNameMatch[1]) { + return withNameMatch[1]; + } + + // If no explicit name is set, try to extract from the trigger variable name + const triggerVarPattern = /var\s+(\w+)\s*=\s*WorkflowTriggers\./; + const triggerVarMatch = uncommentedContent.match(triggerVarPattern); + + if (triggerVarMatch && triggerVarMatch[1]) { + return triggerVarMatch[1]; + } + + return undefined; +}; + +/** + * Detects if the codeful workflow has an HTTP request trigger. + * @param fileContent - The content of the C# file + * @returns true if the workflow has an HTTP request trigger, false otherwise + */ +export const hasHttpRequestTrigger = (fileContent: string): boolean => { + // Remove single-line comments (//) and multi-line comments (/* */) to avoid matching commented code + const uncommentedContent = fileContent + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments + .replace(/\/\/.*/g, ''); // Remove // comments + + return uncommentedContent.includes('WorkflowTriggers.BuiltIn.CreateHttpTrigger'); +}; + +/** + * Extracts the HTTP trigger name from WorkflowTriggers.BuiltIn.CreateHttpTrigger() call. + * @param fileContent - The content of the C# file + * @returns The HTTP trigger name if found, undefined otherwise + */ +export const extractHttpTriggerName = (fileContent: string): string | undefined => { + // Remove single-line comments (//) and multi-line comments (/* */) to avoid matching commented code + const uncommentedContent = fileContent + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments + .replace(/\/\/.*/g, ''); // Remove // comments + + // Pattern to match: WorkflowTriggers.BuiltIn.CreateHttpTrigger("triggerName", ...) + // Using [\s\S]*? to match any characters including newlines between parts + const pattern = /WorkflowTriggers[\s\S]*?\.BuiltIn[\s\S]*?\.CreateHttpTrigger\s*\(\s*["']([^"']+)["']/; + const match = uncommentedContent.match(pattern); + + if (match && match[1]) { + return match[1]; + } + + // If CreateHttpTrigger() is called without a name parameter, return undefined + // The caller should query the LSP server to get the SDK-generated default name + return undefined; }; diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/connection.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/connection.test.ts new file mode 100644 index 00000000000..e34c04a69f4 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/connection.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Create mock functions +const mockUpdateConnectionReferenceWithMSIInLocalSettings = vi.fn(); +const mockWriteLocalSettingsFile = vi.fn(); +const mockGetLocalSettingsJson = vi.fn(); + +// Mock dependencies +vi.mock('../../../appSettings/localSettings', () => ({ + getLocalSettingsJson: mockGetLocalSettingsJson, + writeLocalSettingsFile: mockWriteLocalSettingsFile, +})); + +// Import the module after mocks are set up +const mockModule = await import('../connection'); + +describe('updateConnectionReferencesLocalMSI - parallel processing', () => { + const testProjectPath = '/test/project'; + const testConnectionReferences = { + 'msnweather-1': { + api: { id: 'msnweather' }, + connection: { id: '/subscriptions/test/connections/msnweather-1' }, + connectionProperties: {}, + }, + 'office365-2': { + api: { id: 'office365' }, + connection: { id: '/subscriptions/test/connections/office365-2' }, + connectionProperties: {}, + }, + 'sql-3': { + api: { id: 'sql' }, + connection: { id: '/subscriptions/test/connections/sql-3' }, + connectionProperties: {}, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Default mock implementations + mockGetLocalSettingsJson.mockResolvedValue({ + IsEncrypted: false, + Values: {}, + }); + mockWriteLocalSettingsFile.mockResolvedValue(undefined); + mockUpdateConnectionReferenceWithMSIInLocalSettings.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it('should process multiple connections in parallel', async () => { + const updateConnectionReferencesLocalMSI = mockModule.updateConnectionReferencesLocalMSI; + + if (updateConnectionReferencesLocalMSI) { + const mockContext = { telemetry: { properties: {} } }; + const azureDetails = { + subscriptionId: 'test-sub', + tenantId: 'test-tenant', + accessToken: 'test-token', + }; + + // Mock each connection taking 100ms to process + const connectionProcessingTimes: number[] = []; + mockGetLocalSettingsJson.mockImplementation(async () => { + const startTime = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 100)); + connectionProcessingTimes.push(Date.now() - startTime); + return { IsEncrypted: false, Values: {} }; + }); + + const startTime = Date.now(); + await updateConnectionReferencesLocalMSI(mockContext as any, testProjectPath, testConnectionReferences, azureDetails as any); + const totalTime = Date.now() - startTime; + + // Advance timers to complete all promises + await vi.runAllTimersAsync(); + + // With 3 connections taking 100ms each: + // - Sequential would take ~300ms + // - Parallel should take ~100ms + // We'll check that it's closer to parallel time + expect(totalTime).toBeLessThan(250); // Less than 2.5x the individual time + } + }); + + it('should call processing for each connection reference', async () => { + const updateConnectionReferencesLocalMSI = mockModule.updateConnectionReferencesLocalMSI; + + if (updateConnectionReferencesLocalMSI) { + const mockContext = { telemetry: { properties: {} } }; + const azureDetails = { + subscriptionId: 'test-sub', + tenantId: 'test-tenant', + accessToken: 'test-token', + }; + + await updateConnectionReferencesLocalMSI(mockContext as any, testProjectPath, testConnectionReferences, azureDetails as any); + + // Should have processed all 3 connections + expect(mockGetLocalSettingsJson).toHaveBeenCalledTimes(3); + } + }); + + it('should handle errors in individual connection processing', async () => { + const updateConnectionReferencesLocalMSI = mockModule.updateConnectionReferencesLocalMSI; + + if (updateConnectionReferencesLocalMSI) { + const mockContext = { telemetry: { properties: {} } }; + const azureDetails = { + subscriptionId: 'test-sub', + tenantId: 'test-tenant', + accessToken: 'test-token', + }; + + // Make one connection fail + let callCount = 0; + mockGetLocalSettingsJson.mockImplementation(async () => { + callCount++; + if (callCount === 2) { + throw new Error('Connection processing failed'); + } + return { IsEncrypted: false, Values: {} }; + }); + + // Should not throw - parallel processing should handle individual failures + try { + await updateConnectionReferencesLocalMSI(mockContext as any, testProjectPath, testConnectionReferences, azureDetails as any); + } catch (error) { + // If it throws, verify it's an expected error + expect(error).toBeDefined(); + } + + // Should have attempted to process all connections + expect(mockGetLocalSettingsJson).toHaveBeenCalled(); + } + }); + + it('should process connections faster than sequential approach', async () => { + const updateConnectionReferencesLocalMSI = mockModule.updateConnectionReferencesLocalMSI; + + if (updateConnectionReferencesLocalMSI) { + const mockContext = { telemetry: { properties: {} } }; + const azureDetails = { + subscriptionId: 'test-sub', + tenantId: 'test-tenant', + accessToken: 'test-token', + }; + + const delay = 50; // Each connection takes 50ms + mockGetLocalSettingsJson.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, delay)); + return { IsEncrypted: false, Values: {} }; + }); + + const startTime = Date.now(); + const promise = updateConnectionReferencesLocalMSI( + mockContext as any, + testProjectPath, + testConnectionReferences, + azureDetails as any + ); + + // Run all timers to complete promises + await vi.runAllTimersAsync(); + await promise; + const duration = Date.now() - startTime; + + // With 3 connections and 50ms each: + // - Sequential: 150ms + // - Parallel: ~50ms + // Allow some overhead, but should be much faster than sequential + const sequentialTime = delay * Object.keys(testConnectionReferences).length; + expect(duration).toBeLessThan(sequentialTime * 0.8); // At least 20% faster + } + }); + + it('should work with empty connection references', async () => { + const updateConnectionReferencesLocalMSI = mockModule.updateConnectionReferencesLocalMSI; + + if (updateConnectionReferencesLocalMSI) { + const mockContext = { telemetry: { properties: {} } }; + const azureDetails = { + subscriptionId: 'test-sub', + tenantId: 'test-tenant', + accessToken: 'test-token', + }; + + await updateConnectionReferencesLocalMSI(mockContext as any, testProjectPath, {}, azureDetails as any); + + // Should not have called any processing + expect(mockGetLocalSettingsJson).not.toHaveBeenCalled(); + } + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts new file mode 100644 index 00000000000..a4008ed8ad7 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Create mock functions +const mockCheckFuncProcessId = vi.fn(); +const mockGetChildProcessesWithScript = vi.fn(); +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); + +// Mock dependencies +vi.mock('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, +})); + +vi.mock('../../findChildProcess/findChildProcess', () => ({ + getChildProcessesWithScript: mockGetChildProcessesWithScript, +})); + +// Import the module after mocks are set up +const mockModule = await import('../startDesignTimeApi'); + +describe('validateRunningFuncProcess - caching', () => { + const testProjectPath = '/test/project'; + const testPid = 12345; + const VALIDATION_CACHE_TTL = 60000; // 60 seconds + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Setup default mocks + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(testPid.toString()); + mockGetChildProcessesWithScript.mockResolvedValue([testPid]); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it('should cache validation results for 60 seconds', async () => { + const validateRunningFuncProcess = (mockModule as any).validateRunningFuncProcess; + + if (validateRunningFuncProcess) { + // First call should execute PowerShell validation + await validateRunningFuncProcess(testProjectPath); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(1); + + // Second call within 60s should use cache + await validateRunningFuncProcess(testProjectPath); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(1); // Still 1, not 2 + } + }); + + it('should skip PowerShell validation when using cache', async () => { + const validateRunningFuncProcess = (mockModule as any).validateRunningFuncProcess; + + if (validateRunningFuncProcess) { + // First validation + await validateRunningFuncProcess(testProjectPath); + const firstCallCount = mockGetChildProcessesWithScript.mock.calls.length; + + // Advance time by 30 seconds (within 60s cache window) + vi.advanceTimersByTime(30000); + + // Second validation should skip PowerShell + await validateRunningFuncProcess(testProjectPath); + const secondCallCount = mockGetChildProcessesWithScript.mock.calls.length; + + expect(secondCallCount).toBe(firstCallCount); // No new calls + } + }); + + it('should run validation after cache expires', async () => { + const validateRunningFuncProcess = (mockModule as any).validateRunningFuncProcess; + + if (validateRunningFuncProcess) { + // First validation + await validateRunningFuncProcess(testProjectPath); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(1); + + // Advance time past cache TTL (60+ seconds) + vi.advanceTimersByTime(VALIDATION_CACHE_TTL + 1000); + + // Second validation should run PowerShell again + await validateRunningFuncProcess(testProjectPath); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(2); + } + }); + + it('should cache per project path', async () => { + const validateRunningFuncProcess = (mockModule as any).validateRunningFuncProcess; + + if (validateRunningFuncProcess) { + const projectPath1 = '/test/project1'; + const projectPath2 = '/test/project2'; + + // Validate first project + await validateRunningFuncProcess(projectPath1); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(1); + + // Validate second project should not use cache from first + await validateRunningFuncProcess(projectPath2); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(2); + + // Validate first project again should use cache + await validateRunningFuncProcess(projectPath1); + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(2); // Still 2 + } + }); + + it('should avoid repeated expensive PowerShell executions', async () => { + const validateRunningFuncProcess = (mockModule as any).validateRunningFuncProcess; + + if (validateRunningFuncProcess) { + // Simulate expensive PowerShell execution (~940ms) + mockGetChildProcessesWithScript.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 940)); + return [testPid]; + }); + + // First call - expensive + const start1 = Date.now(); + await validateRunningFuncProcess(testProjectPath); + await vi.runAllTimersAsync(); + const duration1 = Date.now() - start1; + + // Second call - should be instant (cached) + const start2 = Date.now(); + await validateRunningFuncProcess(testProjectPath); + const duration2 = Date.now() - start2; + + // First call should have executed PowerShell + expect(duration1).toBeGreaterThanOrEqual(900); + // Second call should be instant (no PowerShell execution) + expect(duration2).toBeLessThan(10); + } + }); + + it('should validate with correct PID when cache miss', async () => { + const validateRunningFuncProcess = (mockModule as any).validateRunningFuncProcess; + + if (validateRunningFuncProcess) { + const expectedPid = 54321; + mockReadFileSync.mockReturnValue(expectedPid.toString()); + mockGetChildProcessesWithScript.mockResolvedValue([expectedPid, 99999, 88888]); + + await validateRunningFuncProcess(testProjectPath); + + // Should have called with the PID from file + expect(mockGetChildProcessesWithScript).toHaveBeenCalledWith(expect.anything(), expectedPid); + } + }); + + it('should handle multiple rapid validations efficiently', async () => { + const validateRunningFuncProcess = (mockModule as any).validateRunningFuncProcess; + + if (validateRunningFuncProcess) { + // Simulate 10 rapid validation calls + const validations = Array.from({ length: 10 }, () => validateRunningFuncProcess(testProjectPath)); + + await Promise.all(validations); + await vi.runAllTimersAsync(); + + // Should only execute PowerShell once due to caching + expect(mockGetChildProcessesWithScript).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/common.ts b/apps/vs-code-designer/src/app/utils/codeless/common.ts index 58fff7a4f53..76a792291be 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/common.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/common.ts @@ -187,10 +187,30 @@ export async function getArtifactsPathInLocalProject(projectPath: string): Promi return artifacts; } +// Cache Azure connector details to avoid repeated auth calls (expensive operation) +// Key: projectPath, Value: { timestamp, details } +const azureDetailsCache = new Map(); +const AZURE_DETAILS_CACHE_TTL = 300000; // 5 minutes + +/** + * Invalidates the cached Azure connector details for a project. + * Call after Azure settings change (e.g., enabling Azure connectors). + */ +export function invalidateAzureDetailsCache(projectPath: string): void { + azureDetailsCache.delete(projectPath); +} + export async function getAzureConnectorDetailsForLocalProject( context: IActionContext, projectPath: string ): Promise { + // Check cache first + const cached = azureDetailsCache.get(projectPath); + const now = Date.now(); + if (cached && now - cached.timestamp < AZURE_DETAILS_CACHE_TTL) { + return cached.details; + } + const localSettingsFilePath = path.join(projectPath, localSettingsFileName); const connectorsContext = context as IAzureConnectorsContext; const localSettings = await getLocalSettingsJson(context, localSettingsFilePath); @@ -202,7 +222,6 @@ export async function getAzureConnectorDetailsForLocalProject( let clientId = undefined; // Set default for customers who created Logic Apps before sovereign cloud support was added. let workflowManagementBaseUrl = localSettings.Values[workflowManagementBaseURIKey] ?? `${azurePublicBaseUrl}/`; - const enabled = !!subscriptionId; if (subscriptionId === undefined) { const wizard = createAzureWizard(connectorsContext, projectPath); @@ -214,15 +233,21 @@ export async function getAzureConnectorDetailsForLocalProject( resourceGroupName = connectorsContext.resourceGroup?.name || ''; location = connectorsContext.resourceGroup?.location || ''; workflowManagementBaseUrl = connectorsContext.environment?.resourceManagerEndpointUrl; - } else { + } + + // Get auth token if we have a valid subscription (whether from wizard or local.settings.json) + if (subscriptionId) { const authData = await getAuthData(tenantId); - accessToken = enabled ? `Bearer ${authData?.accessToken}` : undefined; + accessToken = `Bearer ${authData?.accessToken}`; const [parsedClientId, parsedTenantId] = authData.account.id.split('.'); tenantId = parsedTenantId; clientId = parsedClientId; } - return { + // Compute enabled AFTER the wizard block — subscriptionId may now have a value + const enabled = !!subscriptionId; + + const details: AzureConnectorDetails = { enabled, accessToken: accessToken, subscriptionId: enabled ? subscriptionId : undefined, @@ -232,6 +257,11 @@ export async function getAzureConnectorDetailsForLocalProject( clientId: enabled ? clientId : undefined, workflowManagementBaseUrl: enabled ? workflowManagementBaseUrl : undefined, }; + + // Cache the result + azureDetailsCache.set(projectPath, { timestamp: now, details }); + + return details; } export async function getManualWorkflowsInLocalProject(projectPath: string, workflowToExclude: string): Promise> { diff --git a/apps/vs-code-designer/src/app/utils/codeless/connection.ts b/apps/vs-code-designer/src/app/utils/codeless/connection.ts index f01cd5211ca..95fc78b7c53 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/connection.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/connection.ts @@ -96,7 +96,7 @@ export async function addConnectionData( await addOrUpdateLocalAppSettings(context, projectPath ?? '', settings); await saveWorkflowParameterRecords(context, filePath, workflowParameterRecords); - await vscode.window.showInformationMessage(localize('azureFunctions.addConnection', 'Connection added.')); + vscode.window.showInformationMessage(localize('azureFunctions.addConnection', 'Connection added.')); } export async function getLogicAppProjectRoot(context: IActionContext, workflowFilePath: string): Promise { @@ -691,7 +691,8 @@ async function updateConnectionReferencesLocalMSI( const errors: Array<{ referenceKey: string; error: string }> = []; let successCount = 0; - for (const [referenceKey, reference] of Object.entries(connectionReferences)) { + // Process all connections in parallel for better performance + const connectionPromises = Object.entries(connectionReferences).map(async ([referenceKey, reference]) => { const connectionStartTime = Date.now(); try { @@ -699,8 +700,7 @@ async function updateConnectionReferencesLocalMSI( if (!isApiHubConnectionId(connectionId)) { context.telemetry.properties[`msiSkipped_${referenceKey}`] = 'Not an API Hub connection'; - updatedReferences[referenceKey] = reference; - continue; + return { referenceKey, reference, skipped: true }; } context.telemetry.properties[`msiProcessing_${referenceKey}`] = connectionId; @@ -714,20 +714,32 @@ async function updateConnectionReferencesLocalMSI( localSettings, policyNamePrefix ); - successCount++; + context.telemetry.properties[`msiSuccess_${referenceKey}`] = `Completed in ${Date.now() - connectionStartTime}ms`; + return { referenceKey, reference, success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - errors.push({ referenceKey, error: errorMessage }); - context.telemetry.properties[`msiError_${referenceKey}`] = errorMessage; - // Still include the reference but with original authentication - updatedReferences[referenceKey] = reference; - ext.outputChannel.appendLog( localize('msiConnectionError', 'Failed to configure MSI for connection {0}: {1}', referenceKey, errorMessage) ); + + return { referenceKey, reference, error: errorMessage }; + } + }); + + // Wait for all connections to be processed + const results = await Promise.all(connectionPromises); + + // Aggregate results + for (const result of results) { + updatedReferences[result.referenceKey] = result.reference; + + if (result.success) { + successCount++; + } else if (result.error) { + errors.push({ referenceKey: result.referenceKey, error: result.error }); } } diff --git a/apps/vs-code-designer/src/app/utils/codeless/parameter.ts b/apps/vs-code-designer/src/app/utils/codeless/parameter.ts index 9053ed9dbca..5ee274d8525 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/parameter.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/parameter.ts @@ -17,13 +17,13 @@ import * as fse from 'fs-extra'; import * as path from 'path'; /** - * Retrieves the parameters JSON object from a workflow file. - * @param {string} workflowFilePath The path to the workflow file. + * Retrieves the parameters JSON object from a logic app project. + * @param {string} projectPath The path to the logic app project root. * @returns A promise that resolves to a Record object representing the parameters. * @throws An error if the parameters file cannot be parsed. */ -export async function getParametersJson(workflowFilePath: string): Promise> { - const parameterFilePath: string = path.join(workflowFilePath, parametersFileName); +export async function getParametersJson(projectPath: string): Promise> { + const parameterFilePath: string = path.join(projectPath, parametersFileName); if (await fse.pathExists(parameterFilePath)) { const data: string = (await fse.readFile(parameterFilePath)).toString(); if (/[^\s]/.test(data)) { diff --git a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts index 72f97faebe5..b2b2f8c1103 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -19,9 +19,15 @@ import { appKindSetting, } from '../../../constants'; import { ext } from '../../../extensionVariables'; + +// Cache validation results to avoid expensive process checks (3-4 seconds on Windows) +// This balances safety (detecting wrong func process) with performance +// Key: projectPath, Value: { timestamp, isValid } +const processValidationCache = new Map(); +const VALIDATION_CACHE_TTL = 60000; // Cache for 60 seconds - revalidate every minute to catch process changes import { localize } from '../../../localize'; import { addOrUpdateLocalAppSettings, getLocalSettingsSchema } from '../appSettings/localSettings'; -import { updateFuncIgnore } from '../codeless/common'; +import { getAzureConnectorDetailsForLocalProject, updateFuncIgnore } from '../codeless/common'; import { writeFormattedJson } from '../fs'; import { getFunctionsCommand } from '../funcCoreTools/funcVersion'; import { getWorkspaceSetting, updateGlobalSetting } from '../vsCodeConfig/settings'; @@ -139,6 +145,10 @@ export async function startDesignTimeApi(projectPath: string): Promise { actionContext.telemetry.properties.startDesignTimeApi = 'true'; updateFuncIgnore(projectPath, [`${designTimeDirectoryName}/`]); actionContext.telemetry.measurements.startDesignTimeApiDuration = (Date.now() - loadDesignTimeStart) / 1000; + + // Pre-warm Azure details cache and auth token in the background + // so connection view opens are fast + getAzureConnectorDetailsForLocalProject(actionContext, projectPath).catch(() => {}); } catch (error) { const errorMessage = error instanceof Error ? error.message : error; const viewOutput: MessageItem = { title: localize('viewOutput', 'View output') }; @@ -167,8 +177,19 @@ function extractPinnedVersion(input: string): string | null { } async function validateRunningFuncProcess(projectPath: string): Promise { + // Check cache first to avoid expensive validation (3-4+ seconds on Windows) + const cached = processValidationCache.get(projectPath); + const now = Date.now(); + if (cached && now - cached.timestamp < VALIDATION_CACHE_TTL) { + return; + } + const correctFuncProcess = await checkFuncProcessId(projectPath); - if (!correctFuncProcess) { + + if (correctFuncProcess) { + // Cache successful validation + processValidationCache.set(projectPath, { timestamp: now, isValid: true }); + } else { ext.outputChannel.appendLog( localize( 'invalidChildFuncPid', @@ -176,6 +197,8 @@ async function validateRunningFuncProcess(projectPath: string): Promise { projectPath ) ); + // Clear cache since we're restarting + processValidationCache.delete(projectPath); stopDesignTimeApi(projectPath); await startDesignTimeApi(projectPath); } @@ -183,20 +206,46 @@ async function validateRunningFuncProcess(projectPath: string): Promise { async function checkFuncProcessId(projectPath: string): Promise { let correctId = false; - let { process, childFuncPid } = ext.designTimeInstances.get(projectPath); + const designTimeInst = ext.designTimeInstances.get(projectPath); + if (!designTimeInst) { + return false; + } + + let { process, childFuncPid } = designTimeInst; + + // Reduce retries and delay for faster checks (was 3 retries * 1000ms = 3s worst case) let retries = 0; - while (!childFuncPid && retries < 3) { - await delay(1000); + while (!childFuncPid && retries < 2) { + await delay(500); // Reduced from 1000ms to 500ms ({ process, childFuncPid } = ext.designTimeInstances.get(projectPath)); retries++; } if (!childFuncPid) { - return false; + ext.outputChannel.appendLog('Design-time child func PID not set yet. Attempting live resolution from current child processes.'); } if (os.platform() === Platform.windows) { const children = await getChildProcessesWithScript(process.pid); - correctId = children.some((p) => p.processId.toString() === childFuncPid && p.name === 'func.exe'); + const resolvedChildFuncPid = + childFuncPid ?? + [...children] + .reverse() + .find((p) => /func(\.exe|)$/i.test(p.name || '')) + ?.processId?.toString(); + + if (resolvedChildFuncPid && resolvedChildFuncPid !== childFuncPid) { + designTimeInst.childFuncPid = resolvedChildFuncPid; + childFuncPid = resolvedChildFuncPid; + ext.outputChannel.appendLog(`Design-time child func PID updated during validation to ${resolvedChildFuncPid}`); + } + + ext.outputChannel.appendLog(`Checking for func.exe child process. Looking for PID: ${childFuncPid}, Found ${children.length} children`); + correctId = children.some((p) => { + const matches = p.processId.toString() === childFuncPid && /func(\.exe|)$/i.test(p.name || ''); + ext.outputChannel.appendLog(` Child: PID=${p.processId}, Name=${p.name}, Matches=${matches}`); + return matches; + }); + ext.outputChannel.appendLog(`Process validation result: ${correctId ? 'VALID' : 'INVALID'}`); } else { await find_process('pid', process.pid).then((list) => { if (list.length > 0) { @@ -209,6 +258,21 @@ async function checkFuncProcessId(projectPath: string): Promise { return correctId; } +async function resolveChildFuncPid(processId: number, retries = 5, delayMs = 500): Promise { + for (let attempt = 0; attempt < retries; attempt++) { + const childFuncPid = await findChildProcess(processId); + if (childFuncPid) { + return childFuncPid; + } + + if (attempt < retries - 1) { + await delay(delayMs); + } + } + + return undefined; +} + export async function getOrCreateDesignTimeDirectory(designTimeDirectory: string, projectRoot: string): Promise { const directory: string = designTimeDirectory + path.sep; if (projectRoot.includes(designTimeDirectoryName)) { @@ -243,7 +307,10 @@ export async function waitForDesignTimeStartUp( } if (setDesignTimeInst) { const designTimeInst = ext.designTimeInstances.get(projectPath); - designTimeInst.childFuncPid = await findChildProcess(designTimeInst.process.pid); + designTimeInst.childFuncPid = await resolveChildFuncPid(designTimeInst.process.pid); + ext.outputChannel.appendLog( + `Design-time child func PID ${designTimeInst.childFuncPid ? `resolved to ${designTimeInst.childFuncPid}` : 'was not resolved during startup'}` + ); designTimeInst.isStarting = false; } context.telemetry.measurements.waitForDesignTimeStartupDuration = (Date.now() - initialTime) / 1000; @@ -336,7 +403,9 @@ export function stopDesignTimeApi(projectPath: string): void { } if (os.platform() === Platform.windows) { - cp.exec(`taskkill /pid ${childFuncPid} /t /f`); + if (childFuncPid) { + cp.exec(`taskkill /pid ${childFuncPid} /t /f`); + } cp.exec(`taskkill /pid ${process.pid} /t /f`); } else { cp.spawn('kill', ['-9'].concat(`${process.pid}`)); @@ -393,9 +462,7 @@ export async function promptStartDesignTimeOption(context: IActionContext) { await createJsonFile(projectUri, localSettingsFileName, settingsFileContent); } - const isCodeful = (await isCodefulProject(projectPath)) ?? false; - - if (autoStartDesignTime && !isCodeful) { + if (autoStartDesignTime) { startDesignTimeApi(projectPath); } } diff --git a/apps/vs-code-designer/src/app/utils/debug.ts b/apps/vs-code-designer/src/app/utils/debug.ts index e77826cce74..43637578fba 100644 --- a/apps/vs-code-designer/src/app/utils/debug.ts +++ b/apps/vs-code-designer/src/app/utils/debug.ts @@ -21,26 +21,37 @@ export async function getDebugSymbolDll(): Promise { * 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) * @param logicAppName - The name of the Logic App to debug - * @param customCodeTargetFramework - Optional target framework for custom code (.NET 8 or .NET Framework) + * @param customCodeTargetFramework - Optional target framework for custom code (.NET 8 or .NET Framework). + * When provided, returns a launch configuration with `type: 'logicapp'`. + * @param isCodeless - Whether the project uses codeless (JSON) workflow definitions. Defaults to `true`. + * - `true` (custom code): includes `customCodeRuntime` for attaching a second debugger to the .NET host. + * - `false` (codeful): omits `customCodeRuntime` since the workflow IS the compiled code. * * @returns A DebugConfiguration object with either: - * - Launch configuration for Logic Apps with custom code, including both function and custom code runtime settings - * - Attach configuration for standard Logic Apps, allowing process selection for debugging + * - Launch configuration for Logic Apps with custom code (isCodeless=true), including both function and custom code runtime settings + * - Launch configuration for codeful Logic Apps (isCodeless=false), with function runtime only + * - Attach configuration for standard Logic Apps (no customCodeTargetFramework), allowing process selection for debugging */ export const getDebugConfiguration = ( version: FuncVersion, logicAppName: string, - customCodeTargetFramework?: TargetFramework + customCodeTargetFramework?: TargetFramework, + isCodeless = true ): DebugConfiguration => { if (customCodeTargetFramework) { - return { - name: `Run/Debug logic app with local function ${logicAppName}`, + const config: DebugConfiguration = { + name: isCodeless ? `Run/Debug logic app with local function ${logicAppName}` : `Run/Debug logic app ${logicAppName}`, type: 'logicapp', request: 'launch', funcRuntime: version === FuncVersion.v1 ? 'clr' : 'coreclr', - customCodeRuntime: customCodeTargetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', - isCodeless: true, + isCodeless, }; + + if (isCodeless) { + config.customCodeRuntime = customCodeTargetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr'; + } + + return config; } return { diff --git a/apps/vs-code-designer/src/app/utils/extension.ts b/apps/vs-code-designer/src/app/utils/extension.ts index f8adbe9a644..fa87858db6a 100644 --- a/apps/vs-code-designer/src/app/utils/extension.ts +++ b/apps/vs-code-designer/src/app/utils/extension.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 { extensionCommand, logicAppsStandardExtensionId } from '../../constants'; +import { extensionCommand, logicAppsStandardExtensionId, customExtensionContext } from '../../constants'; import * as vscode from 'vscode'; import { supportedDataMapDefinitionFileExts, @@ -11,6 +11,8 @@ import { } from '../commands/dataMapper/extensionConfig'; import { getWorkspaceFolderWithoutPrompting } from './workspace'; import { isLogicAppProjectInRoot } from './verifyIsProject'; +import { detectCodefulWorkflow, hasCodefulWorkflowSetting } from './codeful'; +import * as path from 'path'; /** * Gets extension version from the package.json version. @@ -55,3 +57,133 @@ export async function updateLogicAppsContext() { await vscode.commands.executeCommand('setContext', 'logicApps.hasProject', logicAppOpened); } } + +/** + * Scans workspace for .cs files that are codeful workflows and returns their paths. + * Only scans projects that have WORKFLOW_CODEFUL_ENABLED set to true in local.settings.json. + * @returns Array of file paths that contain codeful workflow definitions + */ +export async function scanWorkspaceForCodefulWorkflows(): Promise { + const codefulWorkflowFiles: string[] = []; + + // First, find all local.settings.json files + const settingsFiles = await vscode.workspace.findFiles('**/local.settings.json', '**/node_modules/**'); + + // Check which projects have codeful workflows enabled + const codefulProjectPaths: string[] = []; + for (const settingsUri of settingsFiles) { + const projectPath = path.dirname(settingsUri.fsPath); + const isCodeful = await hasCodefulWorkflowSetting(projectPath); + if (isCodeful) { + codefulProjectPaths.push(projectPath); + } + } + + // If no codeful projects found, return empty array + if (codefulProjectPaths.length === 0) { + return codefulWorkflowFiles; + } + + // Now scan .cs files only in codeful project directories + const csFiles = await vscode.workspace.findFiles('**/*.cs', '**/node_modules/**'); + + for (const fileUri of csFiles) { + // Check if this .cs file is within a codeful project + const filePath = fileUri.fsPath; + const isInCodefulProject = codefulProjectPaths.some((projectPath) => filePath.startsWith(projectPath)); + + if (!isInCodefulProject) { + continue; + } + + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const fileContent = document.getText(); + const workflowInfo = detectCodefulWorkflow(fileContent); + + if (workflowInfo) { + codefulWorkflowFiles.push(fileUri.fsPath); + } + } catch { + // Skip files that can't be read + } + } + + return codefulWorkflowFiles; +} + +/** + * Updates context with the list of codeful workflow file paths. + */ +export async function updateCodefulWorkflowFilesContext(): Promise { + const codefulWorkflowFiles = await scanWorkspaceForCodefulWorkflows(); + await vscode.commands.executeCommand('setContext', customExtensionContext.codefulWorkflowFiles, codefulWorkflowFiles); +} + +/** + * Updates the context to indicate if the current file is a codeful workflow file. + * @param editor The active text editor + */ +export function updateCodefulWorkflowContext(editor: vscode.TextEditor | undefined): void { + if (!editor) { + vscode.commands.executeCommand('setContext', customExtensionContext.isCodefulWorkflowFile, false); + return; + } + + const document = editor.document; + + // Only check .cs files + if (document.languageId !== 'csharp' || !document.fileName.endsWith('.cs')) { + vscode.commands.executeCommand('setContext', customExtensionContext.isCodefulWorkflowFile, false); + return; + } + + try { + const fileContent = document.getText(); + const workflowInfo = detectCodefulWorkflow(fileContent); + + vscode.commands.executeCommand('setContext', customExtensionContext.isCodefulWorkflowFile, !!workflowInfo); + } catch { + vscode.commands.executeCommand('setContext', customExtensionContext.isCodefulWorkflowFile, false); + } +} + +/** + * Registers a listener for active editor changes to update codeful workflow context. + * Also scans workspace for codeful workflow files and watches for file changes. + * @param context The extension context + */ +export function registerCodefulWorkflowContextListener(context: vscode.ExtensionContext): void { + // Update context for the currently active editor + updateCodefulWorkflowContext(vscode.window.activeTextEditor); + + // Initial scan of workspace for codeful workflow files + updateCodefulWorkflowFilesContext(); + + // Register listener for active editor changes + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + updateCodefulWorkflowContext(editor); + }) + ); + + // Also listen to document changes for the active editor + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (vscode.window.activeTextEditor && event.document === vscode.window.activeTextEditor.document) { + updateCodefulWorkflowContext(vscode.window.activeTextEditor); + } + }) + ); + + // Watch for .cs file changes and updates + const fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.cs'); + + context.subscriptions.push(fileWatcher.onDidCreate(() => updateCodefulWorkflowFilesContext())); + + context.subscriptions.push(fileWatcher.onDidChange(() => updateCodefulWorkflowFilesContext())); + + context.subscriptions.push(fileWatcher.onDidDelete(() => updateCodefulWorkflowFilesContext())); + + context.subscriptions.push(fileWatcher); +} diff --git a/apps/vs-code-designer/src/app/utils/findChildProcess/findChildProcess.ts b/apps/vs-code-designer/src/app/utils/findChildProcess/findChildProcess.ts index 3220fe1e9b3..701ddb7e18a 100644 --- a/apps/vs-code-designer/src/app/utils/findChildProcess/findChildProcess.ts +++ b/apps/vs-code-designer/src/app/utils/findChildProcess/findChildProcess.ts @@ -12,16 +12,17 @@ async function runPowerShellScript(scriptPath: string, ...args: string[]): Promi return new Promise((resolve, reject) => { // Escape arguments for PowerShell const escapedArgs = args.map((arg) => `"${arg.replace(/"/g, '`"')}"`).join(' '); - const command = `powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -File "${scriptPath}" ${escapedArgs}`; + const command = `powershell.exe -NoProfile -NoLogo -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}" ${escapedArgs}`; ext.outputChannel.appendLog(`Executing PowerShell script: ${command}`); const child = cp.exec( command, { - timeout: 10000, + timeout: 8000, // Slightly increased for reliability encoding: 'utf8', - maxBuffer: 1024 * 1024, // 1MB buffer + maxBuffer: 512 * 1024, // 512KB is sufficient for process lists + windowsHide: true, // Don't show console window }, (error, stdout, stderr) => { if (error) { @@ -33,6 +34,7 @@ async function runPowerShellScript(scriptPath: string, ...args: string[]): Promi return; } + ext.outputChannel.appendLog(`PowerShell script output: ${stdout.trim()}`); resolve(stdout.trim()); } ); @@ -43,28 +45,37 @@ async function runPowerShellScript(scriptPath: string, ...args: string[]): Promi child.kill('SIGKILL'); reject(new Error('PowerShell script execution timed out')); } - }, 12000); + }, 10000); }); } export async function getChildProcessesWithScript(parentProcessId: number): Promise { try { const scriptPath = path.join(__dirname, 'assets', 'scripts', 'get-child-processes.ps1'); + ext.outputChannel.appendLog(`Getting child processes for PID ${parentProcessId}`); const output = await runPowerShellScript(scriptPath, parentProcessId.toString()); if (!output || output === '[]') { + ext.outputChannel.appendLog(`No child processes found for PID ${parentProcessId}`); return []; } const rawData = JSON.parse(output); const dataArray = Array.isArray(rawData) ? rawData : [rawData]; - return dataArray.map((item: any) => ({ + const result = dataArray.map((item: any) => ({ processId: item.ProcessId, name: item.Name, parentProcessId: item.ParentProcessId, })); + + ext.outputChannel.appendLog( + `Found ${result.length} child processes for PID ${parentProcessId}: ${JSON.stringify(result.map((p) => ({ pid: p.processId, name: p.name })))}` + ); + + return result; } catch (error) { + ext.outputChannel.appendLog(`Failed to get child processes for PID ${parentProcessId}: ${error.message}`); throw new Error(`Failed to execute Powershell script to get the func child process: ${error.message}`); } } diff --git a/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts b/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts index d2d0a3436c6..ac23f77506a 100644 --- a/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts +++ b/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts @@ -10,34 +10,78 @@ export async function installLSPSDK(): Promise { const targetDirectory = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); await fse.ensureDir(targetDirectory); - // Check if LSPServer folder already exists + // Check if LSPServer needs to be extracted or updated const lspServerPath = path.join(targetDirectory, 'LSPServer'); - const shouldExtract = !(await fse.pathExists(lspServerPath)); + const serverZipFile = path.join(__dirname, assetsFolderName, 'LSPServer', 'LSPServer.zip'); + const versionMarkerFile = path.join(targetDirectory, '.lspserver-version'); + // Temporary method to determine whether to exract or update...add a more permanent method after + const shouldExtract = await shouldExtractOrUpdate(serverZipFile, versionMarkerFile, lspServerPath); if (shouldExtract) { - const serverZipFile = path.join(__dirname, assetsFolderName, 'LSPServer', 'LSPServer.zip'); try { const zip = new AdmZip(serverZipFile); await zip.extractAllTo(targetDirectory, /* overwrite */ true, /* Permissions */ true); + + // Write version marker with zip file's modification time + const zipStats = await fse.stat(serverZipFile); + await fse.writeFile(versionMarkerFile, zipStats.mtime.toISOString()); } catch (error) { - throw new Error(`Error extracting worker isolated: ${error}`); + throw new Error(`Error extracting LSP server: ${error}`); } } - // Check if LanguageServerLogicApps folder already exists + // Check if SDK needs to be copied or updated const lspDirectoryPath = path.join(targetDirectory, lspDirectory); - const shouldCopy = !(await fse.pathExists(lspDirectoryPath)); + const sdkNupkgFile = path.join(__dirname, assetsFolderName, 'LSPServer', 'Microsoft.Azure.Workflows.Sdk.1.0.0-preview.1.nupkg'); + const sdkVersionMarkerFile = path.join(targetDirectory, '.lspsdk-version'); + + const shouldCopy = await shouldExtractOrUpdate(sdkNupkgFile, sdkVersionMarkerFile, lspDirectoryPath); if (shouldCopy) { - const sdkNupkgFile = path.join(__dirname, assetsFolderName, 'LSPServer', 'Microsoft.Azure.Workflows.Sdk.Agents.1.141.0.12.nupkg'); try { await fse.ensureDir(lspDirectoryPath); const destinationFile = path.join(lspDirectoryPath, path.basename(sdkNupkgFile)); await fse.copyFile(sdkNupkgFile, destinationFile); + + // Write version marker with SDK file's modification time + const sdkStats = await fse.stat(sdkNupkgFile); + await fse.writeFile(sdkVersionMarkerFile, sdkStats.mtime.toISOString()); } catch (error) { throw new Error(`Error copying sdk: ${error}`); } } }); } + +/** + * Determines if a file should be extracted/copied by comparing modification times. + * @param sourceFile - The source zip or file to check + * @param versionMarkerFile - The version marker file that stores the last extraction time + * @param targetPath - The target directory/file path + * @returns true if extraction/copy is needed, false otherwise + */ +async function shouldExtractOrUpdate(sourceFile: string, versionMarkerFile: string, targetPath: string): Promise { + // If target doesn't exist, we need to extract + if (!(await fse.pathExists(targetPath))) { + return true; + } + + // If version marker doesn't exist, we need to extract + if (!(await fse.pathExists(versionMarkerFile))) { + return true; + } + + try { + // Compare source file modification time with stored version + const sourceStats = await fse.stat(sourceFile); + const storedVersion = await fse.readFile(versionMarkerFile, 'utf-8'); + const storedTime = new Date(storedVersion.trim()); + + // If source is newer than stored version, we need to extract + return sourceStats.mtime > storedTime; + } catch { + // If there's any error reading versions, extract to be safe + return true; + } +} diff --git a/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts b/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts index c6bed8f3297..fb06d748fbe 100644 --- a/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts @@ -108,7 +108,10 @@ async function waitForRuntimeStartUp(context: IActionContext, projectPath: strin } if (setRuntimeInst) { const runtimeInst = ext.runtimeInstances.get(projectPath); - runtimeInst.childFuncPid = await findChildProcess(runtimeInst.process.pid); + runtimeInst.childFuncPid = await resolveChildFuncPid(runtimeInst.process.pid); + ext.outputChannel.appendLog( + `Runtime child func PID ${runtimeInst.childFuncPid ? `resolved to ${runtimeInst.childFuncPid}` : 'was not resolved during startup'}` + ); runtimeInst.isStarting = false; } context.telemetry.measurements.waitForDesignTimeStartupDuration = (Date.now() - initialTime) / 1000; @@ -156,12 +159,32 @@ async function checkFuncProcessId(projectPath: string): Promise { retries++; } if (!childFuncPid) { - return false; + ext.outputChannel.appendLog('Runtime child func PID not set yet. Attempting live resolution from current child processes.'); } if (os.platform() === Platform.windows) { const children = await getChildProcessesWithScript(process.pid); - correctId = children.some((p) => p.processId.toString() === childFuncPid && p.name === 'func.exe'); + const runtimeInst = ext.runtimeInstances.get(projectPath); + const resolvedChildFuncPid = + childFuncPid ?? + [...children] + .reverse() + .find((p) => /func(\.exe|)$/i.test(p.name || '')) + ?.processId?.toString(); + + if (runtimeInst && resolvedChildFuncPid && resolvedChildFuncPid !== childFuncPid) { + runtimeInst.childFuncPid = resolvedChildFuncPid; + childFuncPid = resolvedChildFuncPid; + ext.outputChannel.appendLog(`Runtime child func PID updated during validation to ${resolvedChildFuncPid}`); + } + + ext.outputChannel.appendLog(`Checking for func.exe child process. Looking for PID: ${childFuncPid}, Found ${children.length} children`); + correctId = children.some((p) => { + const matches = p.processId.toString() === childFuncPid && /func(\.exe|)$/i.test(p.name || ''); + ext.outputChannel.appendLog(` Child: PID=${p.processId}, Name=${p.name}, Matches=${matches}`); + return matches; + }); + ext.outputChannel.appendLog(`Process validation result: ${correctId ? 'VALID' : 'INVALID'}`); } else { await find_process('pid', process.pid).then((list) => { if (list.length > 0) { @@ -174,6 +197,21 @@ async function checkFuncProcessId(projectPath: string): Promise { return correctId; } +async function resolveChildFuncPid(processId: number, retries = 5, delayMs = 500): Promise { + for (let attempt = 0; attempt < retries; attempt++) { + const childFuncPid = await findChildProcess(processId); + if (childFuncPid) { + return childFuncPid; + } + + if (attempt < retries - 1) { + await delay(delayMs); + } + } + + return undefined; +} + export function stopRuntimeApi(projectPath: string): void { ext.outputChannel.appendLog(`Stopping Runtime API for project: ${projectPath}`); const { process, childFuncPid } = ext.runtimeInstances.get(projectPath); @@ -183,7 +221,9 @@ export function stopRuntimeApi(projectPath: string): void { } if (os.platform() === Platform.windows) { - cp.exec(`taskkill /pid ${childFuncPid} /t /f`); + if (childFuncPid) { + cp.exec(`taskkill /pid ${childFuncPid} /t /f`); + } cp.exec(`taskkill /pid ${process.pid} /t /f`); } else { cp.spawn('kill', ['-9'].concat(`${process.pid}`)); diff --git a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts index 774525443d9..a2b035c01ad 100644 --- a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts +++ b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts @@ -99,7 +99,7 @@ export async function isLogicAppProject(folderPath: string): Promise { const isCodefulAgent = await hasCodefulWorkflowSetting(folderPath); if (isCodefulAgent) { - vscode.commands.executeCommand('setContext', customExtensionContext.isAgentCodeful, true); + vscode.commands.executeCommand('setContext', customExtensionContext.isCodeful, true); } // Only return false if none of the possible validation mechanisms are present diff --git a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/AgentCodefulFile b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/AgentCodefulFile deleted file mode 100644 index 54ea00f3f6c..00000000000 --- a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/AgentCodefulFile +++ /dev/null @@ -1,101 +0,0 @@ -// ----------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// ----------------------------------------------------------- - -using System; -using Microsoft.Azure.Workflows.Sdk.Agents; -using Microsoft.Azure.Workflows.Sdk.Agents.Connectors; -using Microsoft.Azure.Workflows.Sdk.Agents.Connectors.Msnweather; -using Microsoft.Extensions.Hosting; - -namespace AgentCodefulNamespace; - - -/// -/// Main Class. -/// -public class Program -{ - /// - /// Main Class. - /// - public static void Main() - { - // Configure the worker. - var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .ConfigureServices(services => WorkflowBuilderFactory.ConfigureServices(services)) - .Build(); - - // Create a workflow agent builder for the "TestFlow" workflow. - var builder = WorkflowBuilderFactory.CreateConversationalAgent(<%= flowName %>); - - #region agent configuration - var agent = new AgentBuilder - { - AgentModelType = AgentModelType.AzureOpenAI, - DeploymentId = "gpt-4.1", - AgentModelSettings = new AgentModelSettings - { - AgentChatCompletionSettings = new AgentChatCompletionSettings - { - MaxTokens = 3000, - Temperature = 0, - FrequencyPenalty = 0.1, - PresencePenalty = 0.1, - TopP = 0.1, - }, - DeploymentModelProperties = new AgentDeploymentModelProperties - { - Name = "gpt-4o", - Format = "OpenAI", - Version = "2024-11-20" - } - }, - Messages = new AgentPromptMessage[] - { - new AgentPromptMessage - { - Role = MessageRole.System, - Content = "You are an agent to get the weather. Greet the user warmly and provide the current weather conditions for their location, including temperature, weather type (e.g., sunny, cloudy, rainy), humidity, and wind speed. If a forecast is available, summarize the expected weather for the next 24 hours. Make the response clear and conversational, and suggest whether it’s a good day for outdoor activities based on the weather.\r\n\r\n" - } - }, - ConnectionName = "agent-2", - }; - #endregion - - #region Add tool to the agent - agent.AddTool(b => - { - var getCurrentWeatherAction = WorkflowActions.ManagedConnectors.Msnweather("msnweather").CurrentWeather( - location: () => "WA", - units: () => Microsoft.Azure.Workflows.Sdk.Agents.Connectors.Msnweather.CurrentWeatherunitsInput.Imperial); - b.AddAction(getCurrentWeatherAction); - - }, - description: "Gets the weather", - parameters: new WeatherObject()); - #endregion - - builder.AddAgent(agent); - - WorkflowBuilderFactory.CreateWorkflows(); - host.Run(); - } -} - -/// -/// Weather agent parameter class. -/// -public class WeatherObject -{ - /// - /// current weather location property. - /// - public string CurrentWeatherLocation { get; set; } = string.Empty; - - /// - /// current weather location property. - /// - public string Weatherdetails { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/AgentCodefulWorkflow b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/AgentCodefulWorkflow new file mode 100644 index 00000000000..01aa9095c9c --- /dev/null +++ b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/AgentCodefulWorkflow @@ -0,0 +1,92 @@ +// ----------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ----------------------------------------------------------- + +namespace <%= logicAppNamespace %> +{ + using System; + using Microsoft.Azure.Workflows.Sdk; + using Microsoft.Azure.Workflows.Sdk.Connectors; + using Microsoft.Azure.Workflows.Sdk.Connectors.Msnweather; + using Microsoft.Extensions.Hosting; + + /// + /// <%= flowName %> Agent Workflow. + /// + public static class <%= flowNameClass %> + { + /// + /// Main Class. + /// + public static void AddWorkflow() + { + // Create a workflow agent builder for the "TestFlow" workflow. + var builder = WorkflowBuilderFactory.CreateConversationalAgent(<%= flowName %>); + + #region agent configuration + var agent = new AgentBuilder + { + AgentModelType = AgentModelType.AzureOpenAI, + DeploymentId = "gpt-4.1", + AgentModelSettings = new AgentModelSettings + { + AgentChatCompletionSettings = new AgentChatCompletionSettings + { + MaxTokens = 3000, + Temperature = 0, + FrequencyPenalty = 0.1, + PresencePenalty = 0.1, + TopP = 0.1, + }, + DeploymentModelProperties = new AgentDeploymentModelProperties + { + Name = "gpt-4o", + Format = "OpenAI", + Version = "2024-11-20" + } + }, + Messages = new AgentPromptMessage[] + { + new AgentPromptMessage + { + Role = MessageRole.System, + Content = "You are an agent to get the weather. Greet the user warmly and provide the current weather conditions for their location, including temperature, weather type (e.g., sunny, cloudy, rainy), humidity, and wind speed. If a forecast is available, summarize the expected weather for the next 24 hours. Make the response clear and conversational, and suggest whether it’s a good day for outdoor activities based on the weather.\r\n\r\n" + } + }, + ConnectionName = "agent", + }; + #endregion + + #region Add tool to the agent + agent.AddTool(b => + { + var getCurrentWeatherAction = WorkflowActions.ManagedConnectors.Msnweather("msnweather").CurrentWeather( + location: () => "WA", + units: () => unitsInput.Imperial); + b.AddAction(getCurrentWeatherAction); + + }, + description: "Gets the weather", + parameters: new WeatherObject()); + #endregion + + builder.AddAgent(agent); + } + } + + /// + /// Weather agent parameter class. + /// + public class WeatherObject + { + /// + /// current weather location property. + /// + public string CurrentWeatherLocation { get; set; } = string.Empty; + + /// + /// current weather location property. + /// + public string Weatherdetails { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/CodefulProj b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/CodefulProj index b55f3bfd0a3..c599f1ab6fc 100644 --- a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/CodefulProj +++ b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/CodefulProj @@ -12,7 +12,7 @@ - + diff --git a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/ProgramFile b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/ProgramFile new file mode 100644 index 00000000000..b96367283a1 --- /dev/null +++ b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/ProgramFile @@ -0,0 +1,33 @@ +// ----------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ----------------------------------------------------------- + +namespace <%= logicAppNamespace %> +{ + using System; + using Microsoft.Azure.Workflows.Sdk; + using Microsoft.Extensions.Hosting; + + /// + /// Main Program entry point. + /// + public class Program + { + /// + /// Main entry point for the application. + /// + public static void Main() + { + // Configure the worker. + var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .ConfigureServices(services => WorkflowBuilderFactory.ConfigureServices(services)) + .Build(); + + // Build all workflows + <%= workflowBuilders %> + + host.Run(); + } + } +} \ No newline at end of file diff --git a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/StatefulCodefulFile b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/StatefulCodefulFile deleted file mode 100644 index 8ad2a2fd244..00000000000 --- a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/StatefulCodefulFile +++ /dev/null @@ -1,53 +0,0 @@ -// ----------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// ----------------------------------------------------------- - -using System; -using Microsoft.Azure.Workflows.Sdk.Agents; -using Microsoft.Azure.Workflows.Sdk.Agents.Connectors; -using Microsoft.Azure.Workflows.Sdk.Agents.Connectors.Msnweather; -using Microsoft.Extensions.Hosting; - -namespace AgentStatefulNamespace; - - -/// -/// Main Class. -/// -public class Program -{ - /// - /// Main Class. - /// - public static void Main() - { - // Configure the worker. - var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .ConfigureServices(services => WorkflowBuilderFactory.ConfigureServices(services)) - .Build(); - - var trigger = WorkflowTriggers.Managed.Msnweather("msnweather").WhenOnCurrentWeatherChange( - location: () => "Seattle, WA", - measure: () => OnCurrentWeatherChangeMeasureInput.Temperature, - when: () => OnCurrentWeatherChangeWhenInput.IsEqualTo, - target: () => 70, - units: () => "I" - ); - - trigger.WithName("weather_trigger"); - trigger.WithRecurrence(new FlowRecurrence - { - Frequency = FlowRecurrenceFrequency.Minute, - Interval = 1 - }); - var builder = WorkflowBuilderFactory.CreateStatefulWorkflow( <%= flowName %>, trigger); - - var compose = WorkflowActions.BuiltIn.Compose(inputs: () => $"The trigger output {builder.TriggerOutput}"); - builder.AddAction(compose); - - - WorkflowBuilderFactory.CreateWorkflows(); - host.Run(); - } -} diff --git a/apps/vs-code-designer/src/assets/CodefulProjectTemplate/StatefulCodefulWorkflow b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/StatefulCodefulWorkflow new file mode 100644 index 00000000000..7475fd5fd8e --- /dev/null +++ b/apps/vs-code-designer/src/assets/CodefulProjectTemplate/StatefulCodefulWorkflow @@ -0,0 +1,31 @@ +// ----------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ----------------------------------------------------------- + +namespace <%= logicAppNamespace %> +{ + using Microsoft.Azure.Workflows.Sdk; + using Microsoft.Azure.Workflows.Sdk.Connectors; + using Microsoft.Azure.Workflows.Sdk.Connectors.Msnweather; + + /// + /// <%= flowName %> Stateful Workflow. + /// + public static class <%= flowNameClass %> + { + /// + /// Adds the HTTP request/response workflow. + /// + public static void AddWorkflow() + { + var builder = WorkflowBuilderFactory.CreateStatefulWorkflow(<%= flowName %>, WorkflowTriggers.BuiltIn.CreateHttpTrigger()); + var getCurrentWeatherAction = WorkflowActions.ManagedConnectors.Msnweather("msnweather").CurrentWeather( + location: () => "98058", + units: () => unitsInput.Imperial); + builder.AddAction(getCurrentWeatherAction); + + var response = WorkflowActions.BuiltIn.Response(responseBody: () => $"{getCurrentWeatherAction.Body}"); + builder.AddAction(response); + } + } +} diff --git a/apps/vs-code-designer/src/assets/LSPServer/LSPServer.zip b/apps/vs-code-designer/src/assets/LSPServer/LSPServer.zip index e891bfe225a..dc078d62ec0 100644 Binary files a/apps/vs-code-designer/src/assets/LSPServer/LSPServer.zip and b/apps/vs-code-designer/src/assets/LSPServer/LSPServer.zip differ diff --git a/apps/vs-code-designer/src/assets/LSPServer/Microsoft.Azure.Workflows.Sdk.1.0.0-preview.1.nupkg b/apps/vs-code-designer/src/assets/LSPServer/Microsoft.Azure.Workflows.Sdk.1.0.0-preview.1.nupkg new file mode 100644 index 00000000000..2b616286299 Binary files /dev/null and b/apps/vs-code-designer/src/assets/LSPServer/Microsoft.Azure.Workflows.Sdk.1.0.0-preview.1.nupkg differ diff --git a/apps/vs-code-designer/src/assets/LSPServer/Microsoft.Azure.Workflows.Sdk.Agents.1.141.0.12.nupkg b/apps/vs-code-designer/src/assets/LSPServer/Microsoft.Azure.Workflows.Sdk.Agents.1.141.0.12.nupkg deleted file mode 100644 index dc28cf93a2a..00000000000 Binary files a/apps/vs-code-designer/src/assets/LSPServer/Microsoft.Azure.Workflows.Sdk.Agents.1.141.0.12.nupkg and /dev/null differ diff --git a/apps/vs-code-designer/src/assets/scripts/get-child-processes.ps1 b/apps/vs-code-designer/src/assets/scripts/get-child-processes.ps1 index f04014aa27a..f9667204684 100644 --- a/apps/vs-code-designer/src/assets/scripts/get-child-processes.ps1 +++ b/apps/vs-code-designer/src/assets/scripts/get-child-processes.ps1 @@ -1,21 +1,48 @@ $parentProcessId = $args[0] try { - function Get-ChildProcesses ($ParentProcessId) { - $filter = "parentprocessid = '$($ParentProcessId)'" - Get-CIMInstance -ClassName win32_process -filter $filter | Foreach-Object { - $_ - if ($_.ParentProcessId -ne $_.ProcessId) { - Get-ChildProcesses $_.ProcessId + # Query all processes ONCE - much faster than recursive CIM queries + $allProcs = @(Get-CimInstance -ClassName Win32_Process -Property ProcessId,Name,ParentProcessId -ErrorAction SilentlyContinue) + + # Build hashtable mapping ParentProcessId -> array of child processes + $childMap = @{} + foreach ($proc in $allProcs) { + $ppid = [int]$proc.ParentProcessId + if ($ppid -gt 0) { + if (-not $childMap.ContainsKey($ppid)) { + $childMap[$ppid] = @() } + $childMap[$ppid] += $proc } } - $processes = Get-ChildProcesses $parentProcessId | Select ProcessId, Name, ParentProcessId - if ($processes) { - $processes | ConvertTo-Json -Depth 2 + + # Recursively get all descendants + function Get-AllDescendants { + param([int]$targetPid) + $descendants = @() + + if ($childMap.ContainsKey($targetPid)) { + foreach ($child in $childMap[$targetPid]) { + $descendants += [PSCustomObject]@{ + ProcessId = $child.ProcessId + Name = $child.Name + ParentProcessId = $child.ParentProcessId + } + # Recursively add this child descendants + $grandchildren = Get-AllDescendants -targetPid ([int]$child.ProcessId) + $descendants += $grandchildren + } + } + + return $descendants + } + + $processes = Get-AllDescendants -targetPid ([int]$parentProcessId) + if ($processes.Count -gt 0) { + $processes | ConvertTo-Json -Depth 2 -Compress } else { '[]' } } catch { '[]' -} \ No newline at end of file +} diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 5894529ff4b..468784c247f 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -137,7 +137,6 @@ export const designerApiLoadTimeout = 300000; // Commands export const extensionCommand = { openDesigner: 'azureLogicAppsStandard.openDesigner', - openRunHistory: 'azureLogicAppsStandard.openRunHistory', activate: 'azureLogicAppsStandard.activate', viewContent: 'azureLogicAppsStandard.viewContent', openFile: 'azureLogicAppsStandard.openFile', @@ -229,7 +228,9 @@ export type extensionCommand = (typeof extensionCommand)[keyof typeof extensionC // Extension context export const customExtensionContext = { - isAgentCodeful: 'azureLogicAppsStandard.isAgentCodeful', + isCodeful: 'azureLogicAppsStandard.isCodeful', + isCodefulWorkflowFile: 'azureLogicAppsStandard.isCodefulWorkflowFile', + codefulWorkflowFiles: 'azureLogicAppsStandard.codefulWorkflowFiles', } as const; export type customExtensionContext = (typeof customExtensionContext)[keyof typeof customExtensionContext]; diff --git a/apps/vs-code-designer/src/extensionVariables.ts b/apps/vs-code-designer/src/extensionVariables.ts index 2944be928d0..923c2ef30ce 100644 --- a/apps/vs-code-designer/src/extensionVariables.ts +++ b/apps/vs-code-designer/src/extensionVariables.ts @@ -94,7 +94,6 @@ export namespace ext { export: 'export', overview: 'overview', unitTest: 'unitTest', - runHistory: 'runHistory', languageServer: 'languageServer', createWorkspace: 'createWorkspace', createWorkspaceFromPackage: 'createWorkspaceFromPackage', diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index 6dd5aaa49a3..27178e7ed49 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -7,7 +7,12 @@ import type { AzureAccountTreeItemWithProjects } from './app/tree/AzureAccountTr import { downloadExtensionBundle } from './app/utils/bundleFeed'; import { stopAllDesignTimeApis } from './app/utils/codeless/startDesignTimeApi'; import { UriHandler } from './app/utils/codeless/urihandler'; -import { getExtensionVersion, initializeCustomExtensionContext, updateLogicAppsContext } from './app/utils/extension'; +import { + getExtensionVersion, + initializeCustomExtensionContext, + registerCodefulWorkflowContextListener, + updateLogicAppsContext, +} from './app/utils/extension'; import { registerFuncHostTaskEvents } from './app/utils/funcCoreTools/funcHostTask'; import { verifyVSCodeConfigOnActivate } from './app/utils/vsCodeConfig/verifyVSCodeConfigOnActivate'; import { extensionCommand, logicAppFilter } from './constants'; @@ -105,12 +110,8 @@ export async function activate(context: vscode.ExtensionContext) { verifyLocalConnectionKeys(activateContext); await startOnboarding(activateContext); - // Initialize Language Server Protocol startLanguageServerProtocol(); - // Removed for unit test codefull experience standby - //await prepareTestExplorer(context, activateContext); - ext.rgApi = await getResourceGroupsApi(); // @ts-expect-error _rootTreeItem does not exist on type AzExtTreeDataProvider ext.azureAccountTreeItem = ext.rgApi.appResourceTree._rootTreeItem as AzureAccountTreeItemWithProjects; @@ -137,6 +138,9 @@ export async function activate(context: vscode.ExtensionContext) { activateContext.telemetry.properties.lastStep = 'registerFuncHostTaskEvents'; registerFuncHostTaskEvents(); + // Register codeful workflow context listener + registerCodefulWorkflowContextListener(context); + ext.rgApi.registerApplicationResourceResolver(getAzExtResourceType(logicAppFilter), new LogicAppResolver()); const azureResourcesApi = await getAzureResourcesExtensionApi(context, '2.0.0'); ext.rgApiV2 = azureResourcesApi; diff --git a/apps/vs-code-designer/src/onboarding.ts b/apps/vs-code-designer/src/onboarding.ts index c1331f11ffb..5b9fd9c8e14 100644 --- a/apps/vs-code-designer/src/onboarding.ts +++ b/apps/vs-code-designer/src/onboarding.ts @@ -47,16 +47,19 @@ export const startOnboarding = async (activateContext: IActionContext) => { activateContext.telemetry.properties.skippedOnboarding = 'true'; activateContext.telemetry.properties.skippedReason = 'devContainer'; return; - } + } - callWithTelemetryAndErrorHandling(autoRuntimeDependenciesValidationAndInstallationSetting, async (actionContext: IActionContext) => { - const binariesInstallStartTime = Date.now(); - await runWithDurationTelemetry(actionContext, autoRuntimeDependenciesValidationAndInstallationSetting, async () => { - activateContext.telemetry.properties.lastStep = autoRuntimeDependenciesValidationAndInstallationSetting; - await installBinaries(actionContext); - }); - activateContext.telemetry.measurements.binariesInstallDuration = Date.now() - binariesInstallStartTime; - }); + await callWithTelemetryAndErrorHandling( + autoRuntimeDependenciesValidationAndInstallationSetting, + async (actionContext: IActionContext) => { + const binariesInstallStartTime = Date.now(); + await runWithDurationTelemetry(actionContext, autoRuntimeDependenciesValidationAndInstallationSetting, async () => { + activateContext.telemetry.properties.lastStep = autoRuntimeDependenciesValidationAndInstallationSetting; + await installBinaries(actionContext); + }); + activateContext.telemetry.measurements.binariesInstallDuration = Date.now() - binariesInstallStartTime; + } + ); await callWithTelemetryAndErrorHandling(autoStartDesignTimeSetting, async (actionContext: IActionContext) => { await runWithDurationTelemetry(actionContext, showStartDesignTimeMessageSetting, async () => { diff --git a/apps/vs-code-designer/src/package.json b/apps/vs-code-designer/src/package.json index 83ecf6c0f93..30bccc6dcc4 100644 --- a/apps/vs-code-designer/src/package.json +++ b/apps/vs-code-designer/src/package.json @@ -53,11 +53,6 @@ "title": "Open designer", "category": "Azure Logic Apps" }, - { - "command": "azureLogicAppsStandard.openRunHistory", - "title": "Open run history", - "category": "Azure Logic Apps" - }, { "command": "azureLogicAppsStandard.viewContent", "title": "View content", @@ -723,8 +718,8 @@ "group": "navigation@3" }, { - "command": "azureLogicAppsStandard.openRunHistory", - "when": "resourceFilename==Program.cs && azureLogicAppsStandard.isAgentCodeful == true", + "command": "azureLogicAppsStandard.openOverview", + "when": "logicApps.hasProject && resourceFilename==Program.cs && azureLogicAppsStandard.isCodeful == true", "group": "navigation@3" }, { @@ -773,10 +768,6 @@ "command": "azureLogicAppsStandard.openDesigner", "when": "logicApps.hasProject && resourceFilename==workflow.json" }, - { - "command": "azureLogicAppsStandard.openRunHistory", - "when": "resourceFilename==Program.cs" - }, { "command": "azureLogicAppsStandard.viewContent", "when": "never" @@ -793,6 +784,10 @@ "command": "azureLogicAppsStandard.openOverview", "when": "logicApps.hasProject && resourceFilename==workflow.json" }, + { + "command": "azureLogicAppsStandard.openOverview", + "when": "logicApps.hasProject && resourceFilename==Program.cs && azureLogicAppsStandard.isCodeful == true" + }, { "command": "azureLogicAppsStandard.toggleAppSettingVisibility", "when": "never" diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx index dcb858b4ab4..9fad3a8067b 100644 --- a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx @@ -7,7 +7,7 @@ import { Button, Spinner, Text } from '@fluentui/react-components'; import { VSCodeContext } from '../../webviewCommunication'; import type { RootState } from '../../state/store'; import type { CreateWorkspaceState } from '../../state/createWorkspaceSlice'; -import { nextStep, previousStep, setCurrentStep, setFlowType } from '../../state/createWorkspaceSlice'; +import { nextStep, previousStep, setCurrentStep, setFlowType, resetState } from '../../state/createWorkspaceSlice'; import { useContext, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; // Import validation patterns and functions for navigation blocking @@ -25,7 +25,9 @@ const FLOW_TYPES = { CREATE_WORKSPACE_STRUCTURE: 'createWorkspaceStructure', }; -export const CreateWorkspace = () => { +// Internal component that contains the actual logic +// This is rendered by all the wrapper components +const CreateWorkspaceInternal = () => { const vscode = useContext(VSCodeContext); const dispatch = useDispatch(); const styles = useCreateWorkspaceStyles(); @@ -60,11 +62,6 @@ export const CreateWorkspace = () => { isDevContainerProject, } = createWorkspaceState; - // Set flow type when component mounts - useEffect(() => { - dispatch(setFlowType(FLOW_TYPES.CREATE_WORKSPACE)); - }, [dispatch]); - // Calculate total steps - always 2: Setup and Review + Create const totalSteps = 2; const isFirstStep = currentStep === 0; @@ -426,6 +423,34 @@ export const CreateWorkspace = () => { }; const handleCreate = () => { + // Validate that required paths exist before proceeding + const requirements = getValidationRequirements(flowType, logicAppType); + + if (requirements.needsWorkspacePath && (!workspaceProjectPath || !workspaceProjectPath.fsPath)) { + console.error('Cannot create workspace: workspaceProjectPath is missing or invalid', { + workspaceProjectPath, + flowType, + logicAppType, + }); + return; + } + + if (requirements.needsPackagePath && (!packagePath || !packagePath.fsPath)) { + console.error('Cannot create workspace: packagePath is missing or invalid', { + packagePath, + flowType, + }); + return; + } + + // Log what we're about to send for debugging + console.log('CreateWorkspace - Sending data:', { + workspaceProjectPath, + workspaceName, + logicAppType, + flowType, + }); + const baseData = { workspaceProjectPath, workspaceName, @@ -502,7 +527,7 @@ export const CreateWorkspace = () => { logicAppName, workflowType, workflowName, - targetFramework: logicAppType === ProjectType.agentCodeful ? 'net8' : targetFramework, + targetFramework: logicAppType === ProjectType.codeful ? 'net8' : targetFramework, ...(logicAppType === ProjectType.customCode && { functionFolderName, functionNamespace, @@ -528,7 +553,37 @@ export const CreateWorkspace = () => { ? ExtensionCommand.createWorkflow : ExtensionCommand.createWorkspace; - vscode.postMessage({ command, data }); + // Prepare diagnostic data that will be sent to extension for logging + const diagnostics: Record = { + command, + flowType, + timestamp: new Date().toISOString(), + dataKeys: Object.keys(data), + workspaceProjectPath: data.workspaceProjectPath, + workspaceName: data.workspaceName, + logicAppName: data.logicAppName, + logicAppType: data.logicAppType, + hasWorkspaceProjectPath: !!data.workspaceProjectPath, + hasWorkspaceProjectPathFsPath: !!data.workspaceProjectPath?.fsPath, + workspaceProjectPathFsPath: data.workspaceProjectPath?.fsPath, + }; + + // Try to serialize the full data to detect any issues + let serializationSuccess = false; + let dataSize = 0; + try { + const serialized = JSON.stringify(data); + dataSize = serialized.length; + serializationSuccess = true; + } catch (error) { + diagnostics['serializationError'] = error instanceof Error ? error.message : String(error); + } + + diagnostics['serializationSuccess'] = serializationSuccess; + diagnostics['dataSize'] = dataSize; + + // Include diagnostics in the message so extension can log it + vscode.postMessage({ command, data, _diagnostics: diagnostics }); }; const renderCurrentStep = () => { @@ -629,44 +684,59 @@ export const CreateWorkspace = () => { }; // Separate components for each flow type that set their flowType +export const CreateWorkspace = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(resetState(undefined)); + dispatch(setFlowType(FLOW_TYPES.CREATE_WORKSPACE)); + }, [dispatch]); + + return ; +}; + export const CreateWorkspaceFromPackage = () => { const dispatch = useDispatch(); useEffect(() => { + dispatch(resetState(undefined)); dispatch(setFlowType(FLOW_TYPES.CREATE_WORKSPACE_FROM_PACKAGE)); }, [dispatch]); - return ; + return ; }; export const CreateWorkspaceStructure = () => { const dispatch = useDispatch(); useEffect(() => { + dispatch(resetState(undefined)); dispatch(setFlowType(FLOW_TYPES.CONVERT_TO_WORKSPACE)); }, [dispatch]); - return ; + return ; }; export const CreateLogicApp = () => { const dispatch = useDispatch(); useEffect(() => { + dispatch(resetState(undefined)); dispatch(setFlowType(FLOW_TYPES.CREATE_LOGIC_APP)); }, [dispatch]); - return ; + return ; }; export const CreateWorkflow = () => { const dispatch = useDispatch(); useEffect(() => { + dispatch(resetState({ preserveLogicAppData: true })); dispatch(setFlowType(FLOW_TYPES.CREATE_WORKFLOW)); }, [dispatch]); - return ; + return ; }; export function useOutlet() { diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/logicAppTypeStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/logicAppTypeStep.tsx index a2666ebe677..579698bd846 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/logicAppTypeStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/logicAppTypeStep.tsx @@ -172,7 +172,7 @@ export const LogicAppTypeStep: React.FC = () => {
- + {intlText.CODEFUL_DESCRIPTION} diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/workflowTypeStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/workflowTypeStep.tsx index 23f37c20461..f40c7a22ba1 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/workflowTypeStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/workflowTypeStep.tsx @@ -55,7 +55,7 @@ export const WorkflowTypeStep: React.FC = () => { }; const selectWorkflowTypes = useMemo(() => { - const logicAppCodeType = logicAppType === ProjectType.agentCodeful ? logicAppCodeTypes.CODEFUL : logicAppCodeTypes.CODELESS; + const logicAppCodeType = logicAppType === ProjectType.codeful ? logicAppCodeTypes.CODEFUL : logicAppCodeTypes.CODELESS; return workflowTypes[logicAppCodeType]; }, [logicAppType, workflowTypes]); @@ -79,7 +79,7 @@ export const WorkflowTypeStep: React.FC = () => { { ); })} - {workflowType && ( - - {workflowTypes[workflowType]} - - )}
); diff --git a/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx b/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx index 9ff89b5b9c8..16eefd50548 100644 --- a/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx +++ b/apps/vs-code-react/src/app/designer/DesignerCommandBar/index.tsx @@ -71,6 +71,7 @@ export interface DesignerCommandBarProps { runId: string; kind?: string; getAgentUrl?: () => Promise; + supportsUnitTest?: boolean; } export const DesignerCommandBar: React.FC = ({ @@ -80,9 +81,9 @@ export const DesignerCommandBar: React.FC = ({ onRefresh, isDarkMode, isUnitTest, - isLocal, runId, getAgentUrl, + supportsUnitTest, }) => { const vscode = useContext(VSCodeContext); const dispatch = DesignerStore.dispatch; @@ -309,7 +310,7 @@ export const DesignerCommandBar: React.FC = ({ onResubmit(); }, }, - ...(isLocal + ...(supportsUnitTest ? [ { key: 'CreateUnitTestFromRun', diff --git a/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx b/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx index 6598e226486..417d5e84d52 100644 --- a/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx +++ b/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx @@ -91,12 +91,13 @@ export interface DesignerCommandBarProps { switchToDesignerView: () => void; switchToCodeView: () => void; switchToMonitoringView: () => void; + supportsUnitTest?: boolean; + showRunHistory?: boolean; } export const DesignerCommandBar: React.FC = ({ isDarkMode, isUnitTest, - isLocal, runId, saveWorkflow: _saveWorkflow, saveWorkflowFromCode: _saveWorkflowFromCode, @@ -107,6 +108,8 @@ export const DesignerCommandBar: React.FC = ({ switchToDesignerView, switchToCodeView, switchToMonitoringView, + supportsUnitTest, + showRunHistory = true, }) => { const vscode = useContext(VSCodeContext); const dispatch = DesignerStore.dispatch; @@ -263,18 +266,20 @@ export const DesignerCommandBar: React.FC = ({ > Code - + {showRunHistory ? ( + + ) : null} ); @@ -360,7 +365,7 @@ export const DesignerCommandBar: React.FC = ({ - {isLocal && ( + {supportsUnitTest && ( }> {intlText.CREATE_UNIT_TEST_FROM_RUN} diff --git a/apps/vs-code-react/src/app/designer/app.tsx b/apps/vs-code-react/src/app/designer/app.tsx index 2a4bc868444..10a9ae08bb0 100644 --- a/apps/vs-code-react/src/app/designer/app.tsx +++ b/apps/vs-code-react/src/app/designer/app.tsx @@ -56,6 +56,7 @@ const DesignerAppV1 = () => { isUnitTest, unitTestDefinition, workflowRuntimeBaseUrl, + supportsUnitTest, } = vscodeState; const [standardApp, setStandardApp] = useState(panelMetaData?.standardApp); const [customCode, setCustomCode] = useState | undefined>(panelMetaData?.customCodeData); @@ -251,6 +252,7 @@ const DesignerAppV1 = () => { isLocal={isLocal} runId={runId} getAgentUrl={getAgentUrl} + supportsUnitTest={supportsUnitTest} /> ); diff --git a/apps/vs-code-react/src/app/designer/appV2.tsx b/apps/vs-code-react/src/app/designer/appV2.tsx index 586056d2b55..8e670e9e8b4 100644 --- a/apps/vs-code-react/src/app/designer/appV2.tsx +++ b/apps/vs-code-react/src/app/designer/appV2.tsx @@ -32,6 +32,7 @@ export const DesignerApp = () => { const vscode = useContext(VSCodeContext); const dispatch: AppDispatch = useDispatch(); const vscodeState = useSelector((state: RootState) => state.designer); + const { supportsUnitTest } = vscodeState; const styles = useAppStyles(); const { panelMetaData, @@ -60,6 +61,7 @@ export const DesignerApp = () => { const [initialWorkflow, setInitialWorkflow] = useState(panelMetaData?.standardApp); const [workflow, setWorkflow] = useState(panelMetaData?.standardApp); const [customCode, setCustomCode] = useState | undefined>(panelMetaData?.customCodeData); + const isCodefulWorkflow = panelMetaData?.localSettings?.WORKFLOW_CODEFUL_ENABLED === 'true'; const [designerID, setDesignerID] = useState(guid()); const [workflowDefinitionId, setWorkflowDefinitionId] = useState(guid()); @@ -355,6 +357,8 @@ export const DesignerApp = () => { switchToDesignerView={switchToDesignerView} switchToCodeView={switchToCodeView} switchToMonitoringView={switchToMonitoringView} + supportsUnitTest={supportsUnitTest} + showRunHistory={!isCodefulWorkflow} /> {!isCodeView && ( diff --git a/apps/vs-code-react/src/app/languageServer/connectionView.tsx b/apps/vs-code-react/src/app/languageServer/connectionView.tsx index e5367a27145..16cf73de605 100644 --- a/apps/vs-code-react/src/app/languageServer/connectionView.tsx +++ b/apps/vs-code-react/src/app/languageServer/connectionView.tsx @@ -7,9 +7,9 @@ import { useThemeObserver, store as DesignerStore, } from '@microsoft/logic-apps-designer'; -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useContext, useMemo, useRef, useState } from 'react'; import type { Connection, ConnectionCreationInfo } from '@microsoft/logic-apps-shared'; -import { getRecordEntry, Theme } from '@microsoft/logic-apps-shared'; +import { getRecordEntry, isArmResourceId, Theme } from '@microsoft/logic-apps-shared'; import { getDesignerServices } from '../designer/servicesHelper'; import { VSCodeContext } from '../../webviewCommunication'; import { useDispatch, useSelector } from 'react-redux'; @@ -24,7 +24,13 @@ const ConnectionView = ({ connectorName, connectorType, currentConnectionId, -}: { connectorName: string; connectorType: string; currentConnectionId: string }) => { + pendingLocalConnectionDataRef, +}: { + connectorName: string; + connectorType: string; + currentConnectionId: string; + pendingLocalConnectionDataRef: React.MutableRefObject; +}) => { const vscode = useContext(VSCodeContext); const sendMsgToVsix = useCallback( (msg: any) => { @@ -38,19 +44,30 @@ const ConnectionView = ({ }, [sendMsgToVsix]); const onConnectionSuccessful = (connection: Connection) => { - const designerState = DesignerStore.getState(); - const { connectionsMapping, connectionReferences: referencesObject } = designerState.connections; - const connectionReferences = Object.keys(connectionsMapping ?? {}).reduce((references: ConnectionReferences, nodeId: string) => { - const referenceKey = getRecordEntry(connectionsMapping, nodeId); - if (!referenceKey || !referencesObject[referenceKey]) { - return references; - } + if (isArmResourceId(connection.id)) { + // Managed API connection: send connectionReferences so the extension host + // can persist them to connections.json (mirrors the designer save flow). + const designerState = DesignerStore.getState(); + const { connectionsMapping, connectionReferences: referencesObject } = designerState.connections; + const connectionReferences = Object.keys(connectionsMapping ?? {}).reduce((references: ConnectionReferences, nodeId: string) => { + const referenceKey = getRecordEntry(connectionsMapping, nodeId); + if (!referenceKey || !referencesObject[referenceKey]) { + return references; + } - references[referenceKey] = referencesObject[referenceKey]; - return references; - }, {}); + references[referenceKey] = referencesObject[referenceKey]; + return references; + }, {}); - sendMsgToVsix({ command: ExtensionCommand.insert_connection, connection: connection, connectionReferences }); + sendMsgToVsix({ command: ExtensionCommand.insert_connection, connection, connectionReferences }); + } else { + // Local connection: include the connectionAndSetting captured from the + // writeConnection callback so the extension host can write connections.json + // and update the C# source in a single atomic handler. + const connectionAndSetting = pendingLocalConnectionDataRef.current; + pendingLocalConnectionDataRef.current = null; + sendMsgToVsix({ command: ExtensionCommand.insert_connection, connection, connectionAndSetting }); + } }; return ( @@ -97,6 +114,29 @@ export const LanguageServerConnectionView = () => { [vscode] ); + // Ref to capture connectionAndSetting from the writeConnection callback + // (addConnection message) so it can be included in the insert_connection + // message for local connections — avoids a race between two separate messages. + const pendingLocalConnectionDataRef = useRef(null); + + // Wrap the vscode context so addConnection messages are intercepted for + // local connections. The connection data is captured in the ref and sent + // atomically with insert_connection instead. + // TODO(aeldridge): The add connection logic should be decoupled from existing designer flows so this workaround is not necessary. + const wrappedVscode = useMemo( + () => ({ + ...vscode, + postMessage: (msg: any) => { + if (msg?.command === ExtensionCommand.addConnection) { + pendingLocalConnectionDataRef.current = msg.connectionAndSetting; + return; + } + vscode.postMessage(msg); + }, + }), + [vscode] + ); + const services = useMemo(() => { const fileSystemConnectionCreate = async ( connectionInfo: FileSystemConnectionInfo, @@ -121,7 +161,7 @@ export const LanguageServerConnectionView = () => { connectionData, panelMetaData, fileSystemConnectionCreate, - vscode, + wrappedVscode, oauthRedirectUrl, hostVersion, queryClient, @@ -136,6 +176,7 @@ export const LanguageServerConnectionView = () => { connectionData, panelMetaData, vscode, + wrappedVscode, oauthRedirectUrl, dispatch, hostVersion, @@ -168,7 +209,12 @@ export const LanguageServerConnectionView = () => { }} appSettings={panelMetaData?.localSettings} > - + diff --git a/apps/vs-code-react/src/app/overview/app.tsx b/apps/vs-code-react/src/app/overview/app.tsx index c54b3c6090a..9da01e6d2a3 100644 --- a/apps/vs-code-react/src/app/overview/app.tsx +++ b/apps/vs-code-react/src/app/overview/app.tsx @@ -2,7 +2,7 @@ import { QueryKeys } from '../../run-service'; import type { RunDisplayItem } from '../../run-service'; import type { RootState } from '../../state/store'; import { VSCodeContext } from '../../webviewCommunication'; -import { Overview, isRunError, mapToRunItem } from '@microsoft/designer-ui'; +import { Overview, type OverviewPropertiesProps, isRunError, mapToRunItem } from '@microsoft/designer-ui'; import { type Runs, StandardRunService, Theme, equals, isNullOrUndefined, isRuntimeUp } from '@microsoft/logic-apps-shared'; import { ExtensionCommand, HttpClient } from '@microsoft/vscode-extension-logic-apps'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; @@ -13,6 +13,7 @@ import invariant from 'tiny-invariant'; import { useOverviewStyles } from './overviewStyles'; import { getTheme, useThemeObserver } from '@microsoft/logic-apps-designer'; import { fetchAgentUrl } from './services/workflowService'; +import { Dropdown, Field, Option, useId } from '@fluentui/react-components'; export interface CallbackInfo { method?: string; @@ -21,17 +22,33 @@ export interface CallbackInfo { export const OverviewApp = () => { const workflowState = useSelector((state: RootState) => state.workflow); const vscode = useContext(VSCodeContext); - const { apiVersion, baseUrl, accessToken, workflowProperties, hostVersion, azureDetails, kind, connectionData } = workflowState; + const { apiVersion, baseUrl, accessToken, workflowProperties, workflowPropertiesList, hostVersion, azureDetails, kind, connectionData } = + workflowState; const [theme, setTheme] = useState(getTheme(document.body)); const styles = useOverviewStyles(); + const dropdownId = useId('workflow-dropdown'); + const workflowOptions = useMemo(() => { + return workflowPropertiesList?.length ? workflowPropertiesList : [workflowProperties]; + }, [workflowPropertiesList, workflowProperties]); + const [selectedWorkflowName, setSelectedWorkflowName] = useState(workflowOptions[0]?.name ?? workflowProperties.name); + const selectedWorkflowProperties = useMemo(() => { + return workflowOptions.find((workflow) => workflow.name === selectedWorkflowName) ?? workflowOptions[0] ?? workflowProperties; + }, [selectedWorkflowName, workflowOptions, workflowProperties]); + const selectedWorkflowKind = selectedWorkflowProperties.kind ?? kind; useThemeObserver(document.body, theme, setTheme, { attributes: true, }); const isAgentWorkflow = useMemo(() => { - return equals(kind, 'agent', true); - }, [kind]); + return equals(selectedWorkflowKind, 'agent', true); + }, [selectedWorkflowKind]); + + useEffect(() => { + if (!workflowOptions.some((workflow) => workflow.name === selectedWorkflowName)) { + setSelectedWorkflowName(workflowOptions[0]?.name ?? ''); + } + }, [selectedWorkflowName, workflowOptions]); const [isWorkflowRuntimeRunning, setIsWorkflowRuntimeRunning] = useState(true); useEffect(() => { @@ -75,10 +92,10 @@ export const OverviewApp = () => { return new StandardRunService({ baseUrl: baseUrl, apiVersion: apiVersion, - workflowName: workflowProperties.name, + workflowName: selectedWorkflowProperties.name, httpClient, }); - }, [baseUrl, apiVersion, workflowProperties.name, httpClient]); + }, [baseUrl, apiVersion, selectedWorkflowProperties.name, httpClient]); const loadRuns = ({ pageParam }: { pageParam?: string }) => { if (!runService) { @@ -92,13 +109,13 @@ export const OverviewApp = () => { }; const { data, error, isLoading, fetchNextPage, hasNextPage, refetch, isRefetching } = useInfiniteQuery( - [QueryKeys.runsData], + [QueryKeys.runsData, selectedWorkflowProperties.name], loadRuns, { getNextPageParam: (lastPage) => lastPage.nextLink, refetchInterval: 5000, // 5 seconds refresh interval refetchIntervalInBackground: false, // It will automatically refetch when window is focused - enabled: isWorkflowRuntimeRunning, + enabled: isWorkflowRuntimeRunning && !!selectedWorkflowProperties.name, } ); @@ -115,8 +132,10 @@ export const OverviewApp = () => { isLoading: runTriggerLoading, error: runTriggerError, } = useMutation(async () => { - invariant(workflowProperties.callbackInfo, 'Run Trigger should not be runable unless callbackInfo has information'); - await runService?.runTrigger(workflowProperties.callbackInfo as CallbackInfo); + if (!selectedWorkflowProperties.callbackInfo) { + throw new Error('Cannot run trigger: Workflow runtime is not running or callback URL is not available'); + } + await runService?.runTrigger(selectedWorkflowProperties.callbackInfo as CallbackInfo); return refetch(); }); @@ -128,11 +147,11 @@ export const OverviewApp = () => { ); const { isLoading: agentUrlIsLoading, data: agentUrlData } = useQuery( - ['agentUrl', isWorkflowRuntimeRunning, baseUrl], + ['agentUrl', isWorkflowRuntimeRunning, baseUrl, selectedWorkflowProperties.name], async () => { invariant(!!httpClient, 'Agent URL should not be retrieved unless httpClient is available'); return fetchAgentUrl( - workflowProperties.name, + selectedWorkflowProperties.name, baseUrl, httpClient, clientId, @@ -147,7 +166,7 @@ export const OverviewApp = () => { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, - enabled: isWorkflowRuntimeRunning && isAgentWorkflow && !isNullOrUndefined(httpClient), + enabled: isWorkflowRuntimeRunning && isAgentWorkflow && !!selectedWorkflowProperties.name && !isNullOrUndefined(httpClient), } ); @@ -178,7 +197,29 @@ export const OverviewApp = () => { return (
+ {workflowState.isCodeful && workflowOptions.length > 1 ? ( + + { + if (data.optionValue) { + setSelectedWorkflowName(data.optionValue); + } + }} + > + {workflowOptions.map((workflow) => ( + + ))} + + + ) : null} { agentUrlData={agentUrlData} isWorkflowRuntimeRunning={isWorkflowRuntimeRunning} runItems={runItems ?? []} - workflowProperties={workflowState.workflowProperties} + workflowProperties={selectedWorkflowProperties} isRefreshing={isRefetching} onLoadMoreRuns={fetchNextPage} onLoadRuns={refetch} @@ -201,7 +242,7 @@ export const OverviewApp = () => { }} onRunTrigger={runTriggerCall} onVerifyRunId={onVerifyRunId} - supportsUnitTest={workflowState.isLocal} + supportsUnitTest={workflowState.supportsUnitTest ?? false} onCreateUnitTestFromRun={(run: RunDisplayItem) => { vscode.postMessage({ command: ExtensionCommand.createUnitTestFromRun, diff --git a/apps/vs-code-react/src/app/overview/overviewStyles.ts b/apps/vs-code-react/src/app/overview/overviewStyles.ts index 2d795546f4b..3b567b7de18 100644 --- a/apps/vs-code-react/src/app/overview/overviewStyles.ts +++ b/apps/vs-code-react/src/app/overview/overviewStyles.ts @@ -5,4 +5,8 @@ export const useOverviewStyles = makeStyles({ height: '100vh', padding: `0 ${tokens.spacingHorizontalXL}`, }, + workflowSelector: { + maxWidth: '400px', + paddingTop: tokens.spacingVerticalM, + }, }); diff --git a/apps/vs-code-react/src/app/runHistory/app.tsx b/apps/vs-code-react/src/app/runHistory/app.tsx deleted file mode 100644 index e1432f221bb..00000000000 --- a/apps/vs-code-react/src/app/runHistory/app.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { RootState } from '../../state/store'; -import { InitRunService, StandardRunService } from '@microsoft/logic-apps-shared'; -import { HttpClient } from '@microsoft/vscode-extension-logic-apps'; -import { useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { useRunHistoryStyles } from './runHistoryStyles'; -import { RunHistoryPanelInstance } from '@microsoft/logic-apps-designer-v2'; -import type { OptionOnSelectData, SelectionEvents } from '@fluentui/react-components'; -import { Dropdown, Option, Title1, useId } from '@fluentui/react-components'; -import { useIntl } from 'react-intl'; - -export interface CallbackInfo { - method?: string; - value: string; -} -export const RunHistoryApp = () => { - const workflowState = useSelector((state: RootState) => state.workflow); - const styles = useRunHistoryStyles(); - const dropdownId = useId('dropdown-workflows'); - const intl = useIntl(); - const { workflowNames } = workflowState; - const [selectedWorkflow, setSelectedWorkflow] = useState(''); - - const onOptionSelect = (_ev: SelectionEvents, data: OptionOnSelectData) => { - setSelectedWorkflow(data.optionText ?? ''); - }; - - useEffect(() => { - if (workflowNames && workflowNames?.length > 0) { - setSelectedWorkflow(workflowNames[0]); - } - }, [workflowNames]); - - const intlText = { - RUN_HISTORY_TITLE: intl.formatMessage({ - defaultMessage: 'Run history', - id: 'q/i13s', - description: 'Text for run history title', - }), - WORKFLOW: intl.formatMessage({ - defaultMessage: 'Workflow', - id: 'lW2CUD', - description: 'Text for workflow label', - }), - DROPDOWN_PLACEHOLDER: intl.formatMessage({ - defaultMessage: 'Select a workflow', - id: 'zbBPqf', - description: 'Text for workflow dropdown placeholder', - }), - }; - - const runService = useMemo(() => { - const httpClient = new HttpClient({ - accessToken: workflowState.accessToken, - baseUrl: workflowState.baseUrl, - apiHubBaseUrl: '', - hostVersion: workflowState.hostVersion, - }); - - return new StandardRunService({ - baseUrl: workflowState.baseUrl, - apiVersion: workflowState.apiVersion, - workflowName: selectedWorkflow, - httpClient, - }); - }, [workflowState.accessToken, workflowState.baseUrl, workflowState.hostVersion, workflowState.apiVersion, selectedWorkflow]); - - useEffect(() => { - InitRunService(runService); - }, [runService]); - - return ( -
- {intlText.RUN_HISTORY_TITLE} -
- - - {workflowNames?.map((workflow) => ( - - ))} - -
- -
- ); -}; diff --git a/apps/vs-code-react/src/app/runHistory/runHistoryStyles.ts b/apps/vs-code-react/src/app/runHistory/runHistoryStyles.ts deleted file mode 100644 index df71a531588..00000000000 --- a/apps/vs-code-react/src/app/runHistory/runHistoryStyles.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { makeStyles, tokens } from '@fluentui/react-components'; - -export const useRunHistoryStyles = makeStyles({ - runHistoryContainer: { - height: '100vh', - padding: `${tokens.spacingHorizontalXL}`, - }, - runHistoryTitle: { - margin: `0 ${tokens.spacingHorizontalMNudge}`, - }, - workflowDropdown: { - display: 'grid', - gridTemplateRows: 'repeat(1fr)', - justifyItems: 'start', - gap: '2px', - maxWidth: '400px', - margin: '10px', - }, -}); diff --git a/apps/vs-code-react/src/intl/messages.ts b/apps/vs-code-react/src/intl/messages.ts index 96896f19c88..98a6e3ab52d 100644 --- a/apps/vs-code-react/src/intl/messages.ts +++ b/apps/vs-code-react/src/intl/messages.ts @@ -1082,6 +1082,16 @@ export const overviewMessages = defineMessages({ id: 'VWH06W', description: 'Debug project error message', }, + WORKFLOW: { + defaultMessage: 'Workflow', + id: 'lW2CUD', + description: 'Text for workflow label', + }, + SELECT_WORKFLOW: { + defaultMessage: 'Select a workflow', + id: 'zbBPqf', + description: 'Text for workflow dropdown placeholder', + }, }); export const chatMessages = defineMessages({ diff --git a/apps/vs-code-react/src/router/index.tsx b/apps/vs-code-react/src/router/index.tsx index 5d0d7eab6fb..9090c570f2d 100644 --- a/apps/vs-code-react/src/router/index.tsx +++ b/apps/vs-code-react/src/router/index.tsx @@ -21,7 +21,6 @@ import { import { StateWrapper } from '../stateWrapper'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { RouteName } from '@microsoft/vscode-extension-logic-apps'; -import { RunHistoryApp } from '../app/runHistory/app'; export const Router: React.FC = () => { return ( @@ -40,7 +39,6 @@ export const Router: React.FC = () => { } /> } /> } /> - } /> }> } /> diff --git a/apps/vs-code-react/src/run-service/types.ts b/apps/vs-code-react/src/run-service/types.ts index 94cd5a33bd1..7fa16353b59 100644 --- a/apps/vs-code-react/src/run-service/types.ts +++ b/apps/vs-code-react/src/run-service/types.ts @@ -295,6 +295,7 @@ export interface UpdateCallbackInfoMessage { command: typeof ExtensionCommand.update_callback_info; data: { callbackInfo?: ICallbackUrlResponse; + workflowName?: string; }; } diff --git a/apps/vs-code-react/src/state/DesignerSlice.ts b/apps/vs-code-react/src/state/DesignerSlice.ts index a1f929eac57..8945e59bb64 100644 --- a/apps/vs-code-react/src/state/DesignerSlice.ts +++ b/apps/vs-code-react/src/state/DesignerSlice.ts @@ -25,6 +25,7 @@ export interface DesignerState { hostVersion: string; isUnitTest: boolean; unitTestDefinition: UnitTestDefinition | null; + supportsUnitTest: boolean; } const initialState: DesignerState = { @@ -56,6 +57,7 @@ const initialState: DesignerState = { hostVersion: '', isUnitTest: false, unitTestDefinition: null, + supportsUnitTest: false, }; export const designerSlice: Slice = createSlice({ @@ -78,6 +80,7 @@ export const designerSlice: Slice = createSlice({ isUnitTest, unitTestDefinition, workflowRuntimeBaseUrl, + supportsUnitTest, } = action.payload; state.panelMetaData = panelMetadata; @@ -94,6 +97,7 @@ export const designerSlice: Slice = createSlice({ state.hostVersion = hostVersion; state.isUnitTest = isUnitTest; state.unitTestDefinition = unitTestDefinition; + state.supportsUnitTest = supportsUnitTest ?? (isLocal && !isMonitoringView); }, updateRuntimeBaseUrl: (state, action: PayloadAction) => { state.workflowRuntimeBaseUrl = action.payload ?? ''; diff --git a/apps/vs-code-react/src/state/WorkflowSlice.ts b/apps/vs-code-react/src/state/WorkflowSlice.ts index abd79a17f02..23efa7bf16d 100644 --- a/apps/vs-code-react/src/state/WorkflowSlice.ts +++ b/apps/vs-code-react/src/state/WorkflowSlice.ts @@ -12,19 +12,22 @@ export interface InitializePayload { accessToken?: string; cloudHost?: string; workflowProperties: OverviewPropertiesProps; + workflowPropertiesList?: OverviewPropertiesProps[]; reviewContent?: IValidationData; hostVersion?: string; isLocal?: boolean; isWorkflowRuntimeRunning?: boolean; - workflowNames?: string[]; + isCodeful?: boolean; azureDetails?: AzureConnectorDetails; kind?: string; + supportsUnitTest?: boolean; connectionData?: Record; } export interface UpdateCallbackInfoPayload { baseUrl?: string; callbackInfo?: ICallbackUrlResponse; + workflowName?: string; } export const Status = { @@ -41,6 +44,7 @@ export interface WorkflowState { apiVersion: string; baseUrl: string; workflowProperties: OverviewPropertiesProps; + workflowPropertiesList?: OverviewPropertiesProps[]; exportData: ExportData; statuses?: string[]; finalStatus?: Status; @@ -48,7 +52,8 @@ export interface WorkflowState { hostVersion?: string; isLocal?: boolean; isWorkflowRuntimeRunning?: boolean; - workflowNames?: string[]; + isCodeful?: boolean; + supportsUnitTest?: boolean; azureDetails?: AzureConnectorDetails; kind?: string; connectionData?: Record; @@ -92,14 +97,16 @@ export const workflowSlice = createSlice({ corsNotice, accessToken, workflowProperties, + workflowPropertiesList, reviewContent, cloudHost, hostVersion, isLocal, isWorkflowRuntimeRunning, - workflowNames, + isCodeful, azureDetails, kind, + supportsUnitTest, connectionData, } = action.payload; const initializedState = state; @@ -109,6 +116,7 @@ export const workflowSlice = createSlice({ initializedState.baseUrl = baseUrl ?? ''; initializedState.corsNotice = corsNotice; initializedState.workflowProperties = workflowProperties; + initializedState.workflowPropertiesList = workflowPropertiesList; initializedState.reviewContent = reviewContent; initializedState.exportData = { selectedWorkflows: [], @@ -131,20 +139,29 @@ export const workflowSlice = createSlice({ initializedState.hostVersion = hostVersion; initializedState.isLocal = isLocal; initializedState.isWorkflowRuntimeRunning = isWorkflowRuntimeRunning; - initializedState.workflowNames = workflowNames; + initializedState.isCodeful = isCodeful; initializedState.azureDetails = azureDetails; initializedState.kind = kind; + initializedState.supportsUnitTest = supportsUnitTest; initializedState.connectionData = connectionData || {}; }, updateBaseUrl: (state: WorkflowState, action: PayloadAction) => { state.baseUrl = action.payload ?? ''; }, updateCallbackInfo: (state: WorkflowState, action: PayloadAction) => { - const { callbackInfo } = action.payload; - state.workflowProperties = { - ...state.workflowProperties, - callbackInfo: callbackInfo, - }; + const { callbackInfo, workflowName } = action.payload; + if (workflowName && state.workflowPropertiesList) { + state.workflowPropertiesList = state.workflowPropertiesList.map((workflowProperties) => + workflowProperties.name === workflowName ? { ...workflowProperties, callbackInfo } : workflowProperties + ); + } + + if (!workflowName || state.workflowProperties.name === workflowName) { + state.workflowProperties = { + ...state.workflowProperties, + callbackInfo: callbackInfo, + }; + } }, updateAccessToken: (state: WorkflowState, action: PayloadAction) => { state.accessToken = action.payload; diff --git a/apps/vs-code-react/src/state/__test__/WorkflowSlice.test.ts b/apps/vs-code-react/src/state/__test__/WorkflowSlice.test.ts new file mode 100644 index 00000000000..2bdcc4e1379 --- /dev/null +++ b/apps/vs-code-react/src/state/__test__/WorkflowSlice.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import workflowReducer, { initializeWorkflow, updateCallbackInfo } from '../WorkflowSlice'; + +describe('WorkflowSlice', () => { + it('initializes centralized codeful overview workflow properties', () => { + const state = workflowReducer( + undefined, + initializeWorkflow({ + apiVersion: '2019-10-01-edge-preview', + baseUrl: 'http://localhost:7071/runtime/webhooks/workflow/api/management', + isCodeful: true, + workflowProperties: { + name: 'workflow-a', + stateType: 'Stateful', + }, + workflowPropertiesList: [ + { + name: 'workflow-a', + stateType: 'Stateful', + }, + { + name: 'workflow-b', + stateType: 'Stateful', + }, + ], + }) + ); + + expect(state.isCodeful).toBe(true); + expect(state.workflowProperties.name).toBe('workflow-a'); + expect(state.workflowPropertiesList?.map((workflow) => workflow.name)).toEqual(['workflow-a', 'workflow-b']); + }); + + it('updates callback info for only the matching codeful workflow', () => { + const state = workflowReducer( + { + baseUrl: '', + apiVersion: '2019-10-01-edge-preview', + workflowProperties: { + name: 'workflow-a', + stateType: 'Stateful', + }, + workflowPropertiesList: [ + { + name: 'workflow-a', + stateType: 'Stateful', + }, + { + name: 'workflow-b', + stateType: 'Stateful', + }, + ], + exportData: { + selectedWorkflows: [], + selectedSubscription: '', + location: '', + validationState: '', + targetDirectory: { + fsPath: '', + path: '', + }, + packageUrl: '', + managedConnections: { + isManaged: false, + resourceGroup: undefined, + resourceGroupLocation: undefined, + }, + selectedAdvanceOptions: [], + }, + }, + updateCallbackInfo({ + workflowName: 'workflow-b', + callbackInfo: { + value: 'http://localhost/workflows/workflow-b/triggers/manual/run', + method: 'POST', + }, + }) + ); + + expect(state.workflowProperties.callbackInfo).toBeUndefined(); + expect(state.workflowPropertiesList?.[0].callbackInfo).toBeUndefined(); + expect(state.workflowPropertiesList?.[1].callbackInfo?.value).toBe('http://localhost/workflows/workflow-b/triggers/manual/run'); + }); +}); diff --git a/apps/vs-code-react/src/state/createWorkspaceSlice.ts b/apps/vs-code-react/src/state/createWorkspaceSlice.ts index e3a0ee0eb19..79b718f4498 100644 --- a/apps/vs-code-react/src/state/createWorkspaceSlice.ts +++ b/apps/vs-code-react/src/state/createWorkspaceSlice.ts @@ -85,10 +85,11 @@ export const createWorkspaceSlice = createSlice) => { - const { separator, platform, logicAppType } = action.payload; + const { separator, platform, logicAppType, logicAppName } = action.payload; state.separator = separator; state.platform = platform; state.logicAppType = logicAppType || ''; + state.logicAppName = logicAppName || ''; }, setCurrentStep: (state, action: PayloadAction) => { state.currentStep = action.payload; @@ -191,7 +192,22 @@ export const createWorkspaceSlice = createSlice) => { state.isComplete = action.payload; }, - resetState: () => initialState, + resetState: (state, action: PayloadAction<{ preserveLogicAppData?: boolean } | undefined>) => { + const preserveLogicAppData = action.payload?.preserveLogicAppData; + const preservedLogicAppType = preserveLogicAppData ? state.logicAppType : ''; + const preservedLogicAppName = preserveLogicAppData ? state.logicAppName : ''; + const preservedSeparator = preserveLogicAppData ? state.separator : '/'; + const preservedPlatform = preserveLogicAppData ? state.platform : null; + + Object.assign(state, initialState); + + if (preserveLogicAppData) { + state.logicAppType = preservedLogicAppType; + state.logicAppName = preservedLogicAppName; + state.separator = preservedSeparator; + state.platform = preservedPlatform; + } + }, nextStep: (state) => { if (state.currentStep < 7) { // Maximum of 8 steps (0-7) for custom code, 7 steps (0-6) for others diff --git a/apps/vs-code-react/src/stateWrapper.tsx b/apps/vs-code-react/src/stateWrapper.tsx index 3042d0355e3..1f3b14d8b75 100644 --- a/apps/vs-code-react/src/stateWrapper.tsx +++ b/apps/vs-code-react/src/stateWrapper.tsx @@ -35,10 +35,6 @@ export const StateWrapper: React.FC = () => { navigate(`/${ProjectName.unitTest}`, { replace: true }); break; } - case ProjectName.runHistory: { - navigate(`/${ProjectName.runHistory}`, { replace: true }); - break; - } case ProjectName.languageServer: { switch (projectState.route) { case RouteName.connectionView: { diff --git a/libs/designer-ui/src/lib/overview/__test__/overviewcommandbar.spec.tsx b/libs/designer-ui/src/lib/overview/__test__/overviewcommandbar.spec.tsx index b90531fe161..a81daf4a67e 100644 --- a/libs/designer-ui/src/lib/overview/__test__/overviewcommandbar.spec.tsx +++ b/libs/designer-ui/src/lib/overview/__test__/overviewcommandbar.spec.tsx @@ -26,7 +26,9 @@ describe('lib/overview/overviewcommandbar', () => { }); it('renders with Run trigger button', () => { - const tree = renderer.create().toJSON(); + const tree = renderer + .create() + .toJSON(); expect(tree).toMatchSnapshot(); }); }); diff --git a/libs/designer-ui/src/lib/overview/index.tsx b/libs/designer-ui/src/lib/overview/index.tsx index d27b510c0b1..f49ead715d3 100644 --- a/libs/designer-ui/src/lib/overview/index.tsx +++ b/libs/designer-ui/src/lib/overview/index.tsx @@ -140,6 +140,7 @@ export const Overview: React.FC = ({ agentUrlLoading={agentUrlLoading} agentUrlData={agentUrlData} isWorkflowRuntimeRunning={isWorkflowRuntimeRunning} + hasCallbackInfo={!!workflowProperties.callbackInfo} onRefresh={onLoadRuns} onRunTrigger={onRunTrigger} /> diff --git a/libs/designer-ui/src/lib/overview/overviewcommandbar.tsx b/libs/designer-ui/src/lib/overview/overviewcommandbar.tsx index 71e36ac1c32..2a41da9f28a 100644 --- a/libs/designer-ui/src/lib/overview/overviewcommandbar.tsx +++ b/libs/designer-ui/src/lib/overview/overviewcommandbar.tsx @@ -13,6 +13,7 @@ export interface OverviewCommandBarProps { agentUrlLoading?: boolean; agentUrlData?: AgentURL; isWorkflowRuntimeRunning?: boolean; + hasCallbackInfo?: boolean; onRefresh(): void; onRunTrigger(): void; } @@ -24,6 +25,7 @@ export const OverviewCommandBar: React.FC = ({ agentUrlLoading, agentUrlData, isWorkflowRuntimeRunning, + hasCallbackInfo, onRefresh, onRunTrigger, triggerName, @@ -59,7 +61,7 @@ export const OverviewCommandBar: React.FC = ({ icon: , title: Resources.OVERVIEW_RUN_TRIGGER, onClick: onRunTrigger, - disabled: !isWorkflowRuntimeRunning || !triggerName, + disabled: !isWorkflowRuntimeRunning || !triggerName || !hasCallbackInfo, }); } diff --git a/libs/designer-ui/src/lib/overview/overviewproperties.tsx b/libs/designer-ui/src/lib/overview/overviewproperties.tsx index 1ee716a501d..14068631887 100644 --- a/libs/designer-ui/src/lib/overview/overviewproperties.tsx +++ b/libs/designer-ui/src/lib/overview/overviewproperties.tsx @@ -12,6 +12,7 @@ export interface OverviewPropertiesProps { operationOptions?: string; statelessRunMode?: string; stateType: string; + kind?: string; triggerName?: string; definition?: LogicAppsV2.WorkflowDefinition; agentUrl?: string; diff --git a/libs/designer-v2/src/lib/core/queries/runs.ts b/libs/designer-v2/src/lib/core/queries/runs.ts index 835883b6dba..85f6e927ccf 100644 --- a/libs/designer-v2/src/lib/core/queries/runs.ts +++ b/libs/designer-v2/src/lib/core/queries/runs.ts @@ -46,6 +46,7 @@ export const useRunsInfiniteQuery = (enabled = false) => { { enabled, ...queryOpts, + refetchInterval: enabled ? constants.RUN_POLLING_INTERVAL_IN_MS : false, getNextPageParam: (lastPage) => lastPage.nextLink ?? undefined, // Seed flattened runs and per-run cache entries so `useRun` can read // them without an extra fetch when available. @@ -105,6 +106,12 @@ export const useRun = (runId: string | undefined, enabled = true) => { ...old, [fetchedRun.id]: fetchedRun, })); + + // When a run reaches terminal status, refresh the runs list + if (fetchedRun.properties.status !== constants.FLOW_STATUS.RUNNING) { + queryClient.invalidateQueries([runsQueriesKeys.runs]); + } + return fetchedRun; }, { diff --git a/libs/designer-v2/src/lib/ui/panel/runHistoryPanel/runHistoryPanelInstance.tsx b/libs/designer-v2/src/lib/ui/panel/runHistoryPanel/runHistoryPanelInstance.tsx index ca9a7747675..5a31605c559 100644 --- a/libs/designer-v2/src/lib/ui/panel/runHistoryPanel/runHistoryPanelInstance.tsx +++ b/libs/designer-v2/src/lib/ui/panel/runHistoryPanel/runHistoryPanelInstance.tsx @@ -26,12 +26,13 @@ type FilterTypes = 'runId' | 'workflowVersion' | 'status'; interface RunHistoryPanelProps { onRefresh?: () => void; + onRunSelected?: (runId: string) => void; } -export const RunHistoryPanelInstance = (_props: RunHistoryPanelProps) => { +export const RunHistoryPanelInstance = (props: RunHistoryPanelProps) => { const intl = useIntl(); const styles = useRunHistoryPanelStyles(); - const runsQuery = useRunsInfiniteQuery(); + const runsQuery = useRunsInfiniteQuery(true); const runs = useAllRuns(); useEffect(() => { @@ -199,7 +200,7 @@ export const RunHistoryPanelInstance = (_props: RunHistoryPanelProps) => { key={run.id} runId={run.id} isSelected={false} - onRunSelected={() => {}} + onRunSelected={props.onRunSelected ? () => props.onRunSelected?.(run.id) : () => {}} addFilterCallback={addFilterCallback} /> ))} diff --git a/libs/designer/src/lib/ui/panel/connectionsPanel/selectConnection/connectionTable.tsx b/libs/designer/src/lib/ui/panel/connectionsPanel/selectConnection/connectionTable.tsx index 2e6993b7b11..b29a6a9b17d 100644 --- a/libs/designer/src/lib/ui/panel/connectionsPanel/selectConnection/connectionTable.tsx +++ b/libs/designer/src/lib/ui/panel/connectionsPanel/selectConnection/connectionTable.tsx @@ -23,6 +23,7 @@ import { getLabelForConnection, getSubLabelForConnection, } from './selectConnection.helpers'; +import { useConnectionRefs } from '../../../../core/state/connection/connectionSelector'; export interface ConnectionTableProps { connections: Connection[]; @@ -55,9 +56,21 @@ export const ConnectionTable = (props: ConnectionTableProps): JSX.Element => { const intl = useIntl(); const initiallySelectedConnectionId = useRef(currentConnectionId); + const connectionReferences = useConnectionRefs(); + + // Check if the currentConnectionId is actually configured in connectionReferences + const isCurrentConnectionConfigured = useMemo(() => { + if (!currentConnectionId) { + return false; + } + return Object.values(connectionReferences).some((ref: any) => { + const refConnectionId = ref?.connection?.id; + return refConnectionId && getIdLeaf(refConnectionId) === currentConnectionId; + }); + }, [currentConnectionId, connectionReferences]); const isSelectedConnection = (connection: ConnectionWithFlattenedProperties): boolean => { - return cleanResourceId(connection.id) === cleanResourceId(initiallySelectedConnectionId.current); + return isCurrentConnectionConfigured && cleanResourceId(connection.id) === cleanResourceId(initiallySelectedConnectionId.current); }; // We need to flatten the connection to allow the detail list access to nested props @@ -86,10 +99,10 @@ export const ConnectionTable = (props: ConnectionTableProps): JSX.Element => { message: 'Connection was selected.', }); - if (areIdLeavesEqual(connection.id, currentConnectionId)) { - cancelSelectionCallback?.(); // User clicked the existing connection, keep selection the same and return + if (areIdLeavesEqual(connection.id, currentConnectionId) && isCurrentConnectionConfigured) { + cancelSelectionCallback?.(); // User clicked the existing connection that is already configured } else { - saveSelectionCallback(connection); // User clicked a different connection, save selection and return + saveSelectionCallback(connection); // User clicked a different connection or unconfigured connection } }, [cancelSelectionCallback, currentConnectionId, saveSelectionCallback] diff --git a/libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/version.spec.ts b/libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/version.spec.ts index 23f6a27516a..3f7c87423ba 100644 --- a/libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/version.spec.ts +++ b/libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/version.spec.ts @@ -76,6 +76,13 @@ describe('version helpers', () => { expect(isVersionSupported('1.0.0', '999.999.999')).toBe(false); }); + it('should handle 4-part versions by ignoring the 4th part', () => { + expect(isVersionSupported('1.160.0.18', '1.160.0.0')).toBe(true); // 4th part ignored, compares as 1.160.1 vs 1.160.1 + expect(isVersionSupported('1.160.0.18', '1.160.0')).toBe(true); // 1.160.1 > 1.160.0 + expect(isVersionSupported('1.160.0.99', '1.160.1')).toBe(false); // 1.160.0 < 1.160.1 + expect(isVersionSupported('2.0.0.1', '1.114.22')).toBe(true); // 2.0.0 > 1.114.22 + }); + it('should handle versions with >3 parts by ignoring extra parts', () => { expect(isVersionSupported('2.0.1.0', '2.0.1')).toBe(true); expect(isVersionSupported('1.0.0.2', '2.0.1')).toBe(false); diff --git a/libs/logic-apps-shared/src/utils/src/lib/helpers/version.ts b/libs/logic-apps-shared/src/utils/src/lib/helpers/version.ts index e468362f35a..5c02b6bb9ff 100644 --- a/libs/logic-apps-shared/src/utils/src/lib/helpers/version.ts +++ b/libs/logic-apps-shared/src/utils/src/lib/helpers/version.ts @@ -12,7 +12,7 @@ export const BundleVersionRequirements = { /** * Parses a semantic version string into its components. - * @arg {string} version - The version string to parse (e.g., "1.114.23"). + * @arg {string} version - The version string to parse (e.g., "1.114.23" or "1.160.0.18"). * @return {[number, number, number]} - A tuple of [major, minor, patch] version numbers. * @throws {ArgumentException} If the version string is invalid or contains non-numeric parts. */ @@ -22,11 +22,14 @@ const parseVersion = (version: string): [number, number, number] => { } const parts = version.split('.'); - if (parts.length < 3) { - throw new ArgumentException(`Invalid version format: "${version}". Expected format: "major.minor.patch"`); + // Support both 3-part (1.2.3) and 4-part (1.2.3.4) versions + // For 4-part versions, ignore the 4th part for comparison purposes + if (parts.length !== 3 && parts.length !== 4) { + throw new ArgumentException(`Invalid version format: "${version}". Expected format: "major.minor.patch" or "major.minor.patch.build"`); } - const [major, minor, patch] = parts.map(Number); + // Only use first 3 parts for semantic versioning comparison + const [major, minor, patch] = parts.slice(0, 3).map(Number); if ([major, minor, patch].some(Number.isNaN) || [major, minor, patch].some((v) => v < 0)) { throw new ArgumentException(`Invalid version format: "${version}". All parts must be non-negative numbers`); diff --git a/libs/vscode-extension/src/lib/models/project.ts b/libs/vscode-extension/src/lib/models/project.ts index 9a212b53c65..1ea49e0019b 100644 --- a/libs/vscode-extension/src/lib/models/project.ts +++ b/libs/vscode-extension/src/lib/models/project.ts @@ -13,7 +13,6 @@ export const ProjectName = { designer: 'designer', dataMapper: 'dataMapper', unitTest: 'unitTest', - runHistory: 'runHistory', languageServer: 'languageServer', createWorkspace: 'createWorkspace', createWorkspaceFromPackage: 'createWorkspaceFromPackage', @@ -100,11 +99,10 @@ export interface IWebviewProjectContext extends IActionContext { workspaceProjectPath: ITargetDirectory; workspaceName: string; logicAppName: string; - logicAppType: string; - projectType: string; - targetFramework: string; + logicAppType: ProjectType; + targetFramework?: TargetFramework; workflowName: string; - workflowType: WorkflowType; + workflowType?: WorkflowType; functionFolderName?: string; functionName?: string; functionNamespace?: string; @@ -125,7 +123,7 @@ export const ProjectType = { logicApp: 'logicApp', customCode: 'customCode', rulesEngine: 'rulesEngine', - agentCodeful: 'agentCodeful', + codeful: 'codeful', } as const; export type ProjectType = (typeof ProjectType)[keyof typeof ProjectType]; @@ -141,7 +139,6 @@ export const RouteName = { workflows_selection: 'workflows-selection', validation: 'validation', overview: 'overview', - runHistory: 'runHistory', summary: 'summary', status: 'status', review: 'review',