From ed4d9a512f8678248e1e3bd95e9ebeb92fa9dfa0 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 1 Jul 2026 17:47:02 -0700 Subject: [PATCH] fix(vscode): Fix runtime dependency resolution, debug shutdown, and multi-root workflow creation (#9330) Byte-for-byte cherry-pick of #9330 (squash-merge f3634ee60d135f21276a11890bcf5a78ea5959be) into hotfix/v5.970. Applies cleanly (0 conflicts, 48 files) on the current tip (already contains #9164). No hacks. Co-authored-by: Brian Lam Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Localize/lang/strings.json | 4 + .../__test__/commandWebviewWrappers.test.ts | 40 ++- .../commands/__test__/pickFuncProcess.test.ts | 87 +++++- .../buildCustomCodeFunctionsProject.ts | 3 - .../CreateLogicAppVSCodeContents.ts | 4 +- .../CreateLogicAppVSCodeContents.test.ts | 1 + .../initCustomCodeProjectStep.ts | 4 +- .../initCustomCodeScriptProjectStep.ts | 4 +- .../commands/createProject/createProject.ts | 19 ++ .../__test__/createLogicAppWorkflow.test.ts | 14 +- .../__test__/createWorkflow.test.ts | 247 +++++++++++++++++ .../createWorkflow/createLogicAppWorkflow.ts | 31 +-- .../commands/createWorkflow/createWorkflow.ts | 103 ++++++- .../__test__/validateDotNetIsLatest.test.ts | 64 +++++ .../commands/dotnet/validateDotNetIsLatest.ts | 2 +- .../validateFuncCoreToolsInstalled.test.ts | 39 ++- .../validateFuncCoreToolsInstalled.ts | 10 +- .../validateFuncCoreToolsIsLatest.ts | 4 +- .../GenerateADODeploymentScriptsStep.ts | 17 +- .../initDotnetProjectStep.ts | 4 +- .../initProjectForVSCode/initProjectStep.ts | 4 +- .../initScriptProjectStep.ts | 4 +- .../__test__/validateNodeJsIsLatest.test.ts | 252 +++++++++++++++-- .../commands/nodeJs/validateNodeJsIsLatest.ts | 194 +++++++++++-- .../src/app/commands/pickFuncProcess.ts | 25 +- .../src/app/commands/registerCommands.ts | 30 +- .../shared/workspaceWebviewCommandHandler.ts | 40 +-- .../src/app/utils/__test__/binaries.test.ts | 262 +++++++++++++++++- .../utils/__test__/devContainerUtils.test.ts | 61 +++- .../__test__/languageServerProtocol.test.ts | 106 ++++++- .../app/utils/appSettings/connectionKeys.ts | 4 +- .../src/app/utils/binaries.ts | 172 ++++++++---- .../utils/codeless/__test__/common.test.ts | 9 + .../__test__/startDesignTimeApi.test.ts | 57 +++- .../src/app/utils/codeless/common.ts | 7 + .../app/utils/codeless/startDesignTimeApi.ts | 56 +++- .../src/app/utils/devContainerUtils.ts | 30 ++ .../__test__/funcVersion.test.ts | 135 ++++++++- .../app/utils/funcCoreTools/funcHostTask.ts | 2 +- .../app/utils/funcCoreTools/funcVersion.ts | 115 +++++++- .../src/app/utils/languageServerProtocol.ts | 48 +++- .../nodeJs/__test__/nodeJsVersion.test.ts | 67 ++++- .../src/app/utils/nodeJs/nodeJsVersion.ts | 10 +- .../verifyVSCodeConfigOnActivate.test.ts | 113 ++++++++ .../src/app/utils/vsCodeConfig/tasks.ts | 2 +- .../verifyVSCodeConfigOnActivate.ts | 2 +- .../src/app/utils/workspace.ts | 9 +- apps/vs-code-designer/src/main.ts | 2 +- 48 files changed, 2232 insertions(+), 287 deletions(-) create mode 100644 apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createWorkflow.test.ts create mode 100644 apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts create mode 100644 apps/vs-code-designer/src/app/utils/vsCodeConfig/__test__/verifyVSCodeConfigOnActivate.test.ts diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index 69389cb5802..340af631eb8 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -160,6 +160,7 @@ "19gdw8": "Learn more", "1A1P5b": "Comment", "1AFYij": "This template contains one workflow, therefore it is classified as a Workflow.", + "1Ch1Od": "This name is reserved and cannot be used as a workflow name.", "1D047X": "Required. The value to convert to XML.", "1Fn5n+": "Required. The URI encoded string.", "1GWzEL": "No results found for the specified filters", @@ -2123,6 +2124,7 @@ "_19gdw8.comment": "Label to learn more about creating a new connection", "_1A1P5b.comment": "Comment Label", "_1AFYij.comment": "Content for the toaster for adding a single workflow.", + "_1Ch1Od.comment": "Error message when workflow name matches a reserved project folder name", "_1D047X.comment": "Required string parameter to be converted using xml function", "_1Fn5n+.comment": "Required URI encoded string parameter to be converted using uriComponentToBinary function", "_1GWzEL.comment": "Text displayed when no results are found in the browse grid", @@ -3940,6 +3942,7 @@ "_aGxYMY.comment": "Label to clear editor", "_aGyVJT.comment": "Required number parameter to get number of objects to remove for skip function", "_aI9W5L.comment": "Accessibility text to inform user this template does not contain connectors", + "_aIk72V.comment": "Warning message when workflow name collides with an existing workflow in the project", "_aJg1gl.comment": "error message for maximum invalid retry interval", "_aKf/r+.comment": "Run identifier not found error message", "_aP1wk9.comment": "Label for the description of a custom 'indexOf' function", @@ -5317,6 +5320,7 @@ "aGxYMY": "Clear editor", "aGyVJT": "Required. The number of objects to remove from the front of Collection. Must be a positive integer.", "aI9W5L": "This template does not have connectors", + "aIk72V": "A workflow with this name already exists in the selected project.", "aJg1gl": "Retry policy maximum interval is invalid, must match ISO 8601 duration format", "aKf/r+": "Specified run identifier not found", "aP1wk9": "Returns the first index of a value within a string (case-insensitive, invariant culture)", diff --git a/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts b/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts index 30f68811392..fd01cc35899 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts @@ -119,6 +119,11 @@ describe('workspace webview command wrappers', () => { const logicAppsWithoutCustomCode = ['LogicApp']; (vscode.workspace as any).workspaceFile = workspaceFile; (vscode.workspace.fs.readFile as Mock).mockResolvedValue(Buffer.from(JSON.stringify(workspaceFileJson))); + (vscode.workspace.fs.readDirectory as Mock).mockResolvedValue([ + ['LogicApp', 'directory'], + ['CSharpProject', 'directory'], + ['MyWorkspace.code-workspace', 'file'], + ]); (getLogicAppWithoutCustomCode as Mock).mockResolvedValue(logicAppsWithoutCustomCode); await createNewProject(context); @@ -133,6 +138,7 @@ describe('workspace webview command wrappers', () => { expect(config.extraInitializeData).toEqual({ workspaceFileJson, logicAppsWithoutCustomCode, + existingFolders: ['LogicApp', 'CSharpProject'], }); expect(config.dialogOptions?.workspace).toMatchObject({ canSelectMany: false, @@ -147,6 +153,29 @@ describe('workspace webview command wrappers', () => { expect(createLogicAppProject).toHaveBeenCalledWith(context, data, path.dirname(workspaceFile.fsPath)); }); + it('getExistingFoldersOnDisk filters out non-directory entries using FileType mock', async () => { + const workspaceFile = { fsPath: 'D:\\workspace\\MyWorkspace.code-workspace' }; + const workspaceFileJson = { folders: [{ path: './LogicApp' }] }; + (vscode.workspace as any).workspaceFile = workspaceFile; + (vscode.workspace.fs.readFile as Mock).mockResolvedValue(Buffer.from(JSON.stringify(workspaceFileJson))); + (vscode.workspace.fs.readDirectory as Mock).mockResolvedValue([ + ['LogicApp', 'directory'], + ['notes.txt', 'file'], + ['SomeLink', 'symlink'], + ['AnotherProject', 'directory'], + ['UnknownEntry', ''], + ]); + + await createNewProject(context); + + const config = getLastWebviewConfig(); + expect(config.extraInitializeData).toEqual({ + workspaceFileJson, + logicAppsWithoutCustomCode: [], + existingFolders: ['LogicApp', 'AnotherProject'], + }); + }); + it('createNewProject falls back to convertToWorkspace when no workspace file is open', async () => { await createNewProject(context); @@ -156,13 +185,17 @@ describe('workspace webview command wrappers', () => { it('createWorkflow passes codeful metadata and wires createLogicAppWorkflow', async () => { const projectRoot = path.join(workspaceRoot, 'CodefulLogicApp'); - (getWorkspaceRoot as Mock).mockResolvedValue(workspaceRoot); + const folder = { + name: 'workspace', + uri: { fsPath: workspaceRoot }, + index: 0, + } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folder]; (tryGetLogicAppProjectRoot as Mock).mockResolvedValue(projectRoot); (isCodefulProject as Mock).mockResolvedValue(true); await createWorkflow(context); - expect(getWorkspaceRoot).toHaveBeenCalledWith(context); expect(tryGetLogicAppProjectRoot).toHaveBeenCalledWith(context, workspaceRoot, true); expect(isCodefulProject).toHaveBeenCalledWith(projectRoot); @@ -175,10 +208,11 @@ describe('workspace webview command wrappers', () => { extraInitializeData: { logicAppType: ProjectType.codeful, logicAppName: 'CodefulLogicApp', + availableProjects: [{ name: 'CodefulLogicApp', path: projectRoot, isCodeful: true }], }, }); - const data = { workflowName: 'ProcessOrder' }; + const data = { workflowName: 'ProcessOrder', logicAppName: 'CodefulLogicApp' }; await config.createHandler(context, data); expect(createLogicAppWorkflow).toHaveBeenCalledWith(context, data, projectRoot); 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 index ccf7f403195..ad250ff8926 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.test.ts @@ -47,6 +47,10 @@ vi.mock('../../utils/telemetry', () => ({ runWithDurationTelemetry: vi.fn((_context: unknown, _eventName: string, callback: () => Promise) => callback()), })); +vi.mock('../../utils/delay', () => ({ + delay: vi.fn(), +})); + vi.mock('../../utils/verifyIsProject', () => ({ tryGetLogicAppProjectRoot: vi.fn(), })); @@ -55,6 +59,10 @@ vi.mock('../../utils/vsCodeConfig/settings', () => ({ getWorkspaceSetting: vi.fn(), })); +vi.mock('../../utils/codeful', () => ({ + isCodefulProject: vi.fn(), +})); + vi.mock('../buildCustomCodeFunctionsProject', () => ({ tryBuildCustomCodeFunctionsProject: vi.fn(), })); @@ -68,6 +76,8 @@ import { preDebugValidate } from '../../debug/validatePreDebug'; import { getProjFiles } from '../../utils/dotnet/dotnet'; import { getFuncPortFromTaskOrProject, runningFuncTaskMap } from '../../utils/funcCoreTools/funcHostTask'; import { executeIfNotActive } from '../../utils/taskUtils'; +import { delay } from '../../utils/delay'; +import { isCodefulProject } from '../../utils/codeful'; import { tryGetLogicAppProjectRoot } from '../../utils/verifyIsProject'; import { getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; import { tryBuildCustomCodeFunctionsProject } from '../buildCustomCodeFunctionsProject'; @@ -111,8 +121,10 @@ describe('pickFuncProcessInternal', () => { context.errorHandling = {}; runningFuncTaskMap.clear(); (preDebugValidate as any).mockResolvedValue(true); + (isCodefulProject as any).mockResolvedValue(true); (tryBuildCustomCodeFunctionsProject as any).mockResolvedValue(true); (publishCodefulProject as any).mockResolvedValue(undefined); + (delay as any).mockResolvedValue(undefined); (getProjFiles as any).mockResolvedValue(['CodefulLogicApp.csproj']); (getWorkspaceSetting as any).mockReturnValue(1); (getFuncPortFromTaskOrProject as any).mockResolvedValue('7071'); @@ -134,7 +146,7 @@ describe('pickFuncProcessInternal', () => { restoreProcessPlatform(); }); - it('passes the build-populated codeful skip option from the debug path before starting the func task', async () => { + it('codeful project skips custom code build', async () => { (vscode.tasks.fetchTasks as any).mockResolvedValue([]); await expect( @@ -146,11 +158,82 @@ describe('pickFuncProcessInternal', () => { ) ).rejects.toThrow('Failed to find "func: host start" task.'); - expect(tryBuildCustomCodeFunctionsProject).toHaveBeenCalledWith(context, workspaceFolder.uri); + expect(isCodefulProject).toHaveBeenCalledWith(projectPath); + expect(tryBuildCustomCodeFunctionsProject).not.toHaveBeenCalled(); expect(publishCodefulProject).toHaveBeenCalledWith(context, workspaceFolder.uri, { skipIfBuildPopulatesCodeful: true }); expect(executeIfNotActive).not.toHaveBeenCalled(); }); + it('custom code project skips codeful publish', async () => { + (isCodefulProject as any).mockResolvedValue(false); + (vscode.tasks.fetchTasks as any).mockResolvedValue([]); + + await expect( + pickFuncProcessModule.pickFuncProcessInternal( + context, + { type: 'logicapp', isCodeless: false, preLaunchTask: 'func: host start' }, + workspaceFolder, + projectPath + ) + ).rejects.toThrow('Failed to find "func: host start" task.'); + + expect(isCodefulProject).toHaveBeenCalledWith(projectPath); + expect(tryBuildCustomCodeFunctionsProject).toHaveBeenCalledWith(context, workspaceFolder.uri); + expect(publishCodefulProject).not.toHaveBeenCalled(); + expect(executeIfNotActive).not.toHaveBeenCalled(); + }); + + it('waits for a previous func task to stop before codeful publish', async () => { + const events: string[] = []; + runningFuncTaskMap.set(workspaceFolder, { startTime: Date.now(), processId: 5678 }); + (delay as any).mockImplementationOnce(async () => { + expect(publishCodefulProject).not.toHaveBeenCalled(); + events.push('waited-for-stop'); + runningFuncTaskMap.clear(); + }); + (publishCodefulProject as any).mockImplementation(async () => { + events.push('publish-codeful'); + }); + (vscode.tasks.fetchTasks as any).mockResolvedValue([]); + + await expect( + pickFuncProcessModule.pickFuncProcessInternal( + context, + { type: 'logicapp', isCodeless: false, preLaunchTask: 'func: host start' }, + workspaceFolder, + projectPath + ) + ).rejects.toThrow('Failed to find "func: host start" task.'); + + expect(events).toEqual(['waited-for-stop', 'publish-codeful']); + }); + + it('waits for a previous func task to stop before custom code build', async () => { + const events: string[] = []; + (isCodefulProject as any).mockResolvedValue(false); + runningFuncTaskMap.set(workspaceFolder, { startTime: Date.now(), processId: 5678 }); + (delay as any).mockImplementationOnce(async () => { + expect(tryBuildCustomCodeFunctionsProject).not.toHaveBeenCalled(); + events.push('waited-for-stop'); + runningFuncTaskMap.clear(); + }); + (tryBuildCustomCodeFunctionsProject as any).mockImplementation(async () => { + events.push('build-custom-code'); + }); + (vscode.tasks.fetchTasks as any).mockResolvedValue([]); + + await expect( + pickFuncProcessModule.pickFuncProcessInternal( + context, + { type: 'logicapp', isCodeless: false, preLaunchTask: 'func: host start' }, + workspaceFolder, + projectPath + ) + ).rejects.toThrow('Failed to find "func: host start" task.'); + + expect(events).toEqual(['waited-for-stop', 'build-custom-code']); + }); + it('starts the func task after publishing and returns the tracked workflow process id', async () => { (getProjFiles as any).mockImplementation(async () => { runningFuncTaskMap.set(workspaceFolder, { startTime: Date.now(), processId: 1234 }); diff --git a/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts b/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts index 6fb40068337..6190dae0e2c 100644 --- a/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts +++ b/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts @@ -13,7 +13,6 @@ import { } from '../utils/customCodeUtils'; import * as vscode from 'vscode'; import { isNullOrUndefined } from '@microsoft/logic-apps-shared'; -import { invalidateCodefulSdkCacheIfNeeded } from '../utils/codeful'; /** * Builds a custom code functions project if exists. @@ -92,8 +91,6 @@ export async function buildWorkspaceCustomCodeFunctionsProjects(context: IAction } async function buildCustomCodeProject(functionsProjectPath: string): Promise { - await invalidateCodefulSdkCacheIfNeeded(functionsProjectPath); - const tasks: vscode.Task[] = await vscode.tasks.fetchTasks(); const buildTask = tasks.find((task) => { const currTaskPath = (task.scope as vscode.WorkspaceFolder)?.uri.fsPath; 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 c71667ec0b8..060bf2309a6 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 @@ -30,7 +30,7 @@ import { localize } from '../../../../localize'; import { ext } from '../../../../extensionVariables'; import { getCustomCodeRuntime } from '../../../utils/debug'; import { isDebugConfigEqual } from '../../../utils/vsCodeConfig/launch'; -import { binariesExist } from '../../../utils/binaries'; +import { binariesExistSync } from '../../../utils/binaries'; import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject'; import { type CustomCodeFunctionsProjectMetadata, @@ -94,7 +94,7 @@ export async function writeExtensionsJson(webviewProjectContext: IWebviewProject const getCodefulTasks = (targetFramework: string) => { const commonDotnetArgs: string[] = ['/property:GenerateFullPaths=true', '/consoleloggerparameters:NoSummary']; const releaseDotnetArgs: string[] = ['--configuration', 'Release']; - const funcBinariesExist = binariesExist(funcDependencyName); + const funcBinariesExist = binariesExistSync(funcDependencyName); const debugSubpath = path.posix.join('bin', 'Debug', targetFramework); const binariesOptions = funcBinariesExist ? getFuncHostTaskEnv({ cwd: debugSubpath }) : {}; return [ 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 ad11f07f349..c0119ab8846 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,7 @@ vi.mock('../../../../utils/fs', () => ({ })); vi.mock('../../../../utils/binaries', () => ({ binariesExist: vi.fn().mockReturnValue(false), + binariesExistSync: vi.fn().mockReturnValue(false), })); describe('CreateLogicAppVSCodeContents', () => { diff --git a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeProjectStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeProjectStep.ts index ad26cf9271c..76653ddd51d 100644 --- a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeProjectStep.ts +++ b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeProjectStep.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 { binariesExist } from '../../../utils/binaries'; +import { binariesExistSync } from '../../../utils/binaries'; import { getFuncHostTaskEnv } from '../../../utils/codeless/funcHostTaskEnv'; import { extensionCommand, func, funcDependencyName, funcWatchProblemMatcher, hostStartCommand } from '../../../../constants'; import { InitCustomCodeScriptProjectStep } from './initCustomCodeScriptProjectStep'; @@ -11,7 +11,7 @@ import type { TaskDefinition } from 'vscode'; export class InitCustomCodeProjectStep extends InitCustomCodeScriptProjectStep { protected getTasks(): TaskDefinition[] { - const funcBinariesExist = binariesExist(funcDependencyName); + const funcBinariesExist = binariesExistSync(funcDependencyName); const binariesOptions = funcBinariesExist ? getFuncHostTaskEnv() : {}; return [ { diff --git a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeScriptProjectStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeScriptProjectStep.ts index 2392c026fb1..883c92c8c04 100644 --- a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeScriptProjectStep.ts +++ b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/initCustomCodeScriptProjectStep.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 { binariesExist } from '../../../utils/binaries'; +import { binariesExistSync } from '../../../utils/binaries'; import { getFuncHostTaskEnv } from '../../../utils/codeless/funcHostTaskEnv'; import { extInstallTaskName, func, funcDependencyName, funcWatchProblemMatcher, hostStartCommand } from '../../../../constants'; import { getLocalFuncCoreToolsVersion } from '../../../utils/funcCoreTools/funcVersion'; @@ -49,7 +49,7 @@ export class InitCustomCodeScriptProjectStep extends InitCustomCodeProjectStepBa } protected getTasks(): TaskDefinition[] { - const funcBinariesExist = binariesExist(funcDependencyName); + const funcBinariesExist = binariesExistSync(funcDependencyName); const binariesOptions = funcBinariesExist ? getFuncHostTaskEnv() : {}; return [ { diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProject.ts b/apps/vs-code-designer/src/app/commands/createProject/createProject.ts index 5435ed5447a..c688b37baa8 100644 --- a/apps/vs-code-designer/src/app/commands/createProject/createProject.ts +++ b/apps/vs-code-designer/src/app/commands/createProject/createProject.ts @@ -14,6 +14,20 @@ import path from 'path'; import { createLogicAppProject } from '../createNewCodeProject/CodeProjectBase/CreateLogicAppProjects'; import { getLogicAppWithoutCustomCode } from '../../utils/workspace'; +/** + * Enumerates all directory names in the workspace root folder. + * This captures folders that may not be in the .code-workspace file (e.g., C# custom code projects). + */ +async function getExistingFoldersOnDisk(workspaceRootFolder: string): Promise { + try { + const rootUri = vscode.Uri.file(workspaceRootFolder); + const entries = await vscode.workspace.fs.readDirectory(rootUri); + return entries.filter(([, type]) => type === vscode.FileType.Directory).map(([name]) => name); + } catch { + return []; + } +} + export async function createNewProject(context: IActionContext): Promise { // Determine if in workspace, if not in workspace but there is a logic app project found, // prompt to see if they want to move the project over to a logic app workspace @@ -33,6 +47,10 @@ export async function createNewProject(context: IActionContext): Promise { const workspaceFileJson = JSON.parse(workspaceFileContent.toString()); const logicAppsWithoutCustomCode = await getLogicAppWithoutCustomCode(context); + // Enumerate all existing directories on disk (includes C# project folders, etc.) + const existingFolders = await getExistingFoldersOnDisk(workspaceRootFolder); + ext.outputChannel.appendLog(`[createProject] workspaceRoot=${workspaceRootFolder}, existingFolders=${JSON.stringify(existingFolders)}`); + await createWorkspaceWebviewCommandHandler({ panelName: localize('createLogicAppProject', 'Create project'), panelGroupKey: ext.webViewKey.createLogicApp, @@ -52,6 +70,7 @@ export async function createNewProject(context: IActionContext): Promise { extraInitializeData: { workspaceFileJson, logicAppsWithoutCustomCode, + existingFolders, }, }); } diff --git a/apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createLogicAppWorkflow.test.ts b/apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createLogicAppWorkflow.test.ts index fd0fd9ebc86..cdcaa04437d 100644 --- a/apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createLogicAppWorkflow.test.ts +++ b/apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createLogicAppWorkflow.test.ts @@ -62,15 +62,19 @@ describe('createLogicAppWorkflow', () => { expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Finished creating workflow.'); }); - it('shows an error and skips creation when no workspace file is open', async () => { + it('creates a workflow in a folder-opened Logic App project when no workspace file is open', async () => { (vscode.workspace as any).workspaceFile = undefined; const options: any = { logicAppName: 'Orders', logicAppType: ProjectType.logicApp }; await createLogicAppWorkflow(context, options, logicAppFolderPath); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - 'Please open an existing logic app workspace before trying to add a new logic app project.' - ); - expect(createLogicAppAndWorkflow).not.toHaveBeenCalled(); + expect(options).toMatchObject({ + logicAppType: ProjectType.logicApp, + shouldCreateLogicAppProject: false, + }); + expect(options.workspaceFilePath).toBeUndefined(); + expect(createLogicAppAndWorkflow).toHaveBeenCalledWith(options, logicAppFolderPath, context); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Finished creating workflow.'); }); }); diff --git a/apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createWorkflow.test.ts b/apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createWorkflow.test.ts new file mode 100644 index 00000000000..38031545a7b --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createWorkflow/__test__/createWorkflow.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { ProjectType } from '@microsoft/vscode-extension-logic-apps'; + +vi.mock('../../../../localize', () => ({ + localize: (_key: string, defaultValue: string, ...args: unknown[]) => + defaultValue.replace(/{(\d+)}/g, (_match, index) => String(args[Number(index)] ?? '')), +})); + +vi.mock('../../shared/workspaceWebviewCommandHandler', () => ({ + createWorkspaceWebviewCommandHandler: vi.fn(), +})); + +vi.mock('../../../utils/codeful', () => ({ + isCodefulProject: vi.fn(), +})); + +vi.mock('../../../utils/verifyIsProject', () => ({ + tryGetLogicAppProjectRoot: vi.fn(), +})); + +vi.mock('../../../utils/codeless/common', () => ({ + getWorkflowsInLocalProject: vi.fn().mockResolvedValue({}), +})); + +vi.mock('../createLogicAppWorkflow', () => ({ + createLogicAppWorkflow: vi.fn(), +})); + +import { createWorkspaceWebviewCommandHandler } from '../../shared/workspaceWebviewCommandHandler'; +import { isCodefulProject } from '../../../utils/codeful'; +import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject'; +import { createLogicAppWorkflow } from '../createLogicAppWorkflow'; +import { createWorkflow } from '../createWorkflow'; + +describe('createWorkflow', () => { + const context = { + telemetry: { + properties: {}, + measurements: {}, + }, + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + (vscode.workspace as any).workspaceFolders = undefined; + (vscode.workspace.getWorkspaceFolder as any) = vi.fn(); + vi.mocked(isCodefulProject).mockResolvedValue(false); + }); + + describe('project collection and selection', () => { + it('collects all projects from workspace folders and sends to webview', async () => { + const folderA = { name: 'ProjectA', uri: { fsPath: 'D:\\workspace\\ProjectA' }, index: 0 } as vscode.WorkspaceFolder; + const folderB = { name: 'ProjectB', uri: { fsPath: 'D:\\workspace\\ProjectB' }, index: 1 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folderA, folderB]; + vi.mocked(tryGetLogicAppProjectRoot).mockImplementation(async (_ctx, folder) => { + if (folder === 'D:\\workspace\\ProjectA') { + return 'D:\\workspace\\ProjectA'; + } + if (folder === 'D:\\workspace\\ProjectB') { + return 'D:\\workspace\\ProjectB'; + } + return undefined; + }); + vi.mocked(isCodefulProject).mockImplementation(async (projectPath) => { + return projectPath === 'D:\\workspace\\ProjectB'; + }); + + await createWorkflow(context); + + expect(createWorkspaceWebviewCommandHandler).toHaveBeenCalledWith( + expect.objectContaining({ + extraInitializeData: expect.objectContaining({ + availableProjects: [ + { name: 'ProjectA', path: 'D:\\workspace\\ProjectA', isCodeful: false, existingWorkflows: [] }, + { name: 'ProjectB', path: 'D:\\workspace\\ProjectB', isCodeful: true, existingWorkflows: [] }, + ], + }), + }) + ); + }); + + it('auto-selects when only one project exists', async () => { + const folder = { name: 'OnlyProject', uri: { fsPath: 'D:\\workspace\\OnlyProject' }, index: 0 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folder]; + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue('D:\\workspace\\OnlyProject'); + vi.mocked(isCodefulProject).mockResolvedValue(true); + + await createWorkflow(context); + + expect(createWorkspaceWebviewCommandHandler).toHaveBeenCalledWith( + expect.objectContaining({ + extraInitializeData: expect.objectContaining({ + logicAppName: 'OnlyProject', + logicAppType: ProjectType.codeful, + }), + }) + ); + }); + + it('does not pre-select when multiple projects and no URI', async () => { + const folderA = { name: 'ProjectA', uri: { fsPath: 'D:\\workspace\\ProjectA' }, index: 0 } as vscode.WorkspaceFolder; + const folderB = { name: 'ProjectB', uri: { fsPath: 'D:\\workspace\\ProjectB' }, index: 1 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folderA, folderB]; + vi.mocked(tryGetLogicAppProjectRoot).mockImplementation(async (_ctx, folder) => { + if (folder === 'D:\\workspace\\ProjectA') { + return 'D:\\workspace\\ProjectA'; + } + if (folder === 'D:\\workspace\\ProjectB') { + return 'D:\\workspace\\ProjectB'; + } + return undefined; + }); + + await createWorkflow(context); + + expect(createWorkspaceWebviewCommandHandler).toHaveBeenCalledWith( + expect.objectContaining({ + extraInitializeData: expect.objectContaining({ + logicAppName: '', + logicAppType: '', + }), + }) + ); + }); + }); + + describe('URI-based project pre-selection', () => { + it('pre-selects project from right-click URI', async () => { + const folderA = { name: 'ProjectA', uri: { fsPath: 'D:\\workspace\\ProjectA' }, index: 0 } as vscode.WorkspaceFolder; + const folderB = { name: 'ControlFlow', uri: { fsPath: 'D:\\workspace\\ControlFlow' }, index: 1 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folderA, folderB]; + + const clickedUri = { fsPath: 'D:\\workspace\\ControlFlow' } as vscode.Uri; + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(folderB); + vi.mocked(tryGetLogicAppProjectRoot).mockImplementation(async (_ctx, folder) => { + if (folder === 'D:\\workspace\\ProjectA') { + return 'D:\\workspace\\ProjectA'; + } + if (folder === 'D:\\workspace\\ControlFlow') { + return 'D:\\workspace\\ControlFlow'; + } + return undefined; + }); + vi.mocked(isCodefulProject).mockResolvedValue(true); + + await createWorkflow(context, clickedUri); + + expect(createWorkspaceWebviewCommandHandler).toHaveBeenCalledWith( + expect.objectContaining({ + extraInitializeData: expect.objectContaining({ + logicAppName: 'ControlFlow', + logicAppType: ProjectType.codeful, + }), + }) + ); + }); + }); + + describe('create handler resolves project from webview data', () => { + it('uses logicAppName from webview to find project path', async () => { + const folder = { name: 'ProjectA', uri: { fsPath: 'D:\\workspace\\ProjectA' }, index: 0 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folder]; + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue('D:\\workspace\\ProjectA'); + + await createWorkflow(context); + + const webviewOptions = vi.mocked(createWorkspaceWebviewCommandHandler).mock.calls[0][0] as any; + const data = { workflowName: 'MyWorkflow', logicAppName: 'ProjectA' }; + await webviewOptions.createHandler(context, data); + + expect(createLogicAppWorkflow).toHaveBeenCalledWith(context, data, 'D:\\workspace\\ProjectA'); + }); + + it('resolves to first matching project when duplicate basenames exist in multi-root workspace', async () => { + const folderA = { name: 'FolderA', uri: { fsPath: 'D:\\workspace\\FolderA' }, index: 0 } as vscode.WorkspaceFolder; + const folderB = { name: 'FolderB', uri: { fsPath: 'D:\\workspace\\FolderB' }, index: 1 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folderA, folderB]; + vi.mocked(tryGetLogicAppProjectRoot).mockImplementation(async (_ctx, folder) => { + if (folder === 'D:\\workspace\\FolderA') { + return 'D:\\repoA\\SharedProject'; + } + if (folder === 'D:\\workspace\\FolderB') { + return 'D:\\repoB\\SharedProject'; + } + return undefined; + }); + + await createWorkflow(context); + + const webviewOptions = vi.mocked(createWorkspaceWebviewCommandHandler).mock.calls[0][0] as any; + const data = { workflowName: 'MyWorkflow', logicAppName: 'SharedProject' }; + await webviewOptions.createHandler(context, data); + + expect(createLogicAppWorkflow).toHaveBeenCalledWith(context, data, 'D:\\repoA\\SharedProject'); + }); + + it('throws when webview sends unrecognized project name', async () => { + const folder = { name: 'ProjectA', uri: { fsPath: 'D:\\workspace\\ProjectA' }, index: 0 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folder]; + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue('D:\\workspace\\ProjectA'); + + await createWorkflow(context); + + const webviewOptions = vi.mocked(createWorkspaceWebviewCommandHandler).mock.calls[0][0] as any; + const data = { workflowName: 'MyWorkflow', logicAppName: 'NonExistentProject' }; + + await expect(webviewOptions.createHandler(context, data)).rejects.toThrow( + 'No project selected. Please select a project and try again.' + ); + }); + }); + + describe('panel naming', () => { + it('uses generic panel name without project-specific suffix', async () => { + const folder = { name: 'MyProject', uri: { fsPath: 'D:\\workspace\\MyProject' }, index: 0 } as vscode.WorkspaceFolder; + (vscode.workspace as any).workspaceFolders = [folder]; + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue('D:\\workspace\\MyProject'); + + await createWorkflow(context); + + expect(createWorkspaceWebviewCommandHandler).toHaveBeenCalledWith( + expect.objectContaining({ + panelName: 'Create workflow', + }) + ); + }); + }); + + describe('error cases', () => { + it('throws when no projects found in workspace', async () => { + (vscode.workspace as any).workspaceFolders = [ + { name: 'Empty', uri: { fsPath: 'D:\\workspace\\Empty' }, index: 0 } as vscode.WorkspaceFolder, + ]; + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue(undefined); + + await expect(createWorkflow(context)).rejects.toThrow('No Logic App project found in the current workspace.'); + expect(createWorkspaceWebviewCommandHandler).not.toHaveBeenCalled(); + }); + + it('throws when no workspace folders exist', async () => { + (vscode.workspace as any).workspaceFolders = undefined; + + await expect(createWorkflow(context)).rejects.toThrow('No Logic App project found in the current workspace.'); + }); + }); +}); 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 693b994a198..813326a8384 100644 --- a/apps/vs-code-designer/src/app/commands/createWorkflow/createLogicAppWorkflow.ts +++ b/apps/vs-code-designer/src/app/commands/createWorkflow/createLogicAppWorkflow.ts @@ -19,27 +19,18 @@ export async function createLogicAppWorkflow(context: IActionContext, options: a } } - // Check if we're in a workspace and get the workspace folder if (vscode.workspace.workspaceFile) { - // Get the directory containing the .code-workspace file - const workspaceFilePath = vscode.workspace.workspaceFile.fsPath; - webviewProjectContext.workspaceFilePath = workspaceFilePath; - webviewProjectContext.shouldCreateLogicAppProject = false; + webviewProjectContext.workspaceFilePath = vscode.workspace.workspaceFile.fsPath; + } + webviewProjectContext.shouldCreateLogicAppProject = false; - const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; - mySubContext.logicAppName = options.logicAppName; - mySubContext.projectPath = logicAppFolderPath; - mySubContext.projectType = webviewProjectContext.logicAppType; - mySubContext.functionFolderName = options.functionFolderName; - mySubContext.targetFramework = options.targetFramework; + const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; + mySubContext.logicAppName = options.logicAppName; + mySubContext.projectPath = logicAppFolderPath; + mySubContext.projectType = webviewProjectContext.logicAppType; + mySubContext.functionFolderName = options.functionFolderName; + mySubContext.targetFramework = options.targetFramework; - await createLogicAppAndWorkflow(webviewProjectContext, logicAppFolderPath, context); - vscode.window.showInformationMessage(localize('finishedCreatingWorkflow', 'Finished creating workflow.')); - } else { - // Fall back to the newly created workspace folder if not in a workspace - vscode.window.showErrorMessage( - localize('notInWorkspace', 'Please open an existing logic app workspace before trying to add a new logic app project.') - ); - return; - } + await createLogicAppAndWorkflow(webviewProjectContext, logicAppFolderPath, context); + vscode.window.showInformationMessage(localize('finishedCreatingWorkflow', 'Finished creating workflow.')); } 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 c8313ef68b0..e0a8fdbf595 100644 --- a/apps/vs-code-designer/src/app/commands/createWorkflow/createWorkflow.ts +++ b/apps/vs-code-designer/src/app/commands/createWorkflow/createWorkflow.ts @@ -7,31 +7,114 @@ import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { ExtensionCommand, ProjectName, ProjectType } from '@microsoft/vscode-extension-logic-apps'; import { createWorkspaceWebviewCommandHandler } from '../shared/workspaceWebviewCommandHandler'; import { localize } from '../../../localize'; +import * as vscode from 'vscode'; import { createLogicAppWorkflow } from './createLogicAppWorkflow'; -import { getWorkspaceRoot } from '../../utils/workspace'; import { isCodefulProject } from '../../utils/codeful'; import { tryGetLogicAppProjectRoot } from '../../utils/verifyIsProject'; +import { getWorkflowsInLocalProject } from '../../utils/codeless/common'; 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); +interface AvailableProject { + name: string; + path: string; + isCodeful: boolean; + existingWorkflows: string[]; +} - const logicAppType = isCodeful ? ProjectType.codeful : ''; +/** + * Collects all Logic App projects across workspace folders. + */ +async function collectAvailableProjects(context: IActionContext): Promise { + const projects: AvailableProject[] = []; + if (!vscode.workspace.workspaceFolders) { + return projects; + } + + for (const folder of vscode.workspace.workspaceFolders) { + const projectRoot = await tryGetLogicAppProjectRoot(context, folder.uri.fsPath, true); + if (projectRoot) { + const isCodeful = await isCodefulProject(projectRoot); + const workflows = await getWorkflowsInLocalProject(projectRoot); + projects.push({ + name: path.basename(projectRoot.replace(/\\/g, '/')), + path: projectRoot, + isCodeful, + existingWorkflows: Object.keys(workflows || {}), + }); + } + } + return projects; +} + +export const createWorkflow = async (context: IActionContext, uri?: vscode.Uri) => { + ext.outputChannel.appendLog(`[createWorkflow] Started. uri=${uri?.fsPath ?? 'undefined'}`); + + // Collect all available projects + let availableProjects: AvailableProject[]; + try { + availableProjects = await collectAvailableProjects(context); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ext.outputChannel.appendLog(`[createWorkflow] collectAvailableProjects failed: ${message}`); + throw new Error(localize('failedToCollectProjects', 'Failed to collect Logic App projects: {0}', message)); + } + + ext.outputChannel.appendLog( + `[createWorkflow] Found ${availableProjects.length} projects: ${availableProjects.map((p) => p.name).join(', ')}` + ); + + // Determine pre-selected project from URI context + let selectedProject: AvailableProject | undefined; + if (uri && typeof uri === 'object' && 'fsPath' in uri) { + try { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + if (workspaceFolder) { + const projectRoot = await tryGetLogicAppProjectRoot(context, workspaceFolder.uri.fsPath, true); + if (projectRoot) { + selectedProject = availableProjects.find((p) => p.path === projectRoot); + } + } + } catch { + ext.outputChannel.appendLog(`[createWorkflow] getWorkspaceFolder failed for uri=${uri.fsPath}, continuing without pre-selection`); + } + } + + // If no projects found at all, show user-friendly error + if (availableProjects.length === 0) { + ext.outputChannel.appendLog('[createWorkflow] No projects found — throwing'); + throw new Error(localize('noLogicAppProject', 'No Logic App project found in the current workspace.')); + } + + // If only one project and no URI selection, auto-select it + if (!selectedProject && availableProjects.length === 1) { + selectedProject = availableProjects[0]; + } + + ext.outputChannel.appendLog(`[createWorkflow] Pre-selected project: ${selectedProject?.name ?? 'none (user must choose from dropdown)'}`); + + const panelName = localize('createWorkflow', 'Create workflow'); await createWorkspaceWebviewCommandHandler({ - panelName: localize('createWorkflow', 'Create workflow'), + panelName, panelGroupKey: ext.webViewKey.createWorkflow, projectName: ProjectName.createWorkflow, createCommand: ExtensionCommand.createWorkflow, createHandler: async (context: IActionContext, data: any) => { + ext.outputChannel.appendLog(`[createWorkflow] createHandler invoked. logicAppName="${data.logicAppName}"`); + // Resolve project root from the user's selection in the webview + const selectedName = data.logicAppName; + const project = availableProjects.find((p) => p.name === selectedName); + const projectRoot = project?.path; + if (!projectRoot) { + ext.outputChannel.appendLog(`[createWorkflow] Project "${selectedName}" not found in available projects`); + throw new Error(localize('noProjectSelected', 'No project selected. Please select a project and try again.')); + } await createLogicAppWorkflow(context, data, projectRoot); }, extraInitializeData: { - logicAppType, - logicAppName, + logicAppType: selectedProject?.isCodeful ? ProjectType.codeful : '', + logicAppName: selectedProject?.name || '', + availableProjects, }, }); }; diff --git a/apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts new file mode 100644 index 00000000000..eef0d5745ac --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { dotnetDependencyName } from '../../../../constants'; +import { binariesExist, getLatestDotNetVersion } from '../../../utils/binaries'; +import { getDotNetCommand, getLocalDotNetVersionFromBinaries } from '../../../utils/dotnet/dotnet'; +import { installDotNet } from '../installDotNet'; +import { validateDotNetIsLatest } from '../validateDotNetIsLatest'; + +const contextRef = vi.hoisted(() => ({ current: undefined as any })); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + callWithTelemetryAndErrorHandling: vi.fn(async (_eventName: string, callback: (context: any) => Promise) => { + contextRef.current = { + errorHandling: {}, + telemetry: { properties: {} }, + }; + await callback(contextRef.current); + }), +})); + +vi.mock('../../../utils/binaries', () => ({ + binariesExist: vi.fn(), + getLatestDotNetVersion: vi.fn(), +})); + +vi.mock('../../../utils/dotnet/dotnet', () => ({ + getDotNetCommand: vi.fn(), + getLocalDotNetVersionFromBinaries: vi.fn(), +})); + +vi.mock('../installDotNet', () => ({ + installDotNet: vi.fn(), +})); + +describe('validateDotNetIsLatest', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getDotNetCommand).mockReturnValue('dotnet'); + }); + + it('installs without checking GitHub latest version when binaries are missing', async () => { + vi.mocked(binariesExist).mockResolvedValue(false); + + await validateDotNetIsLatest('8'); + + expect(binariesExist).toHaveBeenCalledWith(dotnetDependencyName); + expect(installDotNet).toHaveBeenCalledWith(contextRef.current, '8'); + expect(getLocalDotNetVersionFromBinaries).not.toHaveBeenCalled(); + expect(getLatestDotNetVersion).not.toHaveBeenCalled(); + expect(contextRef.current.telemetry.properties.binariesExist).toBe('false'); + }); + + it('checks latest version when binaries are present and local version exists', async () => { + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalDotNetVersionFromBinaries).mockResolvedValue('8.0.318'); + vi.mocked(getLatestDotNetVersion).mockResolvedValue('8.0.318'); + + await validateDotNetIsLatest('8'); + + expect(getLocalDotNetVersionFromBinaries).toHaveBeenCalledWith('8'); + expect(getLatestDotNetVersion).toHaveBeenCalledWith(contextRef.current, '8'); + expect(installDotNet).not.toHaveBeenCalled(); + expect(contextRef.current.telemetry.properties.binariesExist).toBe('true'); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts b/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts index af263dd69b7..0ae76c82f13 100644 --- a/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts @@ -17,7 +17,7 @@ export async function validateDotNetIsLatest(majorVersion?: string): Promise ({ useBinariesDependencies: vi.fn(), @@ -17,16 +19,13 @@ vi.mock('../../../utils/vsCodeConfig/settings', () => ({ updateGlobalSetting: vi.fn(), })); vi.mock('../../../utils/funcCoreTools/funcVersion', () => ({ + ensureFuncCoreToolsCommandExecutablePermissions: vi.fn(() => true), getFunctionsCommand: vi.fn(() => 'func'), tryParseFuncVersion: vi.fn(), tryGetLocalFuncVersion: vi.fn(), getLocalFuncCoreToolsVersion: vi.fn(), setFunctionsCommand: vi.fn(), })); -vi.mock('../funcVersion', () => ({ - tryGetLocalFuncVersion: vi.fn(), - isFuncToolsInstalled: vi.fn(() => Promise.resolve(false)), -})); vi.mock('../../../utils/funcCoreTools/cpUtils', () => ({ executeCommand: vi.fn(() => Promise.reject(new Error('not installed'))), })); @@ -66,6 +65,8 @@ describe('validateFuncCoreToolsInstalled', () => { }, valuesToMask: [], }; + vi.mocked(ensureFuncCoreToolsCommandExecutablePermissions).mockReturnValue(true); + vi.mocked(executeCommand).mockRejectedValue(new Error('not installed')); }); describe('devContainer workspace', () => { @@ -91,6 +92,30 @@ describe('validateFuncCoreToolsInstalled', () => { }); }); + describe('managed FuncCoreTools readiness', () => { + it('returns true when top-level func works and nested managed executables are executable', async () => { + vi.mocked(isDevContainerWorkspace).mockResolvedValue(false); + vi.mocked(useBinariesDependencies).mockResolvedValue(true); + vi.mocked(ensureFuncCoreToolsCommandExecutablePermissions).mockReturnValue(true); + vi.mocked(executeCommand).mockResolvedValue('4.12.0'); + + await expect(validateFuncCoreToolsInstalled(mockContext, 'test message', 'projectPath')).resolves.toBe(true); + + expect(executeCommand).toHaveBeenCalledWith(undefined, undefined, 'func', '--version'); + expect(ensureFuncCoreToolsCommandExecutablePermissions).toHaveBeenCalledWith('func'); + }); + + it('returns false when managed nested executables are not executable even if top-level func exists', async () => { + vi.mocked(isDevContainerWorkspace).mockResolvedValue(false); + vi.mocked(useBinariesDependencies).mockResolvedValue(true); + vi.mocked(ensureFuncCoreToolsCommandExecutablePermissions).mockReturnValue(false); + + await expect(validateFuncCoreToolsInstalled(mockContext, 'test message', 'projectPath')).resolves.toBe(false); + + expect(executeCommand).not.toHaveBeenCalled(); + }); + }); + describe('non-devContainer workspace', () => { it('should check for binaries when setting is enabled', async () => { vi.mocked(isDevContainerWorkspace).mockResolvedValue(false); @@ -115,12 +140,10 @@ describe('validateFuncCoreToolsInstalled', () => { describe('return value based on environment', () => { it('should return false when func tools not installed in devContainer', async () => { - const { isFuncToolsInstalled } = await import('../funcVersion'); vi.mocked(isDevContainerWorkspace).mockResolvedValue(true); vi.mocked(useBinariesDependencies).mockResolvedValue(false); - vi.mocked(isFuncToolsInstalled).mockResolvedValue(false); - const result = await validateFuncCoreToolsInstalled(mockContext, 'test message'); + await validateFuncCoreToolsInstalled(mockContext, 'test message'); // Should handle system validation path expect(useBinariesDependencies).toHaveBeenCalled(); diff --git a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsInstalled.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsInstalled.ts index 3244b546b42..23aa7a50e56 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsInstalled.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsInstalled.ts @@ -6,7 +6,11 @@ import { type PackageManager, funcVersionSetting, validateFuncCoreToolsSetting } import { localize } from '../../../localize'; import { useBinariesDependencies } from '../../utils/binaries'; import { executeCommand } from '../../utils/funcCoreTools/cpUtils'; -import { getFunctionsCommand, tryParseFuncVersion } from '../../utils/funcCoreTools/funcVersion'; +import { + ensureFuncCoreToolsCommandExecutablePermissions, + getFunctionsCommand, + tryParseFuncVersion, +} from '../../utils/funcCoreTools/funcVersion'; import { getFuncPackageManagers } from '../../utils/funcCoreTools/getFuncPackageManagers'; import { getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; import { installFuncCoreToolsBinaries, installFuncCoreToolsSystem } from './installFuncCoreTools'; @@ -63,6 +67,10 @@ export async function validateFuncCoreToolsInstalled(context: IActionContext, me */ async function isFuncToolsInstalled(): Promise { const funcCommand = getFunctionsCommand(); + if (!ensureFuncCoreToolsCommandExecutablePermissions(funcCommand)) { + return false; + } + try { await executeCommand(undefined, undefined, funcCommand, '--version'); return true; diff --git a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts index 7264af0fe61..79dd7106725 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -50,7 +50,7 @@ async function validateFuncCoreToolsIsLatestBinaries(majorVersion?: string): Pro if (shouldInstall) { if (isOutdated) { context.telemetry.properties.outOfDateFunc = 'true'; - stopAllDesignTimeApis(); + await stopAllDesignTimeApis(); } await installFuncCoreToolsBinaries(context, majorVersion); @@ -114,7 +114,7 @@ async function validateFuncCoreToolsIsLatestSystem(): Promise { if (semver.major(newestVersion) === semver.major(localVersion) && semver.gt(newestVersion, localVersion)) { context.telemetry.properties.outOfDateFunc = 'true'; - stopAllDesignTimeApis(); + await stopAllDesignTimeApis(); await updateFuncCoreTools(context, packageManager, versionFromSetting); await startAllDesignTimeApis(); } diff --git a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/adoDeploymentScriptsSteps/GenerateADODeploymentScriptsStep.ts b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/adoDeploymentScriptsSteps/GenerateADODeploymentScriptsStep.ts index 07bc02da7eb..6f481d53ed4 100644 --- a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/adoDeploymentScriptsSteps/GenerateADODeploymentScriptsStep.ts +++ b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/adoDeploymentScriptsSteps/GenerateADODeploymentScriptsStep.ts @@ -429,7 +429,10 @@ export class GenerateADODeploymentScriptsStep extends AzureWizardExecuteStep f.endsWith('.template.json') && f !== 'hybrid-logicapp-template.json'); + const templateFiles = fs.readdirSync(infraFolder).filter((f) => f.endsWith('.template.json') && f !== 'hybrid-logicapp-template.json'); for (const file of templateFiles) { const filePath = path.join(infraFolder, file); @@ -586,9 +585,7 @@ export class GenerateADODeploymentScriptsStep extends AzureWizardExecuteStep f.endsWith('.parameters.json') && f !== 'hybrid-logicapp-parameters.json'); + const paramFiles = fs.readdirSync(infraFolder).filter((f) => f.endsWith('.parameters.json') && f !== 'hybrid-logicapp-parameters.json'); for (const file of paramFiles) { const filePath = path.join(infraFolder, file); diff --git a/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initDotnetProjectStep.ts b/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initDotnetProjectStep.ts index 1a9f4388bc6..ffbe0748a56 100644 --- a/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initDotnetProjectStep.ts +++ b/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initDotnetProjectStep.ts @@ -12,7 +12,7 @@ import { show64BitWarningSetting, } from '../../../constants'; import { localize } from '../../../localize'; -import { binariesExist } from '../../utils/binaries'; +import { binariesExistSync } from '../../utils/binaries'; import { getFuncHostTaskEnv } from '../../utils/codeless/funcHostTaskEnv'; import { getProjFiles, getTargetFramework, getDotnetDebugSubpath, tryGetFuncVersion } from '../../utils/dotnet/dotnet'; import type { ProjectFile } from '../../utils/dotnet/dotnet'; @@ -109,7 +109,7 @@ export class InitDotnetProjectStep extends InitProjectStepBase { protected getTasks(): TaskDefinition[] { const commonArgs: string[] = ['/property:GenerateFullPaths=true', '/consoleloggerparameters:NoSummary']; const releaseArgs: string[] = ['--configuration', 'Release']; - const funcBinariesExist = binariesExist(funcDependencyName); + const funcBinariesExist = binariesExistSync(funcDependencyName); const binariesOptions = funcBinariesExist ? getFuncHostTaskEnv({ cwd: this.debugSubpath }) : {}; return [ { diff --git a/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initProjectStep.ts b/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initProjectStep.ts index 43d78592071..65a66561a72 100644 --- a/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initProjectStep.ts +++ b/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initProjectStep.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 { binariesExist } from '../../utils/binaries'; +import { binariesExistSync } from '../../utils/binaries'; import { getFuncHostTaskEnv } from '../../utils/codeless/funcHostTaskEnv'; import { extensionCommand, func, funcDependencyName, funcWatchProblemMatcher, hostStartCommand } from '../../../constants'; import { InitScriptProjectStep } from './initScriptProjectStep'; @@ -11,7 +11,7 @@ import type { TaskDefinition } from 'vscode'; export class InitProjectStep extends InitScriptProjectStep { protected getTasks(): TaskDefinition[] { - const funcBinariesExist = binariesExist(funcDependencyName); + const funcBinariesExist = binariesExistSync(funcDependencyName); const binariesOptions = funcBinariesExist ? getFuncHostTaskEnv() : {}; return [ { diff --git a/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initScriptProjectStep.ts b/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initScriptProjectStep.ts index 9d67c37ddb5..75e09da84c3 100644 --- a/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initScriptProjectStep.ts +++ b/apps/vs-code-designer/src/app/commands/initProjectForVSCode/initScriptProjectStep.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { extInstallTaskName, func, funcDependencyName, funcWatchProblemMatcher, hostStartCommand } from '../../../constants'; -import { binariesExist } from '../../utils/binaries'; +import { binariesExistSync } from '../../utils/binaries'; import { getFuncHostTaskEnv } from '../../utils/codeless/funcHostTaskEnv'; import { getLocalFuncCoreToolsVersion } from '../../utils/funcCoreTools/funcVersion'; import { InitProjectStepBase } from './initProjectStepBase'; @@ -51,7 +51,7 @@ export class InitScriptProjectStep extends InitProjectStepBase { } protected getTasks(): TaskDefinition[] { - const funcBinariesExist = binariesExist(funcDependencyName); + const funcBinariesExist = binariesExistSync(funcDependencyName); const binariesOptions = funcBinariesExist ? getFuncHostTaskEnv() : {}; return [ { diff --git a/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts index 260e907e45b..4ef2adf5060 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts @@ -1,29 +1,40 @@ +import { DialogResponses, openUrl } from '@microsoft/vscode-azext-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { validateNodeJsIsLatest } from '../validateNodeJsIsLatest'; -import { binariesExist } from '../../../utils/binaries'; -import { getNodeJsCommand, setNodeJsCommand } from '../../../utils/nodeJs/nodeJsVersion'; -import { getWorkspaceSetting } from '../../../utils/vsCodeConfig/settings'; +import * as vscode from 'vscode'; +import { nodeJsDependencyName } from '../../../../constants'; +import { ext } from '../../../../extensionVariables'; +import { binariesExist, getLatestNodeJsVersion } from '../../../utils/binaries'; +import { getLocalNodeJsVersion, getNodeJsCommand, setNodeJsCommand } from '../../../utils/nodeJs/nodeJsVersion'; +import { getWorkspaceSetting, updateGlobalSetting } from '../../../utils/vsCodeConfig/settings'; import { installNodeJs } from '../installNodeJs'; +import { validateNodeJsIsLatest } from '../validateNodeJsIsLatest'; + +const contextRef = vi.hoisted(() => ({ current: undefined as any })); vi.mock('@microsoft/vscode-azext-utils', () => ({ - callWithTelemetryAndErrorHandling: vi.fn(async (_eventName: string, callback: (context: any) => Promise) => - callback({ + callWithTelemetryAndErrorHandling: vi.fn(async (_eventName: string, callback: (context: any) => Promise) => { + contextRef.current = { errorHandling: {}, - telemetry: { - properties: {}, - }, + telemetry: { properties: {} }, ui: { showWarningMessage: vi.fn(), }, - }) - ), + }; + await callback(contextRef.current); + }), DialogResponses: { - learnMore: { title: 'Learn More' }, - dontWarnAgain: { title: "Don't Warn Again" }, + dontWarnAgain: { title: "Don't warn again" }, + learnMore: { title: 'Learn more' }, }, openUrl: vi.fn(), })); +vi.mock('../../../../extensionVariables', () => ({ + ext: { + outputChannel: { appendLog: vi.fn() }, + }, +})); + vi.mock('../../../utils/binaries', () => ({ binariesExist: vi.fn(), getLatestNodeJsVersion: vi.fn(), @@ -45,24 +56,43 @@ vi.mock('../installNodeJs', () => ({ })); vi.mock('../../../localize', () => ({ - localize: vi.fn((_key: string, defaultValue: string) => defaultValue), + localize: vi.fn((_key: string, defaultValue: string, ...args: unknown[]) => + defaultValue.replace(/\{(\d+)\}/g, (_match, index) => String(args[Number(index)] ?? `{${index}}`)) + ), })); +const flushPromises = async () => { + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } +}; + describe('validateNodeJsIsLatest', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined); vi.mocked(getWorkspaceSetting).mockReturnValue(false); vi.mocked(getNodeJsCommand).mockReturnValue('node'); vi.mocked(setNodeJsCommand).mockResolvedValue(undefined); vi.mocked(installNodeJs).mockResolvedValue(undefined); + vi.mocked(updateGlobalSetting).mockResolvedValue(undefined); + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => task({} as any, {} as any)); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(undefined); + vi.mocked(vscode.window.showErrorMessage).mockResolvedValue(undefined); }); - it('installs NodeJS when the binaries check resolves false', async () => { + it('installs without checking GitHub latest version when binaries are missing', async () => { vi.mocked(binariesExist).mockResolvedValue(false); - await validateNodeJsIsLatest('20'); + await validateNodeJsIsLatest('18'); - expect(installNodeJs).toHaveBeenCalledWith(expect.anything(), '20'); + expect(setNodeJsCommand).toHaveBeenCalledOnce(); + expect(binariesExist).toHaveBeenCalledWith(nodeJsDependencyName); + expect(setNodeJsCommand.mock.invocationCallOrder[0]).toBeLessThan(binariesExist.mock.invocationCallOrder[0]); + expect(installNodeJs).toHaveBeenCalledWith(contextRef.current, '18'); + expect(getLocalNodeJsVersion).not.toHaveBeenCalled(); + expect(getLatestNodeJsVersion).not.toHaveBeenCalled(); + expect(contextRef.current.telemetry.properties.binariesExist).toBe('false'); }); it('repairs the NodeJS command before checking whether binaries exist', async () => { @@ -75,20 +105,194 @@ describe('validateNodeJsIsLatest', () => { expect(setNodeJsCommand.mock.invocationCallOrder[0]).toBeLessThan(binariesExist.mock.invocationCallOrder[0]); }); - it('does not install NodeJS when binaries exist and update warnings are disabled', async () => { - vi.mocked(binariesExist).mockResolvedValue(true); + it('does not reinstall after the first validation repairs the NodeJS binary state', async () => { + vi.mocked(binariesExist).mockResolvedValueOnce(false).mockResolvedValueOnce(true); await validateNodeJsIsLatest('20'); + await validateNodeJsIsLatest('20'); + + expect(installNodeJs).toHaveBeenCalledOnce(); + }); + + it('checks latest version only when binaries are present and warnings are enabled', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.0.0'); + + await validateNodeJsIsLatest('18'); + expect(getLocalNodeJsVersion).toHaveBeenCalledWith(contextRef.current); + expect(getLatestNodeJsVersion).toHaveBeenCalledWith(contextRef.current, '18'); expect(installNodeJs).not.toHaveBeenCalled(); + expect(contextRef.current.telemetry.properties.binariesExist).toBe('true'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs local version: 18.0.0'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs dependency feed version: 18'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs resolved newest version: 18.0.0'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs latest version source: unknown'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs warning decision: notNewer'); }); - it('does not reinstall after the first validation repairs the NodeJS binary state', async () => { - vi.mocked(binariesExist).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + it('does not block validation when the outdated Node.js warning is unanswered', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); + vi.mocked(vscode.window.showWarningMessage).mockReturnValue(new Promise(() => {})); - await validateNodeJsIsLatest('20'); - await validateNodeJsIsLatest('20'); + await validateNodeJsIsLatest('18'); - expect(installNodeJs).toHaveBeenCalledOnce(); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + 'Update your local Node JS version (18.0.0) to the latest version (18.1.0) for the best experience.', + { title: 'Update' }, + DialogResponses.learnMore, + DialogResponses.dontWarnAgain + ); + expect(installNodeJs).not.toHaveBeenCalled(); + }); + + it('shows the outdated Node.js warning when the target version includes minor and patch', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.20.8'); + + await validateNodeJsIsLatest('18.0.0'); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + 'Update your local Node JS version (18.0.0) to the latest version (18.20.8) for the best experience.', + { title: 'Update' }, + DialogResponses.learnMore, + DialogResponses.dontWarnAgain + ); + }); + + it('uses the dependency feed target for a newer same-major minor Node.js warning', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('20.18.3'); + + await validateNodeJsIsLatest('20.19.0'); + + expect(getLatestNodeJsVersion).not.toHaveBeenCalled(); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + 'Update your local Node JS version (20.18.3) to the latest version (20.19.0) for the best experience.', + { title: 'Update' }, + DialogResponses.learnMore, + DialogResponses.dontWarnAgain + ); + expect(contextRef.current.telemetry.properties.latestVersionSource).toBe('dependencyFeed'); + expect(contextRef.current.telemetry.properties.nodeJsWarningDecision).toBe('shown'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs resolved newest version: 20.19.0'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs latest version source: dependencyFeed'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('NodeJs warning decision: shown'); + }); + + it('shows the outdated Node.js warning for a newer target major version', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.20.8'); + + await validateNodeJsIsLatest('20.0.0'); + + expect(getLatestNodeJsVersion).not.toHaveBeenCalled(); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + 'Update your local Node JS version (18.20.8) to the latest version (20.0.0) for the best experience.', + { title: 'Update' }, + DialogResponses.learnMore, + DialogResponses.dontWarnAgain + ); + }); + + it('does not show the outdated Node.js warning when fallback latest version does not match the requested target major', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('20.18.3'); + + await validateNodeJsIsLatest('18.0.0'); + + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled(); + }); + + it('updates the warning setting from the nonblocking outdated Node.js prompt callback', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(DialogResponses.dontWarnAgain); + contextRef.current = undefined; + + await validateNodeJsIsLatest('18'); + await flushPromises(); + + expect(updateGlobalSetting).toHaveBeenCalledWith('showNodeJsWarning', false); + }); + + it('updates Node.js and refreshes the command from the nonblocking outdated prompt callback only after Update is selected', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); + let resolveWarning!: (value: any) => void; + const warningPromise = new Promise((resolve) => { + resolveWarning = resolve; + }); + vi.mocked(vscode.window.showWarningMessage).mockReturnValue(warningPromise); + contextRef.current = undefined; + + await validateNodeJsIsLatest('18'); + const update = vi.mocked(vscode.window.showWarningMessage).mock.calls[0][1]; + expect(installNodeJs).not.toHaveBeenCalled(); + resolveWarning(update); + await flushPromises(); + + expect(installNodeJs).toHaveBeenCalledWith(contextRef.current, '18'); + expect(setNodeJsCommand).toHaveBeenCalledTimes(2); + expect(vscode.window.withProgress).toHaveBeenCalledWith( + { + location: vscode.ProgressLocation.Notification, + title: 'Updating Node JS runtime dependency', + }, + expect.any(Function) + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Node JS runtime dependency update completed.'); + }); + + it('opens learn more from the nonblocking outdated Node.js prompt callback', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(DialogResponses.learnMore); + contextRef.current = undefined; + + await validateNodeJsIsLatest('18'); + await flushPromises(); + + expect(openUrl).toHaveBeenCalledWith('https://nodejs.org/en/download'); + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1); + }); + + it('surfaces update failures from the nonblocking outdated Node.js prompt callback', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); + vi.mocked(installNodeJs).mockRejectedValueOnce(new Error('download failed')); + let resolveWarning!: (value: any) => void; + const warningPromise = new Promise((resolve) => { + resolveWarning = resolve; + }); + vi.mocked(vscode.window.showWarningMessage).mockReturnValue(warningPromise); + contextRef.current = undefined; + + await validateNodeJsIsLatest('18'); + const update = vi.mocked(vscode.window.showWarningMessage).mock.calls[0][1]; + resolveWarning(update); + await flushPromises(); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Failed to update Node JS runtime dependency: "download failed".'); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); }); }); diff --git a/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts b/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts index 9c0e0ccfb6a..6ac681a30fc 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { nodeJsDependencyName } from '../../../constants'; +import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { binariesExist, getLatestNodeJsVersion } from '../../utils/binaries'; import { getLocalNodeJsVersion, getNodeJsCommand, setNodeJsCommand } from '../../utils/nodeJs/nodeJsVersion'; @@ -11,7 +12,7 @@ import { installNodeJs } from './installNodeJs'; import { callWithTelemetryAndErrorHandling, DialogResponses, openUrl } from '@microsoft/vscode-azext-utils'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import * as semver from 'semver'; -import type { MessageItem } from 'vscode'; +import { ProgressLocation, window, type MessageItem } from 'vscode'; export async function validateNodeJsIsLatest(majorVersion?: string): Promise { await callWithTelemetryAndErrorHandling('azureLogicAppsStandard.validateNodeJsIsLatest', async (context: IActionContext) => { @@ -30,34 +31,177 @@ export async function validateNodeJsIsLatest(majorVersion?: string): Promise { + const normalizedTargetVersion = getNormalizedVersion(targetVersion); + context.telemetry.properties.targetVersion = normalizedTargetVersion; + + if (normalizedTargetVersion && semver.gt(normalizedTargetVersion, localVersion)) { + context.telemetry.properties.latestVersionSource = 'dependencyFeed'; + return normalizedTargetVersion; + } + + const newestVersion: string = await getLatestNodeJsVersion(context, targetVersion); + if (!semver.gt(newestVersion, localVersion)) { + context.telemetry.properties.nodeJsWarningDecision = + context.telemetry.properties.latestNodeJSVersion === 'fallback' || + context.telemetry.properties.latestNodeJSVersion === 'fallback-no-match' + ? 'latestLookupFallback' + : 'notNewer'; + } else if (!doesNewestVersionMatchTarget(localVersion, newestVersion, targetVersion)) { + context.telemetry.properties.nodeJsWarningDecision = 'targetMismatch'; + } + + return newestVersion; +} + +function doesNewestVersionMatchTarget(localVersion: string, newestVersion: string, targetVersion: string | undefined): boolean { + const localMajorVersion = getMajorVersion(localVersion); + const newestMajorVersion = getMajorVersion(newestVersion); + const targetMajorVersion = targetVersion ? getMajorVersion(targetVersion) : undefined; + + if (localMajorVersion === undefined || newestMajorVersion === undefined) { + return false; + } + + if (targetMajorVersion === undefined) { + return newestMajorVersion === localMajorVersion; + } + + return newestMajorVersion === targetMajorVersion && targetMajorVersion >= localMajorVersion; +} + +function getNormalizedVersion(version: string | undefined): string | undefined { + if (!version) { + return undefined; + } + + return semver.valid(semver.coerce(version)) ?? undefined; +} + +function showOutdatedNodeJsWarning( + context: IActionContext, + localVersion: string, + newestVersion: string, + majorVersion: string | undefined, + showNodeJsWarningKey: string +): void { + const message: string = localize( + 'outdatedNodeJsRuntime', + 'Update your local Node JS version ({0}) to the latest version ({1}) for the best experience.', + localVersion, + newestVersion + ); + const update: MessageItem = { title: 'Update' }; + + window + .showWarningMessage(message, update, DialogResponses.learnMore, DialogResponses.dontWarnAgain) + .then(async (result: MessageItem | undefined) => { + if (result === DialogResponses.learnMore) { + await openUrl('https://nodejs.org/en/download'); + } else if (result === update) { + await updateNodeJsFromWarning(context, majorVersion); + } else if (result === DialogResponses.dontWarnAgain) { + await updateGlobalSetting(showNodeJsWarningKey, false); + } + }) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + context.telemetry.properties.outdatedNodeJsWarningError = errorMessage; + ext.outputChannel.appendLog(localize('outdatedNodeJsWarningError', 'Error handling outdated Node JS warning: "{0}".', errorMessage)); + window.showErrorMessage(localize('outdatedNodeJsUpdateError', 'Failed to update Node JS runtime dependency: "{0}".', errorMessage)); + }); +} + +async function updateNodeJsFromWarning(context: IActionContext, majorVersion: string | undefined): Promise { + await window.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingNodeJsRuntime', 'Updating Node JS runtime dependency'), + }, + async () => { + await installNodeJs(context, majorVersion); + await setNodeJsCommand(); + } + ); + await window.showInformationMessage(localize('updatedNodeJsRuntime', 'Node JS runtime dependency update completed.')); +} + +function shouldShowOutdatedNodeJsWarning(localVersion: string, newestVersion: string, targetVersion: string | undefined): boolean { + if (!semver.gt(newestVersion, localVersion)) { + return false; + } + + const localMajorVersion = getMajorVersion(localVersion); + const newestMajorVersion = getMajorVersion(newestVersion); + const targetMajorVersion = targetVersion ? getMajorVersion(targetVersion) : undefined; + + if (localMajorVersion === undefined || newestMajorVersion === undefined) { + return false; + } + + if (targetMajorVersion === undefined) { + return newestMajorVersion === localMajorVersion; + } + + return newestMajorVersion === targetMajorVersion && targetMajorVersion >= localMajorVersion; +} + +function getMajorVersion(version: string): number | undefined { + const coercedVersion = semver.coerce(version); + return coercedVersion ? semver.major(coercedVersion) : undefined; +} diff --git a/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts b/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts index 2f2802feb21..1ec7f547c29 100644 --- a/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts +++ b/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts @@ -35,6 +35,7 @@ import parser from 'yargs-parser'; import { tryBuildCustomCodeFunctionsProject } from './buildCustomCodeFunctionsProject'; import { publishCodefulProject } from './publishCodefulProject'; import { getProjFiles } from '../utils/dotnet/dotnet'; +import { isCodefulProject } from '../utils/codeful'; import { delay } from '../utils/delay'; type OSAgnosticProcess = { command: string | undefined; pid: number | string }; @@ -89,16 +90,22 @@ export async function pickFuncProcessInternal( throw new UserCancelledError('preDebugValidate'); } - await tryBuildCustomCodeFunctionsProject(context, workspaceFolder.uri); - // For codeful projects, the `func: host start` task chains a Debug `build` via dependsOn, - // and the modern codeful template hooks `CopyToCodefulFolder`/`ReplaceLanguageNetCore` to - // `AfterTargets="Build;Publish"`. Running an explicit Release `publish` first would just - // duplicate the clean+build cycle and its output would be overwritten by the subsequent - // Debug build. Skip it when the .csproj confirms the build hooks are present. Deploy paths - // (deploy.ts) keep the unconditional publish so `bin/Release//publish/` is produced. - await publishCodefulProject(context, workspaceFolder.uri, { skipIfBuildPopulatesCodeful: true }); - + // Stop any previous func process BEFORE building to avoid file lock errors + // (e.g. GenerateFunctionMetadata failing on obj/Debug/net8/WorkerExtensions) await waitForPrevFuncTaskToStop(workspaceFolder); + + if (await isCodefulProject(projectPath)) { + // For codeful projects, the `func: host start` task chains a Debug `build` via dependsOn, + // and the modern codeful template hooks `CopyToCodefulFolder`/`ReplaceLanguageNetCore` to + // `AfterTargets="Build;Publish"`. Running an explicit Release `publish` first would just + // duplicate the clean+build cycle and its output would be overwritten by the subsequent + // Debug build. Skip it when the .csproj confirms the build hooks are present. Deploy paths + // (deploy.ts) keep the unconditional publish so `bin/Release//publish/` is produced. + await publishCodefulProject(context, workspaceFolder.uri, { skipIfBuildPopulatesCodeful: true }); + } else { + await tryBuildCustomCodeFunctionsProject(context, workspaceFolder.uri); + } + const projectFiles = await getProjFiles(context, ProjectLanguage.CSharp, projectPath); const isBundleProject: boolean = projectFiles.length > 0 ? false : true; diff --git a/apps/vs-code-designer/src/app/commands/registerCommands.ts b/apps/vs-code-designer/src/app/commands/registerCommands.ts index e15e53b52ff..baa2d026d2c 100644 --- a/apps/vs-code-designer/src/app/commands/registerCommands.ts +++ b/apps/vs-code-designer/src/app/commands/registerCommands.ts @@ -90,7 +90,7 @@ export function registerCommands(): void { registerCommand(extensionCommand.createProject, createNewProject); registerCommand(extensionCommand.createWorkspace, createWorkspace); registerCommand(extensionCommand.cloudToLocal, cloudToLocal); - registerCommand(extensionCommand.createWorkflow, createWorkflow); + registerCommand(extensionCommand.createWorkflow, (context: IActionContext, uri: vscode.Uri) => createWorkflow(context, uri)); registerCommandWithTreeNodeUnwrapping(extensionCommand.createLogicApp, createLogicApp); registerCommandWithTreeNodeUnwrapping(extensionCommand.createLogicAppAdvanced, createLogicAppAdvanced); registerSiteCommand(extensionCommand.deploy, unwrapTreeNodeCommandCallback(deployProductionSlot)); @@ -180,6 +180,34 @@ export function registerCommands(): void { const errorData: IParsedError = parseError(errorContext.error); const correlationId = guid(); + // Diagnostic: log raw error details for debugging opaque errors like "Error: {}" + if (errorData.message === '{}' || errorData.message === 'Unknown Error') { + const rawError = errorContext.error; + const diagnosticInfo = { + callbackId: errorContext.callbackId, + type: typeof rawError, + constructor: rawError?.constructor?.name, + message: rawError?.message, + keys: rawError ? Object.keys(rawError) : [], + stringified: (() => { + try { + return JSON.stringify(rawError); + } catch { + return ''; + } + })(), + fullStack: rawError?.stack, + }; + ext.outputChannel.appendLog( + `[Error Diagnostics] Raw error producing "${errorData.message}": ${JSON.stringify(diagnosticInfo, null, 2)}` + ); + + // Suppress display of opaque internal errors that provide no useful info to users + if (errorData.message === '{}') { + errorContext.errorHandling.suppressDisplay = true; + } + } + if (errorContext.error instanceof UserCancelledError) { errorContext.errorHandling.suppressDisplay = true; errorContext.telemetry.properties.isUserCancelled = 'true'; 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 825b950ee8a..896c3a4394d 100644 --- a/apps/vs-code-designer/src/app/commands/shared/workspaceWebviewCommandHandler.ts +++ b/apps/vs-code-designer/src/app/commands/shared/workspaceWebviewCommandHandler.ts @@ -55,6 +55,7 @@ export async function createWorkspaceWebviewCommandHandler(config: WorkspaceWebv const options: vscode.WebviewOptions & vscode.WebviewPanelOptions = { enableScripts: true, retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(path.join(ext.context.extensionPath, 'vs-code-react'))], }; const panel = vscode.window.createWebviewPanel('CreateWorkspace', panelName, vscode.ViewColumn.Active, options); @@ -70,39 +71,24 @@ export async function createWorkspaceWebviewCommandHandler(config: WorkspaceWebv // Standard message handlers const messageHandlers = { [ExtensionCommand.initialize]: async () => { + const initData = { + apiVersion, + project: projectName, + hostVersion: ext.extensionVersion, + separator: path.sep, + platform: os.platform(), + ...extraInitializeData, + }; + ext.outputChannel.appendLog( + `[WebviewInit] Sending initialize_frame for ${projectName}. existingFolders=${JSON.stringify(initData.existingFolders ?? 'NOT_SET')}` + ); panel.webview.postMessage({ command: ExtensionCommand.initialize_frame, - data: { - apiVersion, - project: projectName, - hostVersion: ext.extensionVersion, - separator: path.sep, - platform: os.platform(), - ...extraInitializeData, - }, + data: initData, }); }, [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)}`); - if (isCreateInProgress) { return; } diff --git a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts index 545baed6c24..411760a4d18 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts @@ -9,6 +9,7 @@ import { downloadFileWithVerification, DownloadIntegrityError, binariesExist, + binariesExistSync, getLatestDotNetVersion, getLatestFunctionCoreToolsVersion, getLatestNodeJsVersion, @@ -25,10 +26,12 @@ import { import { ext } from '../../../extensionVariables'; import { DependencyVersion, + autoRuntimeDependenciesValidationAndInstallationSetting, dotnetDependencyName, funcCoreToolsBinaryPathSettingKey, funcDependencyName, nodeJsBinaryPathSettingKey, + dotNetBinaryPathSettingKey, nodeJsDependencyName, } from '../../../constants'; import { validateAndInstallBinaries } from '../../commands/binaries/validateAndInstallBinaries'; @@ -352,6 +355,224 @@ describe('binaries', () => { expect(result).toBe(false); expect(fs.existsSync).not.toHaveBeenCalled(); }); + + describe('Windows .exe fallback', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.unstubAllGlobals(); + }); + + it('should return true and update setting when .exe variant exists on Windows for NodeJs', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }); + const nodeFolder = path.join('binariesLocation', nodeJsDependencyName); + const nodeBinaryNoExe = path.join(nodeFolder, 'node'); + const nodeBinaryExe = path.join(nodeFolder, 'node.exe'); + + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + if (filePath === nodeFolder) { + return true; + } + if (filePath === nodeBinaryExe) { + return true; + } + return false; + }); + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspace).mockResolvedValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === nodeJsBinaryPathSettingKey ? nodeBinaryNoExe : 'binariesLocation' + ); + + const result = await binariesExist(nodeJsDependencyName); + + expect(result).toBe(true); + expect(updateGlobalSetting).toHaveBeenCalledWith(nodeJsBinaryPathSettingKey, nodeBinaryExe); + }); + + it('should return true and update setting when .exe variant exists on Windows for FuncCoreTools', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }); + const funcFolder = path.join('binariesLocation', funcDependencyName); + const funcBinaryNoExe = path.join(funcFolder, 'func'); + const funcBinaryExe = path.join(funcFolder, 'func.exe'); + + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + if (filePath === funcFolder) { + return true; + } + if (filePath === funcBinaryExe) { + return true; + } + return false; + }); + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspace).mockResolvedValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === funcCoreToolsBinaryPathSettingKey ? funcBinaryNoExe : 'binariesLocation' + ); + + const result = await binariesExist(funcDependencyName); + + expect(result).toBe(true); + expect(updateGlobalSetting).toHaveBeenCalledWith(funcCoreToolsBinaryPathSettingKey, funcBinaryExe); + }); + + it('should return true and update setting when .exe variant exists on Windows for DotNetSDK', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }); + const dotnetFolder = path.join('binariesLocation', dotnetDependencyName); + const dotnetBinaryNoExe = path.join(dotnetFolder, 'dotnet'); + const dotnetBinaryExe = path.join(dotnetFolder, 'dotnet.exe'); + + (fs.existsSync as Mock).mockImplementation((filePath: string) => { + if (filePath === dotnetFolder) { + return true; + } + if (filePath === dotnetBinaryExe) { + return true; + } + return false; + }); + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspace).mockResolvedValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === dotNetBinaryPathSettingKey ? dotnetBinaryNoExe : 'binariesLocation' + ); + + const result = await binariesExist(dotnetDependencyName); + + expect(result).toBe(true); + expect(updateGlobalSetting).toHaveBeenCalledWith(dotNetBinaryPathSettingKey, dotnetBinaryExe); + }); + + it('should still return false on Windows when neither base nor .exe variant exists', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }); + const funcFolder = path.join('binariesLocation', funcDependencyName); + const funcBinaryNoExe = path.join(funcFolder, 'func'); + + (fs.existsSync as Mock).mockImplementation((filePath: string) => filePath === funcFolder); + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspace).mockResolvedValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === funcCoreToolsBinaryPathSettingKey ? funcBinaryNoExe : 'binariesLocation' + ); + + const result = await binariesExist(funcDependencyName); + + expect(result).toBe(false); + expect(executeCommand).toHaveBeenCalledWith( + ext.outputChannel, + undefined, + 'echo', + `FuncCoreTools binary is missing: ${funcBinaryNoExe}` + ); + }); + + it('should not try .exe fallback when path already ends with .exe', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }); + const funcFolder = path.join('binariesLocation', funcDependencyName); + const funcBinaryExe = path.join(funcFolder, 'func.exe'); + + (fs.existsSync as Mock).mockImplementation((filePath: string) => filePath === funcFolder); + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspace).mockResolvedValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === funcCoreToolsBinaryPathSettingKey ? funcBinaryExe : 'binariesLocation' + ); + + const result = await binariesExist(funcDependencyName); + + expect(result).toBe(false); + expect(updateGlobalSetting).not.toHaveBeenCalled(); + }); + + it('should not try .exe fallback on non-Windows platforms', async () => { + vi.stubGlobal('process', { ...process, platform: 'linux' }); + const nodeFolder = path.join('binariesLocation', nodeJsDependencyName); + const nodeBinaryNoExe = path.join(nodeFolder, 'node'); + + (fs.existsSync as Mock).mockImplementation((filePath: string) => filePath === nodeFolder); + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspace).mockResolvedValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === nodeJsBinaryPathSettingKey ? nodeBinaryNoExe : 'binariesLocation' + ); + + const result = await binariesExist(nodeJsDependencyName); + + expect(result).toBe(false); + expect(updateGlobalSetting).not.toHaveBeenCalled(); + }); + }); + }); + + describe('binariesExistSync', () => { + it('should return false when automatic runtime dependencies are disabled', async () => { + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspaceSync).mockReturnValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === autoRuntimeDependenciesValidationAndInstallationSetting ? false : 'binariesLocation' + ); + + const result = binariesExistSync(funcDependencyName); + + expect(result).toBe(false); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('should return false for devContainer workspace regardless of automatic runtime dependency setting', async () => { + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspaceSync).mockReturnValue(true); + (getGlobalSetting as Mock).mockReturnValue(true); + + const result = binariesExistSync(funcDependencyName); + + expect(result).toBe(false); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('should return true when the configured binary exists', async () => { + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspaceSync).mockReturnValue(false); + const funcFolder = path.join('binariesLocation', funcDependencyName); + const funcBinary = path.join(funcFolder, 'func.exe'); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => { + if (settingName === autoRuntimeDependenciesValidationAndInstallationSetting) { + return true; + } + if (settingName === funcCoreToolsBinaryPathSettingKey) { + return funcBinary; + } + return 'binariesLocation'; + }); + (fs.existsSync as Mock).mockImplementation((filePath: string) => filePath === funcFolder || filePath === funcBinary); + + const result = binariesExistSync(funcDependencyName); + + expect(result).toBe(true); + }); + + it('should return false when the dependency folder exists but the configured binary is missing', async () => { + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspaceSync).mockReturnValue(false); + const funcFolder = path.join('binariesLocation', funcDependencyName); + const funcBinary = path.join(funcFolder, 'func.exe'); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => { + if (settingName === autoRuntimeDependenciesValidationAndInstallationSetting) { + return true; + } + if (settingName === funcCoreToolsBinaryPathSettingKey) { + return funcBinary; + } + return 'binariesLocation'; + }); + (fs.existsSync as Mock).mockImplementation((filePath: string) => filePath === funcFolder); + + const result = binariesExistSync(funcDependencyName); + + expect(result).toBe(false); + expect(executeCommand).toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', `FuncCoreTools binary is missing: ${funcBinary}`); + }); }); describe('getLatestDotNetVersion', () => { @@ -377,15 +598,17 @@ describe('binaries', () => { expect(result).toBe('6.0.0'); }); - it('should throw error when api call to get dotnet version fails and return fallback version', async () => { + it('should return fallback .NET version without showing an error when GitHub latest-version lookup fails', async () => { const showErrorMessage = vi.fn(); - (axios.get as Mock).mockResolvedValue({ data: [], status: 500 }); + (axios.get as Mock).mockRejectedValue(new Error('Request failed with status code 403')); vscode.window.showErrorMessage = showErrorMessage; const result = await getLatestDotNetVersion(context, majorVersion); expect(result).toBe(DependencyVersion.dotnet8); - expect(showErrorMessage).toHaveBeenCalled(); + expect(showErrorMessage).not.toHaveBeenCalled(); + expect(context.telemetry.properties.latestVersionSource).toBe('fallback'); + expect(context.telemetry.properties.errorNewestDotNetVersion).toContain('Request failed with status code 403'); }); it('should return fallback dotnet version when no major version is sent', async () => { @@ -431,18 +654,19 @@ describe('binaries', () => { expect(context.telemetry.properties.latestVersionSource).toBe('github'); }); - it('should return the fallback Function Core Tools version', async () => { + it('should return the fallback Function Core Tools version without showing an error when GitHub lookup fails', async () => { const showErrorMessage = vi.fn(); (isNodeJsInstalled as Mock).mockResolvedValue(false); - (axios.get as Mock).mockResolvedValue({ data: [], status: 500 }); + (axios.get as Mock).mockRejectedValue(new Error('Request failed with status code 403')); vscode.window.showErrorMessage = showErrorMessage; const result = await getLatestFunctionCoreToolsVersion(context, majorVersion); expect(result).toBe(DependencyVersion.funcCoreTools); - expect(showErrorMessage).toHaveBeenCalled(); + expect(showErrorMessage).not.toHaveBeenCalled(); expect(context.telemetry.properties.latestVersionSource).toBe('fallback'); + expect(context.telemetry.properties.errorLatestFunctionCoretoolsVersion).toContain('Request failed with status code 403'); }); it('should return the fallback Function Core Tools version when no major version is sent', async () => { @@ -468,22 +692,36 @@ describe('binaries', () => { }); it('should return the latest Node.js version', async () => { - const response = [{ tag_name: 'v14.0.0' }]; + const response = [{ tag_name: 'v14.1.0' }, { tag_name: 'v14.0.0' }]; (axios.get as any).mockResolvedValue({ data: response, status: 200 }); const result = await getLatestNodeJsVersion(context, majorVersion); - expect(result).toBe('14.0.0'); + expect(result).toBe('14.1.0'); + expect(context.telemetry.properties.latestVersionSource).toBe('github'); + expect(context.telemetry.properties.latestNodeJSVersion).toBe('14.1.0'); + }); + + it('should return the latest Node.js version when requested version includes minor and patch', async () => { + const response = [{ tag_name: 'v20.0.0' }, { tag_name: 'v18.20.8' }, { tag_name: 'v18.0.0' }]; + (axios.get as any).mockResolvedValue({ data: response, status: 200 }); + + const result = await getLatestNodeJsVersion(context, '18.0.0'); + + expect(result).toBe('18.20.8'); }); - it('should throw error when api call to get dotnet version fails', async () => { + it('should return fallback Node.js version without showing an error when latest-version lookups fail', async () => { const showErrorMessage = vi.fn(); - (axios.get as Mock).mockResolvedValue({ data: [], status: 500 }); + (axios.get as Mock).mockRejectedValue(new Error('Request failed with status code 403')); vscode.window.showErrorMessage = showErrorMessage; const result = await getLatestNodeJsVersion(context, majorVersion); expect(result).toBe(DependencyVersion.nodeJs); - expect(showErrorMessage).toHaveBeenCalled(); + expect(showErrorMessage).not.toHaveBeenCalled(); + expect(context.telemetry.properties.latestNodeJSVersion).toBe('fallback'); + expect(context.telemetry.properties.latestVersionSource).toBe('fallback'); + expect(context.telemetry.properties.errorLatestNodeJsVersion).toContain('Request failed with status code 403'); }); it('should return fallback nodejs version when requested version is not found in the list', async () => { @@ -494,12 +732,14 @@ describe('binaries', () => { expect(result).toBe(DependencyVersion.nodeJs); expect(context.telemetry.properties.latestNodeJSVersion).toBe('fallback-no-match'); + expect(context.telemetry.properties.latestVersionSource).toBe('fallback'); expect(context.telemetry.properties.errorLatestNodeJsVersion).toBe('No matching Node JS version found.'); }); it('should return fallback nodejs version when no major version is sent', async () => { const result = await getLatestNodeJsVersion(context); expect(result).toBe(DependencyVersion.nodeJs); + expect(context.telemetry.properties.latestVersionSource).toBe('fallback'); }); }); diff --git a/apps/vs-code-designer/src/app/utils/__test__/devContainerUtils.test.ts b/apps/vs-code-designer/src/app/utils/__test__/devContainerUtils.test.ts index f39fd3fa630..c06028f9f4b 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/devContainerUtils.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/devContainerUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { isDevContainerWorkspace } from '../devContainerUtils'; +import { isDevContainerWorkspace, isDevContainerWorkspaceSync } from '../devContainerUtils'; import * as vscode from 'vscode'; import * as path from 'path'; @@ -153,5 +153,64 @@ describe('devContainerUtils', () => { expect(result).toBe(false); }); + + it('should synchronously return false when no workspace file exists', () => { + (vscode.workspace as any).workspaceFile = undefined; + + const result = isDevContainerWorkspaceSync(); + + expect(result).toBe(false); + }); + + it('should synchronously return false when no workspace folders exist', () => { + (vscode.workspace as any).workspaceFile = { fsPath: path.join(tempDir, 'test.code-workspace') }; + (vscode.workspace as any).workspaceFolders = []; + + const result = isDevContainerWorkspaceSync(); + + expect(result).toBe(false); + }); + + it('should synchronously return true when devcontainer folder is listed and devcontainer.json exists', async () => { + const workspaceFilePath = path.join(tempDir, 'test.code-workspace'); + const devcontainerDir = path.join(tempDir, '.devcontainer'); + const devcontainerJsonPath = path.join(devcontainerDir, 'devcontainer.json'); + + await fse.ensureDir(devcontainerDir); + await fse.writeJson(devcontainerJsonPath, { name: 'Test DevContainer' }); + await fse.writeJson(workspaceFilePath, { + folders: [{ path: './.devcontainer' }, { path: './LogicApp' }], + }); + + (vscode.workspace as any).workspaceFile = { fsPath: workspaceFilePath }; + (vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: tempDir } }]; + + const result = isDevContainerWorkspaceSync(); + + expect(result).toBe(true); + }); + + it('should synchronously return false when devcontainer folder is listed but devcontainer.json does not exist', async () => { + const workspaceFilePath = path.join(tempDir, 'test.code-workspace'); + await fse.writeJson(workspaceFilePath, { + folders: [{ path: './.devcontainer' }, { path: './LogicApp' }], + }); + + (vscode.workspace as any).workspaceFile = { fsPath: workspaceFilePath }; + (vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: tempDir } }]; + + const result = isDevContainerWorkspaceSync(); + + expect(result).toBe(false); + }); + + it('should synchronously return false when workspace file cannot be read', () => { + (vscode.workspace as any).workspaceFile = { fsPath: path.join(tempDir, 'missing.code-workspace') }; + (vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: tempDir } }]; + + const result = isDevContainerWorkspaceSync(); + + expect(result).toBe(false); + }); }); }); diff --git a/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts b/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts index 1292d5db6af..300966e04fd 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue, lspDirectory } from '../../../constants'; import { ext } from '../../../extensionVariables'; -import { installLSPSDK } from '../languageServerProtocol'; +import { formatLockedFileError, installLSPSDK } from '../languageServerProtocol'; import path from 'path'; import { createHash } from 'crypto'; @@ -256,6 +256,7 @@ describe('installLSPSDK', () => { }); it('adds actionable guidance for locked LSP files during extraction', async () => { + vi.useFakeTimers(); const lockedError = Object.assign(new Error("EBUSY: resource busy or locked, open 'ICSharpCode.SharpZipLib.dll'"), { code: 'EBUSY', }); @@ -263,8 +264,107 @@ describe('installLSPSDK', () => { throw lockedError; }); - await expect(installLSPSDK()).rejects.toThrow('stop the dotnet process running SdkLspServer.dll'); - expect(mocks.copyFile).not.toHaveBeenCalled(); + try { + const installPromise = installLSPSDK(); + const expectation = expect(installPromise).rejects.toThrow('stop the dotnet process running SdkLspServer.dll'); + await vi.advanceTimersByTimeAsync(4000); + + await expectation; + expect(mocks.extractAllTo).toHaveBeenCalledTimes(3); + expect(mocks.copyFile).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('retries when extracting LSP server hits EPERM creating the LSPServer directory', async () => { + vi.useFakeTimers(); + const lockedError = Object.assign(new Error(`EPERM: operation not permitted, mkdir '${path.join(targetDirectory, 'LSPServer')}'`), { + code: 'EPERM', + path: path.join(targetDirectory, 'LSPServer'), + syscall: 'mkdir', + }); + let extractionSucceeded = false; + let extractAttempts = 0; + mocks.extractAllTo.mockImplementation(() => { + extractAttempts += 1; + if (extractAttempts === 1) { + throw lockedError; + } + + extractionSucceeded = true; + }); + mocks.pathExists.mockImplementation(async (filePath: string) => { + return filePath.endsWith('SdkLspServer.dll') && extractionSucceeded; + }); + + try { + const installPromise = installLSPSDK(); + await vi.advanceTimersByTimeAsync(2000); + await installPromise; + + expect(mocks.extractAllTo).toHaveBeenCalledTimes(2); + expect(mocks.writeFile).toHaveBeenCalledWith(lspHashMarker, serverZipHash); + expect(mocks.copyFile).toHaveBeenCalledWith(expect.stringContaining(sdkPackageName), sdkDestinationFile); + } finally { + vi.useRealTimers(); + } + }); + + it('surfaces actionable guidance when EPERM mkdir persists during LSPServer extraction', async () => { + vi.useFakeTimers(); + const lockedError = Object.assign(new Error(`EPERM: operation not permitted, mkdir '${path.join(targetDirectory, 'LSPServer')}'`), { + code: 'EPERM', + path: path.join(targetDirectory, 'LSPServer'), + syscall: 'mkdir', + }); + mocks.extractAllTo.mockImplementation(() => { + throw lockedError; + }); + + try { + const installPromise = installLSPSDK(); + const expectation = expect(installPromise).rejects.toThrow( + /Error extracting LSP server:.*stop the dotnet process running SdkLspServer.dll/ + ); + await vi.advanceTimersByTimeAsync(4000); + + await expectation; + expect(mocks.extractAllTo).toHaveBeenCalledTimes(3); + expect(mocks.copyFile).not.toHaveBeenCalled(); + expect(mocks.writeFile).not.toHaveBeenCalledWith(lspHashMarker, serverZipHash); + } finally { + vi.useRealTimers(); + } + }); + + it('formatLockedFileError recognizes EPERM', () => { + const lockedError = Object.assign(new Error("EPERM: operation not permitted, unlink 'ICSharpCode.SharpZipLib.dll'"), { + code: 'EPERM', + }); + + expect(formatLockedFileError(lockedError)).toContain('stop the dotnet process running SdkLspServer.dll'); + }); + + it('installLSPSDK retries on EPERM', async () => { + vi.useFakeTimers(); + try { + const lockedError = Object.assign(new Error("EPERM: operation not permitted, unlink 'ICSharpCode.SharpZipLib.dll'"), { + code: 'EPERM', + }); + setExistingPaths([lspServerPath]); + mocks.remove.mockRejectedValueOnce(lockedError).mockRejectedValueOnce(lockedError).mockResolvedValue(undefined); + + const installPromise = installLSPSDK(); + await vi.advanceTimersByTimeAsync(4000); + await installPromise; + + const removeLspCalls = mocks.remove.mock.calls.filter(([filePath]) => filePath === lspServerPath); + expect(removeLspCalls).toHaveLength(3); + expect(mocks.extractAllTo).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } }); it('stops a running language client before replacing LSP assets', async () => { diff --git a/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts b/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts index b23462df938..9bad3d309f8 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts @@ -20,8 +20,8 @@ export async function verifyLocalConnectionKeys(context: IActionContext, project const verifyConnectionKeysStartTime = Date.now(); if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { if (!projectPath) { - const workspaceFolder = await getWorkspaceFolder(context); - projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); + const workspaceFolder = await getWorkspaceFolder(context, undefined, true); + projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder, true); if (!projectPath) { return; } diff --git a/apps/vs-code-designer/src/app/utils/binaries.ts b/apps/vs-code-designer/src/app/utils/binaries.ts index be3b4c44489..df487d3e564 100644 --- a/apps/vs-code-designer/src/app/utils/binaries.ts +++ b/apps/vs-code-designer/src/app/utils/binaries.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { DependencyVersion, + autoRuntimeDependenciesValidationAndInstallationSetting, autoRuntimeDependenciesPathSettingKey, dependencyTimeoutSettingKey, dotnetDependencyName, @@ -24,6 +25,7 @@ import { executeCommand } from './funcCoreTools/cpUtils'; import { getNpmCommand } from './nodeJs/nodeJsVersion'; import { getGlobalSetting, getWorkspaceSetting, updateGlobalSetting } from './vsCodeConfig/settings'; import { onboardBinaries, useBinariesDependencies } from './runtimeDependencies'; +import { isDevContainerWorkspaceSync } from './devContainerUtils'; import { type DownloadAttemptResult, downloadFileWithVerification as downloadFileWithVerificationCore } from './integrity'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { Platform, type IGitHubReleaseInfo } from '@microsoft/vscode-extension-logic-apps'; @@ -37,7 +39,7 @@ import * as vscode from 'vscode'; import AdmZip from 'adm-zip'; import { isNullOrUndefined, isString } from '@microsoft/logic-apps-shared'; -import { setFunctionsCommand } from './funcCoreTools/funcVersion'; +import { repairFuncCoreToolsExecutablePermissions, setFunctionsCommand } from './funcCoreTools/funcVersion'; import { startAllDesignTimeApis, stopAllDesignTimeApis } from './codeless/startDesignTimeApi'; export { useBinariesDependencies } from './runtimeDependencies'; @@ -152,6 +154,7 @@ export async function downloadAndExtractDependency( fs.chmodSync(`${targetFolder}/in-proc8/func`, 0o755); fs.chmodSync(`${targetFolder}/in-proc6/func`, 0o755); } + repairFuncCoreToolsExecutablePermissions(targetFolder); await setFunctionsCommand(); await startAllDesignTimeApis(); } @@ -260,7 +263,8 @@ export async function downloadFileWithVerification( const getFunctionCoreToolVersionFromGithub = async (context: IActionContext, majorVersion: string): Promise => { try { const response: IGitHubReleaseInfo = await readJsonFromUrl( - 'https://api.github.com/repos/Azure/azure-functions-core-tools/releases/latest' + 'https://api.github.com/repos/Azure/azure-functions-core-tools/releases/latest', + { showUserError: false } ); const latestVersion = semver.valid(semver.coerce(response.tag_name)); context.telemetry.properties.latestVersionSource = 'github'; @@ -320,7 +324,7 @@ export async function getLatestDotNetVersion(context: IActionContext, majorVersi context.telemetry.properties.dotNetMajorVersion = majorVersion; if (majorVersion) { - return await readJsonFromUrl('https://api.github.com/repos/dotnet/sdk/releases') + return await readJsonFromUrl('https://api.github.com/repos/dotnet/sdk/releases', { showUserError: false }) .then((response: IGitHubReleaseInfo[]) => { context.telemetry.properties.latestVersionSource = 'github'; let latestVersion: string | null = null; @@ -354,28 +358,37 @@ export async function getLatestNodeJsVersion(context: IActionContext, majorVersi context.telemetry.properties.nodeMajorVersion = majorVersion; if (majorVersion) { - return await readJsonFromUrl('https://api.github.com/repos/nodejs/node/releases') - .then((response: IGitHubReleaseInfo[]) => { - context.telemetry.properties.latestVersionSource = 'github'; - for (const releaseInfo of response) { - const releaseVersion = semver.valid(semver.coerce(releaseInfo.tag_name)); - context.telemetry.properties.latestGithubVersion = releaseInfo.tag_name; - if (releaseVersion && checkMajorVersion(releaseVersion, majorVersion)) { - return releaseVersion; - } - } - context.telemetry.properties.latestNodeJSVersion = 'fallback-no-match'; - context.telemetry.properties.errorLatestNodeJsVersion = 'No matching Node JS version found.'; - return DependencyVersion.nodeJs; - }) - .catch((error) => { - context.telemetry.properties.latestNodeJSVersion = 'fallback'; - context.telemetry.properties.errorLatestNodeJsVersion = `Error getting latest Node JS version: ${error}`; - return DependencyVersion.nodeJs; + try { + const response: IGitHubReleaseInfo[] = await readJsonFromUrl('https://api.github.com/repos/nodejs/node/releases', { + showUserError: false, }); + let latestVersion: string | undefined; + for (const releaseInfo of response) { + const releaseVersion = semver.valid(semver.coerce(releaseInfo.tag_name)); + context.telemetry.properties.latestGithubVersion = releaseInfo.tag_name; + if (releaseVersion && checkMajorVersion(releaseVersion, majorVersion)) { + latestVersion = latestVersion && semver.gt(latestVersion, releaseVersion) ? latestVersion : releaseVersion; + } + } + if (latestVersion) { + context.telemetry.properties.latestVersionSource = 'github'; + context.telemetry.properties.latestNodeJSVersion = latestVersion; + return latestVersion; + } + context.telemetry.properties.latestNodeJSVersion = 'fallback-no-match'; + context.telemetry.properties.latestVersionSource = 'fallback'; + context.telemetry.properties.errorLatestNodeJsVersion = 'No matching Node JS version found.'; + return DependencyVersion.nodeJs; + } catch (error) { + context.telemetry.properties.latestNodeJSVersion = 'fallback'; + context.telemetry.properties.latestVersionSource = 'fallback'; + context.telemetry.properties.errorLatestNodeJsVersion = `Error getting latest Node JS version from GitHub: ${error}`; + return DependencyVersion.nodeJs; + } } context.telemetry.properties.latestNodeJSVersion = 'fallback'; + context.telemetry.properties.latestVersionSource = 'fallback'; return DependencyVersion.nodeJs; } @@ -416,26 +429,58 @@ export async function binariesExist(dependencyName: string): Promise { return false; } + return await binariesExistFromSettings(dependencyName, true); +} + +export function binariesExistSync(dependencyName: string): boolean { + if (!useBinariesDependenciesFromSettings()) { + return false; + } + + return binariesExistFromSettings(dependencyName, false); +} + +function useBinariesDependenciesFromSettings(): boolean { + if (isDevContainerWorkspaceSync()) { + return false; + } + + const binariesInstallation = getGlobalSetting(autoRuntimeDependenciesValidationAndInstallationSetting); + return !!binariesInstallation; +} + +function getExpectedBinaryPath(dependencyName: string): string | undefined { + if (dependencyName === funcDependencyName) { + return getGlobalSetting(funcCoreToolsBinaryPathSettingKey); + } + if (dependencyName === dotnetDependencyName) { + return getGlobalSetting(dotNetBinaryPathSettingKey); + } + if (dependencyName === nodeJsDependencyName) { + return getGlobalSetting(nodeJsBinaryPathSettingKey); + } + return undefined; +} + +async function binariesExistFromSettings(dependencyName: string, updateMissingExeSetting: true): Promise; +function binariesExistFromSettings(dependencyName: string, updateMissingExeSetting: false): boolean; +function binariesExistFromSettings(dependencyName: string, updateMissingExeSetting: boolean): boolean | Promise { const binariesLocation = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); if (!binariesLocation) { return false; } const binariesPath = path.join(binariesLocation, dependencyName); const binariesExist = fs.existsSync(binariesPath); - let expectedBinaryPath: string | undefined; - if (binariesExist) { - if (dependencyName === funcDependencyName) { - expectedBinaryPath = getGlobalSetting(funcCoreToolsBinaryPathSettingKey); - } else if (dependencyName === dotnetDependencyName) { - expectedBinaryPath = getGlobalSetting(dotNetBinaryPathSettingKey); - } else if (dependencyName === nodeJsDependencyName) { - expectedBinaryPath = getGlobalSetting(nodeJsBinaryPathSettingKey); - } - } + const expectedBinaryPath = binariesExist ? getExpectedBinaryPath(dependencyName) : undefined; executeCommand(ext.outputChannel, undefined, 'echo', `${dependencyName} Binaries: ${binariesPath}`); if (expectedBinaryPath && !fs.existsSync(expectedBinaryPath)) { - if (await tryRepairWindowsNodeJsBinaryPath(dependencyName, binariesPath, expectedBinaryPath)) { + const repairedBinaryPath = getRepairableWindowsBinaryPath(dependencyName, binariesPath, expectedBinaryPath); + if (repairedBinaryPath) { + if (updateMissingExeSetting) { + return updateBinaryPathSetting(dependencyName, repairedBinaryPath).then(() => true); + } + return true; } @@ -446,41 +491,62 @@ export async function binariesExist(dependencyName: string): Promise { return binariesExist; } -async function tryRepairWindowsNodeJsBinaryPath( - dependencyName: string, - binariesPath: string, - expectedBinaryPath: string -): Promise { - if (dependencyName !== nodeJsDependencyName || process.platform !== Platform.windows) { - return false; +async function updateBinaryPathSetting(dependencyName: string, binaryPath: string): Promise { + if (dependencyName === funcDependencyName) { + await updateGlobalSetting(funcCoreToolsBinaryPathSettingKey, binaryPath); + } else if (dependencyName === dotnetDependencyName) { + await updateGlobalSetting(dotNetBinaryPathSettingKey, binaryPath); + } else if (dependencyName === nodeJsDependencyName) { + await updateGlobalSetting(nodeJsBinaryPathSettingKey, binaryPath); + } + + executeCommand(ext.outputChannel, undefined, 'echo', `${dependencyName} binary path updated: ${binaryPath}`); +} + +function getRepairableWindowsBinaryPath(dependencyName: string, binariesPath: string, expectedBinaryPath: string): string | undefined { + if (process.platform !== Platform.windows) { + return undefined; + } + + if (!expectedBinaryPath.toLowerCase().endsWith('.exe')) { + const exeVariant = `${expectedBinaryPath}.exe`; + if (fs.existsSync(exeVariant)) { + return exeVariant; + } + } + + if (dependencyName !== nodeJsDependencyName) { + return undefined; } const nodeCommand = DependencyDefaultPath.node; const staleNodePath = path.join(binariesPath, nodeCommand); if (path.normalize(expectedBinaryPath).toLowerCase() !== path.normalize(staleNodePath).toLowerCase()) { - return false; + return undefined; } const nodeExePath = path.join(binariesPath, `${nodeCommand}.exe`); - if (!fs.existsSync(nodeExePath)) { - return false; - } + return fs.existsSync(nodeExePath) ? nodeExePath : undefined; +} - await updateGlobalSetting(nodeJsBinaryPathSettingKey, nodeExePath); - executeCommand(ext.outputChannel, undefined, 'echo', `${nodeJsDependencyName} binary path updated: ${nodeExePath}`); - return true; +interface ReadJsonFromUrlOptions { + showUserError?: boolean; } -async function readJsonFromUrl(url: string): Promise { +async function readJsonFromUrl(url: string, options: ReadJsonFromUrlOptions = {}): Promise { try { const response = await axios.get(url); if (response.status === 200) { return response.data; } + throw new Error(`Request failed with status: ${response.status}`); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - vscode.window.showErrorMessage(`Error reading JSON from URL ${url} : ${errorMessage}`); + if (options.showUserError ?? true) { + const errorMessage = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Error reading JSON from URL ${url} : ${errorMessage}`); + } + throw error; } } @@ -1027,7 +1093,13 @@ async function extractDependency(dependencyFilePath: string, targetFolder: strin * @returns A boolean indicating whether the major version matches. */ function checkMajorVersion(version: string, majorVersion: string): boolean { - return semver.major(version) === Number(majorVersion); + const requestedMajorVersion = getMajorVersion(majorVersion); + return requestedMajorVersion !== undefined && semver.major(version) === requestedMajorVersion; +} + +function getMajorVersion(version: string): number | undefined { + const coercedVersion = semver.coerce(version); + return coercedVersion ? semver.major(coercedVersion) : undefined; } /** diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/common.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/common.test.ts index e61d4504349..68aee7e7f26 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/common.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/common.test.ts @@ -76,6 +76,15 @@ describe('getAzureConnectorDetailsForLocalProject', () => { expect(context.telemetry.properties.azureConnectorsDefaulted).toBe('rawKeys'); }); + it('handles undefined projectPath', async () => { + const details = await getAzureConnectorDetailsForLocalProject(context, undefined as any); + + expect(details).toEqual({ enabled: false }); + expect(getLocalSettingsJson).not.toHaveBeenCalled(); + expect(createAzureWizard).not.toHaveBeenCalled(); + expect(context.telemetry.properties.azureConnectorDetailsProjectPathMissing).toBe('true'); + }); + it('treats explicitly skipped Azure connectors as disabled without requesting auth', async () => { vi.mocked(getLocalSettingsJson).mockResolvedValue({ Values: { [workflowSubscriptionIdKey]: '' } } as any); 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 index a661045e3a9..0e9d2d92c55 100644 --- 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 @@ -163,11 +163,66 @@ describe('startAllDesignTimeApis', () => { vi.mocked(cp.spawn).mockReturnValue({} as any); ext.designTimeInstances.set('D:/workspace/app-one', { process: { pid: 111 } as any, childFuncPid: '222' }); - stopDesignTimeApi('D:/workspace/app-one'); + await stopDesignTimeApi('D:/workspace/app-one'); expect(cp.spawn).toHaveBeenCalledWith('kill', ['-9', '222']); expect(cp.spawn).toHaveBeenCalledWith('kill', ['-9', '111']); }); + + it('waits for Windows taskkill callbacks before resolving stopDesignTimeApi', async () => { + vi.mocked(os.platform).mockReturnValue('win32' as any); + const taskkillCallbacks: Array<() => void> = []; + vi.spyOn(cp, 'exec').mockImplementation(((command: string, callback?: cp.ExecException | any) => { + taskkillCallbacks.push(() => callback?.(null, '', '')); + return {} as cp.ChildProcess; + }) as any); + ext.designTimeInstances.set('D:/workspace/app-one', { process: { pid: 111 } as any, childFuncPid: '222' }); + + let resolved = false; + const stopPromise = stopDesignTimeApi('D:/workspace/app-one').then(() => { + resolved = true; + }); + await Promise.resolve(); + + expect(cp.exec).toHaveBeenCalledWith('taskkill /pid 222 /t /f', expect.any(Function)); + expect(cp.exec).toHaveBeenCalledWith('taskkill /pid 111 /t /f', expect.any(Function)); + expect(resolved).toBe(false); + + taskkillCallbacks[0](); + await Promise.resolve(); + expect(resolved).toBe(false); + + taskkillCallbacks[1](); + await stopPromise; + expect(resolved).toBe(true); + }); + + it('passes string child func pids to taskkill on Windows', async () => { + vi.mocked(os.platform).mockReturnValue('win32' as any); + vi.spyOn(cp, 'exec').mockImplementation(((_command: string, callback?: cp.ExecException | any) => { + callback?.(null, '', ''); + return {} as cp.ChildProcess; + }) as any); + ext.designTimeInstances.set('D:/workspace/app-one', { process: { pid: 111 } as any, childFuncPid: '12345' }); + + await stopDesignTimeApi('D:/workspace/app-one'); + + expect(cp.exec).toHaveBeenCalledWith('taskkill /pid 12345 /t /f', expect.any(Function)); + expect(cp.exec).toHaveBeenCalledWith('taskkill /pid 111 /t /f', expect.any(Function)); + }); + + it('skips taskkill when the tracked pid is undefined on Windows', async () => { + vi.mocked(os.platform).mockReturnValue('win32' as any); + vi.spyOn(cp, 'exec').mockImplementation(((_command: string, callback?: cp.ExecException | any) => { + callback?.(null, '', ''); + return {} as cp.ChildProcess; + }) as any); + ext.designTimeInstances.set('D:/workspace/app-one', { process: {} as any }); + + await expect(stopDesignTimeApi('D:/workspace/app-one')).resolves.toBeUndefined(); + + expect(cp.exec).not.toHaveBeenCalled(); + }); }); describe('startDesignTimeProcess', () => { 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 be73573cc4e..8863d64717f 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/common.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/common.ts @@ -216,6 +216,13 @@ export async function getAzureConnectorDetailsForLocalProject( context: IActionContext, projectPath: string ): Promise { + if (!projectPath) { + context.telemetry.properties.azureConnectorDetailsProjectPathMissing = 'true'; + return { + enabled: false, + }; + } + // Check cache first const cached = azureDetailsCache.get(projectPath); const now = Date.now(); 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 f988396a492..a6a47a24d49 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -436,7 +436,7 @@ async function validateRunningFuncProcess(projectPath: string): Promise { ) ); processValidationCache.delete(projectPath); - stopDesignTimeApi(projectPath); + await stopDesignTimeApi(projectPath); await startDesignTimeApi(projectPath); } @@ -616,8 +616,13 @@ export function startDesignTimeProcess( 'Language worker issue found when launching func most likely due to a conflicting port. Restarting design-time process.' ); - stopDesignTimeApi(projectPath); - scheduleStartDesignTimeApi(projectPath); + stopDesignTimeApi(projectPath) + .catch((error) => { + ext.outputChannel.appendLog(`Failed to stop design-time process before restart. Error: ${error}`); + }) + .finally(() => { + scheduleStartDesignTimeApi(projectPath); + }); } }); stdout?.on('end', () => appendStdout?.flush()); @@ -634,8 +639,13 @@ export function startDesignTimeProcess( if (data.toLowerCase().includes(portUnavailableText.toLowerCase())) { ext.outputChannel.appendLog('Conflicting port found when launching func. Restarting design-time process.'); - stopDesignTimeApi(projectPath); - scheduleStartDesignTimeApi(projectPath); + stopDesignTimeApi(projectPath) + .catch((error) => { + ext.outputChannel.appendLog(`Failed to stop design-time process before restart. Error: ${error}`); + }) + .finally(() => { + scheduleStartDesignTimeApi(projectPath); + }); } }); stderr?.on('end', () => appendStderr?.flush()); @@ -647,35 +657,51 @@ export function startDesignTimeProcess( } } -export function stopAllDesignTimeApis(): void { - for (const projectPath of ext.designTimeInstances.keys()) { - stopDesignTimeApi(projectPath); - } +export async function stopAllDesignTimeApis(): Promise { + await Promise.allSettled([...ext.designTimeInstances.keys()].map((projectPath) => stopDesignTimeApi(projectPath))); } -export function stopDesignTimeApi(projectPath: string): void { +export async function stopDesignTimeApi(projectPath: string): Promise { ext.outputChannel.appendLog(`Stopping Design Time Api for project: ${projectPath}`); const designTimeInst = ext.designTimeInstances.get(projectPath); if (!designTimeInst) { return; } - const { process, childFuncPid } = designTimeInst; + const { process: proc, childFuncPid } = designTimeInst; ext.designTimeInstances.delete(projectPath); - if (process === null || process === undefined) { + if (proc === null || proc === undefined) { return; } if (os.platform() === Platform.windows) { + const killPromises: Promise[] = []; if (childFuncPid) { - cp.exec(`taskkill /pid ${childFuncPid} /t /f`); + killPromises.push(execTaskkill(childFuncPid)); } - cp.exec(`taskkill /pid ${process.pid} /t /f`); + killPromises.push(execTaskkill(proc.pid)); + await Promise.allSettled(killPromises); } else { - killTrackedUnixProcesses(process, childFuncPid); + killTrackedUnixProcesses(proc, childFuncPid); } } +/** + * Runs taskkill and waits for it to complete so file locks are released + * before subsequent build steps. + */ +function execTaskkill(pid: number | string | undefined): Promise { + if (pid === undefined) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + cp.exec(`taskkill /pid ${pid} /t /f`, () => { + resolve(); + }); + }); +} + export function scheduleStartAllDesignTimeApis(): void { ext.outputChannel.appendLog( localize('scheduleAllDesignTimeApis', 'Scheduling background design-time startup for the current workspace.') diff --git a/apps/vs-code-designer/src/app/utils/devContainerUtils.ts b/apps/vs-code-designer/src/app/utils/devContainerUtils.ts index 958c922ee0e..e9d9567c05b 100644 --- a/apps/vs-code-designer/src/app/utils/devContainerUtils.ts +++ b/apps/vs-code-designer/src/app/utils/devContainerUtils.ts @@ -53,3 +53,33 @@ export async function isDevContainerWorkspace(): Promise { return false; } } + +export function isDevContainerWorkspaceSync(): boolean { + try { + const workspaceFile = vscode.workspace.workspaceFile; + if (!workspaceFile) { + return false; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return false; + } + + const workspaceFileContent = fse.readJsonSync(workspaceFile.fsPath); + const folders = workspaceFileContent.folders || []; + const hasDevContainerFolder = folders.some( + (folder: any) => folder.path === devContainerFolderName || folder.path === `./${devContainerFolderName}` + ); + + if (!hasDevContainerFolder) { + return false; + } + + const workspaceFolder = path.dirname(workspaceFile.fsPath); + const devContainerJsonPath = path.join(workspaceFolder, devContainerFolderName, devContainerFileName); + return fse.pathExistsSync(devContainerJsonPath); + } catch { + return false; + } +} diff --git a/apps/vs-code-designer/src/app/utils/funcCoreTools/__test__/funcVersion.test.ts b/apps/vs-code-designer/src/app/utils/funcCoreTools/__test__/funcVersion.test.ts index 4ee29ea8db8..a0707edf6d7 100644 --- a/apps/vs-code-designer/src/app/utils/funcCoreTools/__test__/funcVersion.test.ts +++ b/apps/vs-code-designer/src/app/utils/funcCoreTools/__test__/funcVersion.test.ts @@ -2,11 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('fs', () => ({ existsSync: vi.fn(), chmodSync: vi.fn(), + statSync: vi.fn(() => ({ mode: 0o644 })), + accessSync: vi.fn(), + readdirSync: vi.fn(() => []), + constants: { + X_OK: 1, + }, })); vi.mock('../../../../localize', () => ({ @@ -47,10 +53,13 @@ import { executeCommand } from '../cpUtils'; import { getGlobalSetting, getWorkspaceSettingFromAnyFolder, updateGlobalSetting } from '../../vsCodeConfig/settings'; import { addLocalFuncTelemetry, + areFuncCoreToolsExecutablePermissionsValid, checkSupportedFuncVersion, + ensureFuncCoreToolsCommandExecutablePermissions, getDefaultFuncVersion, getFunctionsCommand, getLocalFuncCoreToolsVersion, + repairFuncCoreToolsExecutablePermissions, setFunctionsCommand, tryGetLocalFuncVersion, tryGetMajorVersion, @@ -61,9 +70,14 @@ const BIN_ROOT = '/usr/local/azurelogicapps/dependencies'; const FUNC_DIR = path.join(BIN_ROOT, 'FuncCoreTools'); const FUNC_EXE = path.join(FUNC_DIR, 'func'); const FUNC_WIN_EXE = path.join(FUNC_DIR, 'func.exe'); -const PREFERRED_FUNC_EXE = process.platform === 'win32' ? FUNC_WIN_EXE : FUNC_EXE; const FUNC_INPROC8_EXE = path.join(FUNC_DIR, 'in-proc8', 'func'); const FUNC_INPROC8_WIN_EXE = path.join(FUNC_DIR, 'in-proc8', 'func.exe'); +const FUNC_INPROC6_EXE = path.join(FUNC_DIR, 'in-proc6', 'func'); +const originalPlatform = process.platform; + +function mockPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} describe('funcVersion - command resolution', () => { beforeEach(() => { @@ -74,6 +88,15 @@ describe('funcVersion - command resolution', () => { vi.mocked(executeCommand).mockReset(); vi.mocked(fs.existsSync).mockReset(); vi.mocked(fs.chmodSync).mockReset(); + vi.mocked(fs.statSync).mockReset(); + vi.mocked(fs.statSync).mockReturnValue({ mode: 0o644 } as fs.Stats); + vi.mocked(fs.accessSync).mockReset(); + vi.mocked(fs.readdirSync).mockReset(); + vi.mocked(fs.readdirSync).mockReturnValue([]); + }); + + afterEach(() => { + mockPlatform(originalPlatform); }); describe('getFunctionsCommand', () => { @@ -86,6 +109,7 @@ describe('funcVersion - command resolution', () => { }); it('self-heals by inspecting the local binaries folder when the setting is empty', () => { + mockPlatform('linux'); vi.mocked(getGlobalSetting).mockImplementation((key: string) => { if (key === 'funcCoreToolsBinaryPath') { return undefined; @@ -98,9 +122,11 @@ describe('funcVersion - command resolution', () => { vi.mocked(fs.existsSync).mockImplementation((p: any) => p === FUNC_DIR || p === FUNC_EXE); expect(getFunctionsCommand()).toBe(FUNC_EXE); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_EXE, 0o755); }); it('self-heals to the in-proc8 binary when the top-level func binary is missing', () => { + mockPlatform('linux'); vi.mocked(getGlobalSetting).mockImplementation((key: string) => { if (key === 'funcCoreToolsBinaryPath') { return undefined; @@ -113,9 +139,11 @@ describe('funcVersion - command resolution', () => { vi.mocked(fs.existsSync).mockImplementation((p: any) => p === FUNC_DIR || p === FUNC_INPROC8_EXE); expect(getFunctionsCommand()).toBe(FUNC_INPROC8_EXE); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o755); }); it('self-heals to func.exe when that is the extracted binary name', () => { + mockPlatform('win32'); vi.mocked(getGlobalSetting).mockImplementation((key: string) => { if (key === 'funcCoreToolsBinaryPath') { return undefined; @@ -128,6 +156,7 @@ describe('funcVersion - command resolution', () => { vi.mocked(fs.existsSync).mockImplementation((p: any) => p === FUNC_DIR || p === FUNC_WIN_EXE); expect(getFunctionsCommand()).toBe(FUNC_WIN_EXE); + expect(fs.chmodSync).not.toHaveBeenCalled(); }); it('throws when the setting is empty and the local binaries are not yet on disk', () => { @@ -174,50 +203,126 @@ describe('funcVersion - command resolution', () => { expect(fs.chmodSync).not.toHaveBeenCalled(); }); - it('writes the preferred func path and chmods only the directory when the executable is missing', async () => { + it('writes the preferred func path without chmoding when the executable is missing', async () => { + mockPlatform('linux'); vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT as any); vi.mocked(fs.existsSync).mockImplementation((p) => p === FUNC_DIR); await setFunctionsCommand(); - expect(updateGlobalSetting).toHaveBeenCalledWith('funcCoreToolsBinaryPath', PREFERRED_FUNC_EXE); - expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_DIR, 0o777); - expect(fs.chmodSync).not.toHaveBeenCalledWith(PREFERRED_FUNC_EXE, 0o777); + expect(updateGlobalSetting).toHaveBeenCalledWith('funcCoreToolsBinaryPath', FUNC_EXE); + expect(fs.chmodSync).not.toHaveBeenCalled(); }); - it('writes the preferred func path and chmods both the directory and the executable when both exist', async () => { + it('writes the preferred func path and chmods the executable when both exist', async () => { + mockPlatform('linux'); vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT as any); vi.mocked(fs.existsSync).mockReturnValue(true); await setFunctionsCommand(); - expect(updateGlobalSetting).toHaveBeenCalledWith('funcCoreToolsBinaryPath', PREFERRED_FUNC_EXE); + expect(updateGlobalSetting).toHaveBeenCalledWith('funcCoreToolsBinaryPath', FUNC_EXE); expect(fs.existsSync).toHaveBeenCalledWith(FUNC_DIR); - expect(fs.existsSync).toHaveBeenCalledWith(PREFERRED_FUNC_EXE); - expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_DIR, 0o777); - expect(fs.chmodSync).toHaveBeenCalledWith(PREFERRED_FUNC_EXE, 0o777); + expect(fs.existsSync).toHaveBeenCalledWith(FUNC_EXE); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_EXE, 0o755); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o755); + expect(fs.chmodSync).not.toHaveBeenCalledWith(FUNC_DIR, 0o755); }); it('writes the in-proc8 func path when the top-level executable is missing', async () => { + mockPlatform('linux'); vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT as any); vi.mocked(fs.existsSync).mockImplementation((p) => p === FUNC_DIR || p === FUNC_INPROC8_EXE); await setFunctionsCommand(); expect(updateGlobalSetting).toHaveBeenCalledWith('funcCoreToolsBinaryPath', FUNC_INPROC8_EXE); - expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_DIR, 0o777); - expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o777); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o755); + expect(fs.chmodSync).not.toHaveBeenCalledWith(FUNC_DIR, 0o755); }); it('writes the in-proc8 func.exe path when that is the extracted binary name', async () => { + mockPlatform('win32'); vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT as any); vi.mocked(fs.existsSync).mockImplementation((p) => p === FUNC_DIR || p === FUNC_INPROC8_WIN_EXE); await setFunctionsCommand(); expect(updateGlobalSetting).toHaveBeenCalledWith('funcCoreToolsBinaryPath', FUNC_INPROC8_WIN_EXE); - expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_DIR, 0o777); - expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_WIN_EXE, 0o777); + expect(fs.chmodSync).not.toHaveBeenCalled(); + }); + }); + + describe('repairFuncCoreToolsExecutablePermissions', () => { + it('repairs top-level and nested FuncCoreTools executables independently', () => { + mockPlatform('linux'); + vi.mocked(fs.existsSync).mockImplementation((p) => [FUNC_DIR, FUNC_EXE, FUNC_INPROC8_EXE, FUNC_INPROC6_EXE].includes(p as string)); + + repairFuncCoreToolsExecutablePermissions(FUNC_DIR); + + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_EXE, 0o755); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o755); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC6_EXE, 0o755); + expect(fs.chmodSync).not.toHaveBeenCalledWith(FUNC_DIR, 0o755); + }); + + it('repairs nested in-proc8 when optional candidates are missing', () => { + mockPlatform('linux'); + vi.mocked(fs.existsSync).mockImplementation((p) => [FUNC_DIR, FUNC_EXE, FUNC_INPROC8_EXE].includes(p as string)); + + repairFuncCoreToolsExecutablePermissions(FUNC_DIR); + + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_EXE, 0o755); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o755); + }); + + it('continues repairing later candidates after one chmod failure', () => { + mockPlatform('linux'); + vi.mocked(fs.existsSync).mockImplementation((p) => [FUNC_DIR, FUNC_EXE, FUNC_INPROC8_EXE].includes(p as string)); + vi.mocked(fs.chmodSync).mockImplementation((p) => { + if (p === FUNC_EXE) { + throw new Error('permission denied'); + } + }); + + repairFuncCoreToolsExecutablePermissions(FUNC_DIR); + + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_EXE, 0o755); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o755); + }); + + it('does not chmod on Windows', () => { + mockPlatform('win32'); + vi.mocked(fs.existsSync).mockReturnValue(true); + + repairFuncCoreToolsExecutablePermissions(FUNC_DIR); + + expect(fs.chmodSync).not.toHaveBeenCalled(); + }); + + it('returns false when a managed nested executable remains non-executable after repair', () => { + mockPlatform('linux'); + vi.mocked(getGlobalSetting).mockImplementation((key: string) => + key === 'autoRuntimeDependenciesPath' ? (BIN_ROOT as any) : undefined + ); + vi.mocked(fs.existsSync).mockImplementation((p) => [FUNC_DIR, FUNC_EXE, FUNC_INPROC8_EXE].includes(p as string)); + vi.mocked(fs.accessSync).mockImplementation((p) => { + if (p === FUNC_INPROC8_EXE) { + throw new Error('not executable'); + } + }); + + expect(ensureFuncCoreToolsCommandExecutablePermissions(FUNC_EXE)).toBe(false); + expect(fs.chmodSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, 0o755); + }); + + it('returns true for managed FuncCoreTools when existing candidates are executable', () => { + mockPlatform('linux'); + vi.mocked(fs.existsSync).mockImplementation((p) => [FUNC_DIR, FUNC_EXE, FUNC_INPROC8_EXE].includes(p as string)); + + expect(areFuncCoreToolsExecutablePermissionsValid(FUNC_DIR, FUNC_EXE)).toBe(true); + expect(fs.accessSync).toHaveBeenCalledWith(FUNC_EXE, fs.constants.X_OK); + expect(fs.accessSync).toHaveBeenCalledWith(FUNC_INPROC8_EXE, fs.constants.X_OK); }); }); }); diff --git a/apps/vs-code-designer/src/app/utils/funcCoreTools/funcHostTask.ts b/apps/vs-code-designer/src/app/utils/funcCoreTools/funcHostTask.ts index 0e1df49bfd7..3132465bfc7 100644 --- a/apps/vs-code-designer/src/app/utils/funcCoreTools/funcHostTask.ts +++ b/apps/vs-code-designer/src/app/utils/funcCoreTools/funcHostTask.ts @@ -131,7 +131,7 @@ export async function getFuncPortFromTaskOrProject( if (isString(projectPathOrTaskScope)) { projectPath = projectPathOrTaskScope; } else if (typeof projectPathOrTaskScope === 'object') { - projectPath = await tryGetLogicAppProjectRoot(context, projectPathOrTaskScope); + projectPath = await tryGetLogicAppProjectRoot(context, projectPathOrTaskScope, true); } if (projectPath) { diff --git a/apps/vs-code-designer/src/app/utils/funcCoreTools/funcVersion.ts b/apps/vs-code-designer/src/app/utils/funcCoreTools/funcVersion.ts index 0dc0872f6fb..f473401afac 100644 --- a/apps/vs-code-designer/src/app/utils/funcCoreTools/funcVersion.ts +++ b/apps/vs-code-designer/src/app/utils/funcCoreTools/funcVersion.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as semver from 'semver'; -function getFuncCoreToolsCandidatePaths(funcBinariesPath: string): string[] { +export function getFuncCoreToolsCandidatePaths(funcBinariesPath: string): string[] { const executableNames = process.platform === 'win32' && !ext.funcCliPath.toLowerCase().endsWith('.exe') ? [`${ext.funcCliPath}.exe`, ext.funcCliPath] @@ -35,6 +35,110 @@ function resolveFuncCoreToolsCommand(funcBinariesPath: string): string { return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; } +function getManagedFuncCoreToolsPath(command?: string): string | undefined { + const binariesLocation = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); + const funcBinariesPath = binariesLocation ? path.join(binariesLocation, funcDependencyName) : undefined; + if (!funcBinariesPath) { + return undefined; + } + + if (!command) { + return fs.existsSync(funcBinariesPath) ? funcBinariesPath : undefined; + } + + const relativePath = path.relative(funcBinariesPath, command); + const isManagedCommand = relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); + return isManagedCommand ? funcBinariesPath : undefined; +} + +function getFuncCoreToolsExecutableRepairCandidates(funcBinariesPath: string): string[] { + const executableNames = process.platform === 'win32' ? ['func.exe', 'func'] : ['func']; + const knownCandidates = ['', 'in-proc8', 'in-proc6'].flatMap((subdir) => + executableNames.map((executableName) => path.join(funcBinariesPath, subdir, executableName)) + ); + const discoveredCandidates: string[] = []; + const directoriesToScan = [funcBinariesPath]; + + for (const directory of directoriesToScan) { + if (!fs.existsSync(directory)) { + continue; + } + + try { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + directoriesToScan.push(entryPath); + } else if (executableNames.includes(entry.name)) { + discoveredCandidates.push(entryPath); + } + } + } catch (error) { + ext.outputChannel.appendLog(`Unable to inspect FuncCoreTools directory ${directory}: ${error}`); + } + } + + return [...new Set([...knownCandidates, ...discoveredCandidates])]; +} + +function addExecutePermission(filePath: string): void { + const stats = fs.statSync(filePath); + fs.chmodSync(filePath, stats.mode | 0o111); +} + +function isExecutable(filePath: string): boolean { + if (process.platform === 'win32') { + return true; + } + + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +export function repairFuncCoreToolsExecutablePermissions(funcBinariesPath: string): void { + if (process.platform === 'win32' || !fs.existsSync(funcBinariesPath)) { + return; + } + + const pathsToRepair = getFuncCoreToolsExecutableRepairCandidates(funcBinariesPath); + for (const candidate of pathsToRepair) { + if (!fs.existsSync(candidate)) { + continue; + } + + try { + addExecutePermission(candidate); + } catch (error) { + ext.outputChannel.appendLog(`Unable to set execute permission on FuncCoreTools file ${candidate}: ${error}`); + } + } +} + +export function areFuncCoreToolsExecutablePermissionsValid(funcBinariesPath: string, selectedCommand?: string): boolean { + if (process.platform === 'win32') { + return true; + } + + const candidates = selectedCommand + ? [selectedCommand, ...getFuncCoreToolsExecutableRepairCandidates(funcBinariesPath)] + : getFuncCoreToolsExecutableRepairCandidates(funcBinariesPath); + return [...new Set(candidates)].every((candidate) => !fs.existsSync(candidate) || isExecutable(candidate)); +} + +export function ensureFuncCoreToolsCommandExecutablePermissions(command: string): boolean { + const managedFuncBinariesPath = getManagedFuncCoreToolsPath(command); + if (!managedFuncBinariesPath) { + return true; + } + + repairFuncCoreToolsExecutablePermissions(managedFuncBinariesPath); + return areFuncCoreToolsExecutablePermissionsValid(managedFuncBinariesPath, command); +} + /** * Parses functions core tools version. * @param {string | undefined} data - Functions core tools package version. @@ -186,6 +290,8 @@ export function getFunctionsCommand(): string { if (!command) { throw Error('Functions Core Tools Binary Path Setting is empty'); } + + ensureFuncCoreToolsCommandExecutablePermissions(command); return command; } @@ -196,13 +302,8 @@ export async function setFunctionsCommand(): Promise { const funcBinariesPath = path.join(binariesLocation, funcDependencyName); const binariesExist = fs.existsSync(funcBinariesPath); if (binariesExist) { + repairFuncCoreToolsExecutablePermissions(funcBinariesPath); command = resolveFuncCoreToolsCommand(funcBinariesPath); - fs.chmodSync(funcBinariesPath, 0o777); - - const funcExist = fs.existsSync(command); - if (funcExist) { - fs.chmodSync(command, 0o777); - } } } diff --git a/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts b/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts index 7907c65324a..acb8fd704d1 100644 --- a/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts +++ b/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts @@ -10,6 +10,9 @@ import { createHash } from 'crypto'; const lspServerDirectoryName = 'LSPServer'; const lspServerHashMarkerName = '.lspserver-hash'; const lspSdkHashMarkerName = '.lspsdk-hash'; +const lockedFileErrorCodes = new Set(['EBUSY', 'EPERM']); +const lockedFileRetryDelayMs = 2000; +const lockedFileRetryAttempts = 3; export async function installLSPSDK(): Promise { await callWithTelemetryAndErrorHandling('azureLogicAppsStandard.installLSPSDK', async () => { @@ -39,11 +42,11 @@ export async function installLSPSDK(): Promise { if (shouldExtract) { try { if (await fse.pathExists(lspServerPath)) { - await fse.remove(lspServerPath); + await removeWithRetry(lspServerPath); } const zip = new AdmZip(serverZipFile); - await zip.extractAllTo(targetDirectory, /* overwrite */ true, /* Permissions */ true); + await runWithLockedFileRetry(() => zip.extractAllTo(targetDirectory, /* overwrite */ true, /* Permissions */ true)); if (!(await fse.pathExists(lspServerDllPath))) { throw new Error(`Extracted LSP server is missing ${lspServerDllPath}`); @@ -147,13 +150,46 @@ async function stopLanguageClientForUpdate(): Promise { } } -function formatLockedFileError(error: unknown): string { +async function removeWithRetry(targetPath: string): Promise { + await runWithLockedFileRetry(() => fse.remove(targetPath)); +} + +async function runWithLockedFileRetry(operation: () => Promise | void): Promise { + for (let attempt = 1; attempt <= lockedFileRetryAttempts; attempt++) { + try { + await operation(); + return; + } catch (error) { + if (!isLockedFileError(error) || attempt === lockedFileRetryAttempts) { + throw error; + } + + await sleep(lockedFileRetryDelayMs); + } + } +} + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function isLockedFileError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); const code = error instanceof Error && 'code' in error ? String(error.code) : ''; - const isLockedFileError = - code === 'EBUSY' || message.includes('EBUSY') || message.includes('resource busy') || message.includes('locked'); - if (!isLockedFileError) { + return ( + lockedFileErrorCodes.has(code) || + message.includes('EBUSY') || + message.includes('EPERM') || + message.includes('resource busy') || + message.includes('locked') + ); +} + +export function formatLockedFileError(error: unknown): string { + const lockedFileError = isLockedFileError(error); + + if (!lockedFileError) { return String(error); } diff --git a/apps/vs-code-designer/src/app/utils/nodeJs/__test__/nodeJsVersion.test.ts b/apps/vs-code-designer/src/app/utils/nodeJs/__test__/nodeJsVersion.test.ts index 7077c9f5ae8..f51f4b572f0 100644 --- a/apps/vs-code-designer/src/app/utils/nodeJs/__test__/nodeJsVersion.test.ts +++ b/apps/vs-code-designer/src/app/utils/nodeJs/__test__/nodeJsVersion.test.ts @@ -37,7 +37,8 @@ vi.mock('../../../../constants', async () => { import * as fs from 'fs'; import * as path from 'path'; -import { getNpmCommand, getNodeJsCommand, setNodeJsCommand } from '../nodeJsVersion'; +import { ext } from '../../../../extensionVariables'; +import { getNpmCommand, getNodeJsCommand, resolveNodeJsCommand, setNodeJsCommand } from '../nodeJsVersion'; import { getGlobalSetting, updateGlobalSetting } from '../../vsCodeConfig/settings'; const BIN_ROOT = '/usr/local/azurelogicapps/dependencies'; @@ -53,6 +54,7 @@ describe('nodeJsVersion - cross-platform binary path resolution', () => { beforeEach(() => { vi.clearAllMocks(); + ext.nodeJsCliPath = 'node'; }); afterEach(() => { @@ -161,10 +163,14 @@ describe('nodeJsVersion - cross-platform binary path resolution', () => { expect(fs.chmodSync).not.toHaveBeenCalled(); }); - it('writes the root-level node path on Windows and does not chmod the binaries directory', async () => { + it('writes the root-level node.exe path on Windows when the .exe binary exists', async () => { setPlatform(Platform.windows as NodeJS.Platform); vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p); + // NodeJs directory exists, and node.exe exists + return s === NODE_DIR || s === path.join(NODE_DIR, 'node.exe'); + }); await setNodeJsCommand(); @@ -173,6 +179,35 @@ describe('nodeJsVersion - cross-platform binary path resolution', () => { expect(fs.readdirSync).not.toHaveBeenCalled(); }); + it('writes the root-level node path on Windows when only the extensionless binary exists', async () => { + setPlatform(Platform.windows as NodeJS.Platform); + vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p); + // NodeJs directory exists, node (no .exe) exists, but node.exe does not + return s === NODE_DIR || s === path.join(NODE_DIR, 'node'); + }); + + await setNodeJsCommand(); + + expect(updateGlobalSetting).toHaveBeenCalledWith('nodeJsBinaryPath', path.join(NODE_DIR, 'node.exe')); + expect(fs.chmodSync).not.toHaveBeenCalled(); + }); + + it('writes node.exe as first candidate on Windows when neither variant exists yet', async () => { + setPlatform(Platform.windows as NodeJS.Platform); + vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT); + vi.mocked(fs.existsSync).mockImplementation((p) => { + // Only the NodeJs directory exists, no executables found + return String(p) === NODE_DIR; + }); + + await setNodeJsCommand(); + + // Falls back to first candidate (node.exe) when none exist on disk + expect(updateGlobalSetting).toHaveBeenCalledWith('nodeJsBinaryPath', path.join(NODE_DIR, 'node.exe')); + }); + it('writes the /bin/node path on Linux, reads the binaries directory, and chmods it', async () => { setPlatform(Platform.linux as NodeJS.Platform); vi.mocked(getGlobalSetting).mockReturnValue(BIN_ROOT); @@ -217,6 +252,32 @@ describe('nodeJsVersion - cross-platform binary path resolution', () => { }); }); + describe('resolveNodeJsCommand', () => { + it('appends .exe when nodeJsCliPath has no extension', () => { + ext.nodeJsCliPath = 'node'; + + expect(resolveNodeJsCommand('/deps/NodeJs')).toBe(path.join('/deps/NodeJs', 'node.exe')); + }); + + it('does not double .exe when nodeJsCliPath already ends with .exe', () => { + ext.nodeJsCliPath = 'node.exe'; + + expect(resolveNodeJsCommand('/deps/NodeJs')).toBe(path.join('/deps/NodeJs', 'node.exe')); + }); + + it('handles uppercase .EXE extension without doubling', () => { + ext.nodeJsCliPath = 'node.EXE'; + + expect(resolveNodeJsCommand('/deps/NodeJs')).toBe(path.join('/deps/NodeJs', 'node.EXE')); + }); + + it('handles mixed case .Exe extension without doubling', () => { + ext.nodeJsCliPath = 'node.Exe'; + + expect(resolveNodeJsCommand('/deps/NodeJs')).toBe(path.join('/deps/NodeJs', 'node.Exe')); + }); + }); + describe('getNodeJsCommand', () => { it('returns the workspace setting when set', () => { vi.mocked(getGlobalSetting).mockReturnValue('/custom/path/to/node'); diff --git a/apps/vs-code-designer/src/app/utils/nodeJs/nodeJsVersion.ts b/apps/vs-code-designer/src/app/utils/nodeJs/nodeJsVersion.ts index e40a33189fe..08ae50c7dde 100644 --- a/apps/vs-code-designer/src/app/utils/nodeJs/nodeJsVersion.ts +++ b/apps/vs-code-designer/src/app/utils/nodeJs/nodeJsVersion.ts @@ -72,7 +72,7 @@ export async function setNodeJsCommand(): Promise { if (binariesExist) { // windows the executable is at root folder, linux & macos its in /bin if (process.platform === Platform.windows) { - command = path.join(nodeJsBinariesPath, `${ext.nodeJsCliPath}.exe`); + command = resolveNodeJsCommand(nodeJsBinariesPath); } else { const nodeSubFolder = getNodeSubFolder(nodeJsBinariesPath); if (nodeSubFolder) { @@ -85,6 +85,14 @@ export async function setNodeJsCommand(): Promise { await updateGlobalSetting(nodeJsBinaryPathSettingKey, command); } +/** + * Resolves the preferred Node.js command on Windows while normalizing the .exe suffix. + */ +export function resolveNodeJsCommand(nodeJsBinariesPath: string): string { + const executableName = ext.nodeJsCliPath.toLowerCase().endsWith('.exe') ? ext.nodeJsCliPath : `${ext.nodeJsCliPath}.exe`; + return path.join(nodeJsBinariesPath, executableName); +} + function getNodeSubFolder(directoryPath: string): string | undefined { try { const items = fs.readdirSync(directoryPath); diff --git a/apps/vs-code-designer/src/app/utils/vsCodeConfig/__test__/verifyVSCodeConfigOnActivate.test.ts b/apps/vs-code-designer/src/app/utils/vsCodeConfig/__test__/verifyVSCodeConfigOnActivate.test.ts new file mode 100644 index 00000000000..c8d9ca79f08 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/vsCodeConfig/__test__/verifyVSCodeConfigOnActivate.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import type { WorkspaceFolder, Uri } from 'vscode'; + +vi.mock('../../verifyIsProject', () => ({ + tryGetLogicAppProjectRoot: vi.fn(), +})); + +vi.mock('../settings', () => ({ + getWorkspaceSetting: vi.fn(), + updateGlobalSetting: vi.fn(), +})); + +vi.mock('../../funcCoreTools/funcVersion', () => ({ + tryParseFuncVersion: vi.fn(), +})); + +vi.mock('../verifyTargetFramework', () => ({ + verifyTargetFramework: vi.fn(), +})); + +vi.mock('../../commands/initProjectForVSCode/initProjectForVSCode', () => ({ + initProjectForVSCode: vi.fn(), +})); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + isEmptyString: vi.fn((s: string) => s === '' || s === undefined || s === null), + isNullOrUndefined: vi.fn((v: unknown) => v === null || v === undefined), +})); + +vi.mock('@microsoft/vscode-extension-logic-apps', () => ({ + ProjectLanguage: { CSharp: 'C#', JavaScript: 'JavaScript', CustomCode: 'CustomCode' }, +})); + +import { verifyVSCodeConfigOnActivate } from '../verifyVSCodeConfigOnActivate'; +import { tryGetLogicAppProjectRoot } from '../../verifyIsProject'; +import { getWorkspaceSetting } from '../settings'; +import { tryParseFuncVersion } from '../../funcCoreTools/funcVersion'; +import { ext } from '../../../../extensionVariables'; + +describe('verifyVSCodeConfigOnActivate', () => { + let mockContext: IActionContext; + + beforeEach(() => { + vi.clearAllMocks(); + (ext as any).defaultLogicAppPath = ''; + mockContext = { + telemetry: { properties: {}, suppressIfSuccessful: false }, + errorHandling: { suppressDisplay: false }, + ui: { showWarningMessage: vi.fn() }, + } as unknown as IActionContext; + }); + + function createWorkspaceFolder(fsPath: string): WorkspaceFolder { + return { + uri: { fsPath } as Uri, + name: fsPath.split('/').pop() || '', + index: 0, + }; + } + + it('should do nothing when folders is undefined', async () => { + await verifyVSCodeConfigOnActivate(mockContext, undefined); + expect(tryGetLogicAppProjectRoot).not.toHaveBeenCalled(); + }); + + it('should do nothing when folders is empty', async () => { + await verifyVSCodeConfigOnActivate(mockContext, []); + expect(tryGetLogicAppProjectRoot).not.toHaveBeenCalled(); + }); + + it('should call tryGetLogicAppProjectRoot with suppressPrompt=true', async () => { + const folder = createWorkspaceFolder('/workspace/myapp'); + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue(undefined); + + await verifyVSCodeConfigOnActivate(mockContext, [folder]); + + expect(tryGetLogicAppProjectRoot).toHaveBeenCalledWith(mockContext, folder, true); + }); + + it('should set ext.defaultLogicAppPath when project is found', async () => { + const folder = createWorkspaceFolder('/workspace/myapp'); + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue('/workspace/myapp/logicapp'); + vi.mocked(getWorkspaceSetting).mockReturnValue(''); + vi.mocked(tryParseFuncVersion).mockReturnValue(undefined); + + await verifyVSCodeConfigOnActivate(mockContext, [folder]); + + expect(ext.defaultLogicAppPath).toBe('/workspace/myapp/logicapp'); + }); + + it('should set telemetry properties on activation', async () => { + const folder = createWorkspaceFolder('/workspace/myapp'); + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue(undefined); + + await verifyVSCodeConfigOnActivate(mockContext, [folder]); + + expect(mockContext.telemetry.suppressIfSuccessful).toBe(true); + expect(mockContext.telemetry.properties.isActivationEvent).toBe('true'); + expect(mockContext.errorHandling.suppressDisplay).toBe(true); + }); + + it('should iterate over all workspace folders', async () => { + const folders = [createWorkspaceFolder('/workspace/app1'), createWorkspaceFolder('/workspace/app2')]; + vi.mocked(tryGetLogicAppProjectRoot).mockResolvedValue(undefined); + + await verifyVSCodeConfigOnActivate(mockContext, folders); + + expect(tryGetLogicAppProjectRoot).toHaveBeenCalledTimes(2); + expect(tryGetLogicAppProjectRoot).toHaveBeenCalledWith(mockContext, folders[0], true); + expect(tryGetLogicAppProjectRoot).toHaveBeenCalledWith(mockContext, folders[1], true); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts b/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts index 6a5b90e1dde..ba285bb9156 100644 --- a/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts +++ b/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts @@ -93,7 +93,7 @@ export async function validateTasksJson(context: IActionContext, folders: readon try { if (folders) { for (const folder of folders) { - const projectPath: string | undefined = await tryGetLogicAppProjectRoot(context, folder); + const projectPath: string | undefined = await tryGetLogicAppProjectRoot(context, folder, true); context.telemetry.properties.projectPath = projectPath; if (projectPath) { const tasksJsonPath: string = path.join(projectPath, vscodeFolderName, tasksFileName); diff --git a/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyVSCodeConfigOnActivate.ts b/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyVSCodeConfigOnActivate.ts index 11c24174665..214f1c5f4ba 100644 --- a/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyVSCodeConfigOnActivate.ts +++ b/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyVSCodeConfigOnActivate.ts @@ -29,7 +29,7 @@ export async function verifyVSCodeConfigOnActivate( if (folders) { for (const folder of folders) { const workspacePath: string = folder.uri.fsPath; - const projectPath: string | undefined = await tryGetLogicAppProjectRoot(context, folder); + const projectPath: string | undefined = await tryGetLogicAppProjectRoot(context, folder, true); if (projectPath) { ext.defaultLogicAppPath = projectPath; diff --git a/apps/vs-code-designer/src/app/utils/workspace.ts b/apps/vs-code-designer/src/app/utils/workspace.ts index 1690a559b26..8e84b38dd28 100644 --- a/apps/vs-code-designer/src/app/utils/workspace.ts +++ b/apps/vs-code-designer/src/app/utils/workspace.ts @@ -31,7 +31,7 @@ import { tryGetLogicAppCustomCodeFunctionsProjects } from './customCodeUtils'; */ export const hasLogicAppProject = async (actionContext: IActionContext): Promise => { for (const folder of vscode.workspace.workspaceFolders) { - const projectRoot = await tryGetLogicAppProjectRoot(actionContext, folder); + const projectRoot = await tryGetLogicAppProjectRoot(actionContext, folder, true); if (projectRoot) { return true; } @@ -242,12 +242,11 @@ async function getLogicAppWorkspaceFolder( ): Promise { const logicAppProjectRoots: string[] = []; for (const folder of subFolders ?? vscode.workspace.workspaceFolders) { - const projectRoot = await tryGetLogicAppProjectRoot(context, folder); + const projectRoot = await tryGetLogicAppProjectRoot(context, folder, true); if (projectRoot) { logicAppProjectRoots.push(projectRoot); } } - if (logicAppProjectRoots.length === 0) { return undefined; } @@ -313,7 +312,7 @@ async function selectLogicAppWorkspaceFolderWithoutCustomCode( ): Promise { const logicAppsWorkspaces = []; for (const folder of returnsWorkspaceFolder ? vscode.workspace.workspaceFolders : subFolders) { - const projectRoot = await tryGetLogicAppProjectRoot(context, folder); + const projectRoot = await tryGetLogicAppProjectRoot(context, folder, true); if (projectRoot) { logicAppsWorkspaces.push(projectRoot); } @@ -379,7 +378,7 @@ export async function getLogicAppWorkspaceFolderWithoutCustomCode( ): Promise { const logicAppsWorkspaces = []; for (const folder of returnsWorkspaceFolder ? vscode.workspace.workspaceFolders : subFolders) { - const projectRoot = await tryGetLogicAppProjectRoot(context, folder); + const projectRoot = await tryGetLogicAppProjectRoot(context, folder, true); if (projectRoot) { logicAppsWorkspaces.push(projectRoot); } diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index e1e40e78323..13d30df6230 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -218,7 +218,7 @@ export async function activate(context: vscode.ExtensionContext) { } export async function deactivate(): Promise { - stopAllDesignTimeApis(); + await stopAllDesignTimeApis(); ext.unitTestController?.dispose(); try { await ext.languageClient?.stop();