From 6771a130b850b1ee4d733af763cf211aaddde2e4 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 17 Aug 2026 00:29:48 -0400 Subject: [PATCH 01/23] fix UserCanceledError logged as error in telemetry, clean up create unit test commands --- .../commands/createLogicApp/createLogicApp.ts | 16 +- .../app/commands/parameterizeConnections.ts | 5 +- .../unitTest/__test__/createUnitTest.test.ts | 13 +- .../workflows/unitTest/createUnitTest.ts | 388 +++++++--------- .../unitTest/createUnitTestFromRun.ts | 436 +++++++----------- .../utils/unitTest/__test__/unitTest.test.ts | 64 --- .../src/app/utils/unitTest/unitTest.ts | 59 +-- 7 files changed, 354 insertions(+), 627 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts b/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts index f2ee57aa82c..a32f62b9b8d 100644 --- a/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts +++ b/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts @@ -32,16 +32,12 @@ export async function createLogicApp( context.newResourceGroupName = newResourceGroupName; - try { - const logicAppNode: SlotTreeItem = await SubscriptionTreeItem.createChild( - context as ICreateLogicAppContext, - node as SubscriptionTreeItem - ); - await notifyCreateLogicAppComplete(logicAppNode); - return logicAppNode; - } catch (error) { - throw new Error(`Error in creating logic app. ${error}`); - } + const logicAppNode: SlotTreeItem = await SubscriptionTreeItem.createChild( + context as ICreateLogicAppContext, + node as SubscriptionTreeItem + ); + await notifyCreateLogicAppComplete(logicAppNode); + return logicAppNode; } export async function createLogicAppAdvanced( diff --git a/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts b/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts index 2ecb02d2104..04773ffbeab 100644 --- a/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts +++ b/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts @@ -10,7 +10,7 @@ import { getConnectionsJson, saveConnectionReferences } from '../utils/codeless/ import { getParametersJson, saveWorkflowParameter } from '../utils/codeless/parameter'; import { areAllConnectionsParameterized, parameterizeConnection } from '../utils/codeless/parameterizer'; import { getWorkspaceLogicAppRoots } from '../utils/workspace'; -import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { UserCancelledError, type IActionContext } from '@microsoft/vscode-azext-utils'; import { workspace } from 'vscode'; import type { ConnectionsData } from '@microsoft/vscode-extension-logic-apps'; @@ -30,6 +30,9 @@ export async function parameterizeAllConnections(context: IActionContext): Promi try { await parameterizeProjectConnectionsInternal(context, projectPath); } catch (error) { + if (error instanceof UserCancelledError) { + throw error; + } failedProjectPaths.push(projectPath); errorMessages.push(error instanceof Error ? error.message : String(error)); } diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/createUnitTest.test.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/createUnitTest.test.ts index 64f3ce2813d..e8c231e8b80 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/createUnitTest.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/__test__/createUnitTest.test.ts @@ -94,7 +94,10 @@ describe('createUnitTest', () => { }); vi.spyOn(unitTestUtils, 'createTestCsFile').mockResolvedValue(); + vi.spyOn(unitTestUtils, 'createTestSettingsConfigFile').mockResolvedValue(); + vi.spyOn(unitTestUtils, 'createTestExecutorFile').mockResolvedValue(); vi.spyOn(unitTestUtils, 'ensureCsproj').mockResolvedValue(); + vi.spyOn(unitTestUtils, 'updateCsprojFile').mockResolvedValue(true); vi.spyOn(workspaceUtils, 'ensureDirectoryInWorkspace').mockResolvedValue(); vi.spyOn(ext.outputChannel, 'appendLog').mockImplementation(() => {}); @@ -110,14 +113,13 @@ describe('createUnitTest', () => { test('should successfully create a unit test', async () => { await createUnitTest(dummyContext, dummyNode, dummyUnitTestDefinition); - expect(dummyContext.telemetry.properties.unitTestSaveStatus).toBe('Success'); expect(unitTestUtils.promptForUnitTestName).toHaveBeenCalledTimes(1); expect(fs.ensureDir).toHaveBeenCalled(); expect(updateSolutionWithProjectSpy).toHaveBeenCalledOnce(); expect(updateSolutionWithProjectSpy).not.toThrowError(); - expect(dummyContext.telemetry.properties.result).toBe('Succeeded'); expect(dummyContext.telemetry.properties.lastStep).toBe('syncCloudSettings'); + expect(dummyContext.telemetry.properties.unitTestGenerationStatus).toBe('Success'); }); test('should not continue if not a valid workspace', async () => { @@ -132,14 +134,11 @@ describe('createUnitTest', () => { expect(dummyContext.telemetry.properties.result).toBe('Canceled'); }); - test('should log an error and call handleError when an exception occurs', async () => { + test('should throw when an exception occurs', async () => { const testError = new Error('Test error'); vi.spyOn(unitTestUtils, 'parseUnitTestOutputs').mockRejectedValueOnce(testError); - await createUnitTest(dummyContext, dummyNode, dummyUnitTestDefinition); - + await expect(createUnitTest(dummyContext, dummyNode, dummyUnitTestDefinition)).rejects.toThrow('Test error'); expect(updateSolutionWithProjectSpy).not.toHaveBeenCalled(); - expect(dummyContext.telemetry.properties.result).toBe('Failed'); - expect(dummyContext.telemetry.properties.errorMessage).toBeDefined(); }); }); diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts index f70a8bd1bc3..4d9d3e0b60a 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTest.ts @@ -19,7 +19,7 @@ import { } from '../../../utils/unitTest/unitTest'; import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject'; import { ensureDirectoryInWorkspace, getWorkflowNode, getWorkspaceFolder, getWorkspacePath } from '../../../utils/workspace'; -import { callWithTelemetryAndErrorHandling, type IActionContext, parseError } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; import * as vscode from 'vscode'; import * as fse from 'fs-extra'; @@ -35,151 +35,92 @@ import { syncCloudSettings } from '../../syncCloudSettings'; * @returns {Promise} - A Promise that resolves when the unit test is created. */ export async function createUnitTest(context: IActionContext, node: vscode.Uri | undefined, nodeOutputOperations: any): Promise { - const startTime = Date.now(); - - // Initialize telemetry properties - Object.assign(context.telemetry.properties, { - workspaceLocated: 'false', - projectRootLocated: 'false', - workflowNodeSelected: 'false', - multiRootWorkspaceValid: 'false', - unitTestNamePrompted: 'false', - directoriesEnsured: 'false', - csFileCreated: 'false', - csprojUpdated: 'false', - workspaceUpdated: 'false', - unitTestDefinitionParsed: 'false', - operationInfoExists: 'false', - outputParametersExists: 'false', - workflowNodePath: '', - workflowTestFolderPathResolved: 'false', - mockOutputsFolderPathCreated: 'false', - mockableOperationsFound: '0', - mockableOperationsProcessed: '0', - mockableTriggersProcessed: '0', - csFilesGenerated: '0', - csFileGenerationFailures: '0', - workspaceUpdatedStatus: 'false', - workspaceUpdateFailureReason: '', - unitTestSaveStatus: 'InProgress', - unitTestSaveFailureReason: '', + context.telemetry.properties.lastStep = 'ensureWorkspace'; + const isWorkspaceReady = await callWithTelemetryAndErrorHandling('createUnitTest.ensureWorkspace', async (actionContext: IActionContext) => { + actionContext.errorHandling.rethrow = true; + actionContext.errorHandling.suppressDisplay = true; + return await ensureWorkspace(actionContext); }); - try { - context.telemetry.properties.lastStep = 'ensureWorkspace'; - const isWorkspaceReady = await callWithTelemetryAndErrorHandling('createUnitTest.ensureWorkspace', async (actionContext: IActionContext) => { - actionContext.errorHandling.rethrow = true; - actionContext.errorHandling.suppressDisplay = true; - return await ensureWorkspace(actionContext); - }); - - if (!isWorkspaceReady) { - context.telemetry.properties.multiRootWorkspaceValid = 'false'; - ext.outputChannel.appendLog( - localize('createUnitTestCancelled', 'Exiting unit test creation, a workspace is required to create unit tests.') - ); - context.telemetry.properties.result = 'Canceled'; - return; - } - Object.assign(context.telemetry.properties, { - multiRootWorkspaceValid: 'true', - workspaceLocated: 'true', - projectRootLocated: 'true', - }); - - // Get parsed outputs - context.telemetry.properties.lastStep = 'parseUnitTestOutputs'; - const parsedOutputs = await parseUnitTestOutputs(nodeOutputOperations); - const operationInfo = parsedOutputs['operationInfo']; - const outputParameters = parsedOutputs['outputParameters']; - context.telemetry.properties.operationInfoExists = operationInfo ? 'true' : 'false'; - context.telemetry.properties.outputParametersExists = outputParameters ? 'true' : 'false'; - - // Determine workflow node - context.telemetry.properties.lastStep = 'getWorkflowNode'; - let workflowNode = getWorkflowNode(node) as vscode.Uri; - let projectPath: string | undefined; - if (workflowNode) { - context.telemetry.properties.lastStep = 'getProjectRootFromWorkflowNode'; - const workspaceFolder = getWorkspacePath(workflowNode.fsPath); - projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); - } else { - context.telemetry.properties.lastStep = 'getProjectRootFromWorkspaceFolder'; - const workspaceFolder = await getWorkspaceFolder(context); - projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); - context.telemetry.properties.lastStep = 'selectWorkflowNode'; - workflowNode = await selectWorkflowNode(context, projectPath); - } - context.telemetry.properties.workflowNodeSelected = 'true'; - context.telemetry.properties.workflowNodePath = workflowNode ? workflowNode.fsPath : ''; - - try { - context.telemetry.properties.lastStep = 'validateWorkflowPath'; - validateWorkflowPath(projectPath, workflowNode.fsPath); - } catch (error) { - vscode.window.showErrorMessage(`Workflow validation failed: ${error.message}`); - context.telemetry.properties.result = 'Failed'; - context.telemetry.properties.errorMessage = error.message; - return; - } - const workflowName = path.basename(path.dirname(workflowNode.fsPath)); - - // Prompt for unit test name - context.telemetry.properties.lastStep = 'promptForUnitTestName'; - const unitTestName = await promptForUnitTestName(context, projectPath, workflowName); - context.telemetry.properties.unitTestNamePrompted = 'true'; - - context.telemetry.properties.lastStep = 'getUnitTestPaths'; - const { unitTestFolderPath, logicAppName, workflowTestFolderPath, logicAppTestFolderPath, testsDirectory } = getUnitTestPaths( - projectPath, - workflowName, - unitTestName - ); - context.telemetry.properties.workflowTestFolderPathResolved = workflowTestFolderPath ? 'true' : 'false'; - - context.telemetry.properties.lastStep = 'getOperationMockClassContent'; - const { mockClassContent, foundActionMocks, foundTriggerMocks } = await getOperationMockClassContent( - operationInfo, - outputParameters, - workflowNode.fsPath, - workflowName, - logicAppName + if (!isWorkspaceReady) { + context.telemetry.properties.multiRootWorkspaceValid = 'false'; + ext.outputChannel.appendLog( + localize('createUnitTestCancelled', 'Exiting unit test creation, a workspace is required to create unit tests.') ); - if (!foundTriggerMocks || Object.keys(foundTriggerMocks).length === 0) { - throw new Error(localize('noTriggersFound', 'No trigger found in the workflow. Unit tests must include a mocked trigger.')); - } - context.telemetry.properties.workflowName = workflowName; - context.telemetry.properties.unitTestName = unitTestName; - - // Save the unit test - context.telemetry.properties.lastStep = 'generateUnitTest'; - await generateUnitTest(context, projectPath, workflowName, unitTestName, mockClassContent, foundActionMocks, foundTriggerMocks); - context.telemetry.properties.unitTestSaveStatus = 'Success'; - context.telemetry.properties.unitTestProcessingTimeMs = (Date.now() - startTime).toString(); + context.telemetry.properties.result = 'Canceled'; + return; + } - try { - const csprojFilePath = path.join(logicAppTestFolderPath, `${logicAppName}.csproj`); + // Get parsed outputs + context.telemetry.properties.lastStep = 'parseUnitTestOutputs'; + const parsedOutputs = await parseUnitTestOutputs(nodeOutputOperations); + const operationInfo = parsedOutputs['operationInfo']; + const outputParameters = parsedOutputs['outputParameters']; + context.telemetry.properties.operationInfoExists = operationInfo ? 'true' : 'false'; + context.telemetry.properties.outputParametersExists = outputParameters ? 'true' : 'false'; + + // Determine workflow node + context.telemetry.properties.lastStep = 'getWorkflowNode'; + let workflowNode = getWorkflowNode(node) as vscode.Uri; + let projectPath: string | undefined; + if (workflowNode) { + context.telemetry.properties.lastStep = 'getProjectRootFromWorkflowNode'; + const workspaceFolder = getWorkspacePath(workflowNode.fsPath); + projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); + } else { + context.telemetry.properties.lastStep = 'getProjectRootFromWorkspaceFolder'; + const workspaceFolder = await getWorkspaceFolder(context); + projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); + + context.telemetry.properties.lastStep = 'selectWorkflowNode'; + workflowNode = await selectWorkflowNode(context, projectPath); + } + context.telemetry.properties.workflowNodePath = workflowNode ? workflowNode.fsPath : ''; + + context.telemetry.properties.lastStep = 'validateWorkflowPath'; + validateWorkflowPath(projectPath, workflowNode.fsPath); + + context.telemetry.properties.lastStep = 'promptForUnitTestName'; + const workflowName = path.basename(path.dirname(workflowNode.fsPath)); + const unitTestName = await promptForUnitTestName(context, projectPath, workflowName); + context.telemetry.properties.workflowName = workflowName; + context.telemetry.properties.unitTestName = unitTestName; + + context.telemetry.properties.lastStep = 'getUnitTestPaths'; + const { unitTestFolderPath, logicAppName, logicAppTestFolderPath, testsDirectory } = getUnitTestPaths( + projectPath, + workflowName, + unitTestName + ); + + context.telemetry.properties.lastStep = 'getOperationMockClassContent'; + const { mockClassContent, foundActionMocks, foundTriggerMocks } = await getOperationMockClassContent( + operationInfo, + outputParameters, + workflowNode.fsPath, + workflowName, + logicAppName + ); + + if (!foundTriggerMocks || Object.keys(foundTriggerMocks).length === 0) { + throw new Error(localize('noTriggersFound', 'No trigger found in the workflow. Unit tests must include a mocked trigger.')); + } - context.telemetry.properties.lastStep = 'updateTestsSln'; - ext.outputChannel.appendLog(`Updating solution in tests folder: ${unitTestFolderPath}`); - await updateTestsSln(testsDirectory, csprojFilePath); - } catch (solutionError) { - ext.outputChannel.appendLog(`Failed to update solution: ${solutionError}`); - } + context.telemetry.properties.lastStep = 'generateUnitTest'; + await generateUnitTest(context, projectPath, workflowName, unitTestName, mockClassContent, foundActionMocks, foundTriggerMocks); - context.telemetry.properties.lastStep = 'syncCloudSettings'; - await syncCloudSettings(context, vscode.Uri.file(projectPath)); + const csprojFilePath = path.join(logicAppTestFolderPath, `${logicAppName}.csproj`); - context.telemetry.properties.result = 'Succeeded'; + context.telemetry.properties.lastStep = 'updateTestsSln'; + try { + ext.outputChannel.appendLog(`Updating solution in tests folder: ${unitTestFolderPath}`); + await updateTestsSln(testsDirectory, csprojFilePath); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - context.telemetry.properties.unitTestGenerationStatus = 'Failed'; - context.telemetry.properties.result = 'Failed'; - context.telemetry.properties.errorMessage = errorMessage; - context.telemetry.properties['createUnitTestError'] = errorMessage; - vscode.window.showErrorMessage(localize('createUnitTestError', 'An error occurred: {0}', errorMessage)); - ext.outputChannel.appendLog(localize('createUnitTestLog', 'Error in createUnitTest: {0}', errorMessage)); + ext.outputChannel.appendLog(localize('updateTestsSlnError', 'Failed to update solution in tests folder. Error: "{0}".', error instanceof Error ? error.message : String(error))); } + + context.telemetry.properties.lastStep = 'syncCloudSettings'; + await syncCloudSettings(context, vscode.Uri.file(projectPath)); } /** @@ -202,102 +143,81 @@ async function generateUnitTest( foundActionMocks: Record, foundTriggerMocks: Record ): Promise { - try { - // Get required paths - const { testsDirectory, logicAppName, logicAppTestFolderPath, workflowTestFolderPath, mocksFolderPath, unitTestFolderPath } = - getUnitTestPaths(projectPath, workflowName, unitTestName); - - // Get cleaned versions of strings - const cleanedUnitTestName = unitTestName.replace(/-/g, '_'); - const cleanedWorkflowName = workflowName.replace(/-/g, '_'); - const cleanedLogicAppName = logicAppName.replace(/-/g, '_'); - - // Ensure directories exist - ext.outputChannel.appendLog(localize('ensuringDirectories', 'Ensuring required directories exist...')); - await Promise.all([ - fse.ensureDir(logicAppTestFolderPath), - fse.ensureDir(workflowTestFolderPath), - fse.ensureDir(unitTestFolderPath), - fse.ensureDir(mocksFolderPath), - ]); - - // Create the testSettings.config and TestExecutor.cs files - ext.outputChannel.appendLog(localize('ensureTestProjectFiles', 'Ensuring test project files...')); - context.telemetry.properties.lastStep = 'createTestSettingsConfigFile'; - await createTestSettingsConfigFile(workflowTestFolderPath, workflowName, logicAppName); - context.telemetry.properties.lastStep = 'createTestExecutorFile'; - await createTestExecutorFile(logicAppTestFolderPath, cleanedLogicAppName); - - const [actionName, actionOutputClassName] = Object.entries(foundActionMocks)[0] || []; - const [, triggerOutputClassName] = Object.entries(foundTriggerMocks)[0] || []; - - // Create actionMockClassName by replacing "Output" with "Mock" in actionOutputClassName - const actionMockClassName = actionOutputClassName?.replace(/(.*)Output$/, '$1Mock'); - const triggerMockClassName = triggerOutputClassName.replace(/(.*)Output$/, '$1Mock'); - - // Create the mock files - context.telemetry.properties.lastStep = 'createMockClasses'; - for (const [mockClassName, classContent] of Object.entries(mockClassContent)) { - const mockFilePath = path.join(mocksFolderPath, `${mockClassName}.cs`); - await fse.writeFile(mockFilePath, classContent, 'utf-8'); - ext.outputChannel.appendLog(localize('csMockFileCreated', 'Created mock class file at: "{0}".', mockFilePath)); - } - - // Create the .cs file for the unit test - context.telemetry.properties.lastStep = 'createTestCsFile'; - await createTestCsFile( - unitTestFolderPath, - unitTestName, - cleanedUnitTestName, - workflowName, - cleanedWorkflowName, - cleanedLogicAppName, - actionName, - actionOutputClassName, - actionMockClassName, - triggerOutputClassName, - triggerMockClassName, - true - ); - context.telemetry.properties.csFileCreated = 'true'; - - // Ensure .csproj file exists - ext.outputChannel.appendLog(localize('ensuringCsproj', 'Ensuring .csproj file exists...')); - await ensureCsproj(logicAppTestFolderPath, logicAppName); - context.telemetry.properties.csprojValid = 'true'; - - // Update .csproj file with content include for the workflow - context.telemetry.properties.lastStep = 'updateCsprojFile'; - const csprojFilePath = path.join(logicAppTestFolderPath, `${logicAppName}.csproj`); - const isCsprojUpdated = await updateCsprojFile(csprojFilePath, workflowName); - context.telemetry.properties.csprojUpdated = isCsprojUpdated ? 'true' : 'false'; - - // Add testsDirectory to workspace if not already included - try { - context.telemetry.properties.lastStep = 'ensureTestsDirectoryInWorkspace'; - ext.outputChannel.appendLog(localize('ensureTestsDirectory', 'Ensuring tests directory exists in workspace...')); - await ensureDirectoryInWorkspace(testsDirectory); - context.telemetry.properties.workspaceUpdatedStatus = 'true'; - } catch (workspaceError) { - const reason = parseError(workspaceError).message; - Object.assign(context.telemetry.properties, { - workspaceUpdated: 'false', - workspaceUpdatedStatus: 'false', - workspaceUpdateFailureReason: reason, - }); - throw workspaceError; - } - - context.telemetry.properties.unitTestGenerationStatus = 'Success'; - ext.outputChannel.appendLog( - localize('generateCodefulUnitTest', 'Successfully created unit test "{0}" at "{1}".', unitTestName, unitTestFolderPath) - ); - } catch (error) { - context.telemetry.properties.result = 'Failed'; - context.telemetry.properties.errorMessage = error.message ?? error; - context.telemetry.properties.generateUnitTest = 'Failed'; - const errorMessage = error.message || localize('unknownError', 'An unknown error occurred.'); - ext.outputChannel.appendLog(errorMessage); - vscode.window.showErrorMessage(errorMessage); + // Get required paths + const { testsDirectory, logicAppName, logicAppTestFolderPath, workflowTestFolderPath, mocksFolderPath, unitTestFolderPath } = + getUnitTestPaths(projectPath, workflowName, unitTestName); + + // Get cleaned versions of strings + const cleanedUnitTestName = unitTestName.replace(/-/g, '_'); + const cleanedWorkflowName = workflowName.replace(/-/g, '_'); + const cleanedLogicAppName = logicAppName.replace(/-/g, '_'); + + // Ensure directories exist + ext.outputChannel.appendLog(localize('ensuringDirectories', 'Ensuring required directories exist...')); + await Promise.all([ + fse.ensureDir(logicAppTestFolderPath), + fse.ensureDir(workflowTestFolderPath), + fse.ensureDir(unitTestFolderPath), + fse.ensureDir(mocksFolderPath), + ]); + + // Create the testSettings.config and TestExecutor.cs files + ext.outputChannel.appendLog(localize('ensureTestProjectFiles', 'Ensuring test project files...')); + context.telemetry.properties.lastStep = 'createTestSettingsConfigFile'; + await createTestSettingsConfigFile(workflowTestFolderPath, workflowName, logicAppName); + + context.telemetry.properties.lastStep = 'createTestExecutorFile'; + await createTestExecutorFile(logicAppTestFolderPath, cleanedLogicAppName); + + const [actionName, actionOutputClassName] = Object.entries(foundActionMocks)[0] || []; + const [, triggerOutputClassName] = Object.entries(foundTriggerMocks)[0] || []; + + // Create actionMockClassName by replacing "Output" with "Mock" in actionOutputClassName + const actionMockClassName = actionOutputClassName?.replace(/(.*)Output$/, '$1Mock'); + const triggerMockClassName = triggerOutputClassName.replace(/(.*)Output$/, '$1Mock'); + + // Create the mock files + context.telemetry.properties.lastStep = 'createMockClasses'; + for (const [mockClassName, classContent] of Object.entries(mockClassContent)) { + const mockFilePath = path.join(mocksFolderPath, `${mockClassName}.cs`); + await fse.writeFile(mockFilePath, classContent, 'utf-8'); + ext.outputChannel.appendLog(localize('csMockFileCreated', 'Created mock class file at: "{0}".', mockFilePath)); } + + // Create the .cs file for the unit test + context.telemetry.properties.lastStep = 'createTestCsFile'; + await createTestCsFile( + unitTestFolderPath, + unitTestName, + cleanedUnitTestName, + workflowName, + cleanedWorkflowName, + cleanedLogicAppName, + actionName, + actionOutputClassName, + actionMockClassName, + triggerOutputClassName, + triggerMockClassName, + true + ); + + // Ensure .csproj file exists + ext.outputChannel.appendLog(localize('ensuringCsproj', 'Ensuring .csproj file exists...')); + context.telemetry.properties.lastStep = 'ensureCsproj'; + await ensureCsproj(logicAppTestFolderPath, logicAppName); + + // Update .csproj file with content include for the workflow + context.telemetry.properties.lastStep = 'updateCsprojFile'; + const csprojFilePath = path.join(logicAppTestFolderPath, `${logicAppName}.csproj`); + await updateCsprojFile(csprojFilePath, workflowName); + + // Add testsDirectory to workspace if not already included + context.telemetry.properties.lastStep = 'ensureTestsDirectoryInWorkspace'; + ext.outputChannel.appendLog(localize('ensureTestsDirectory', 'Ensuring tests directory exists in workspace...')); + await ensureDirectoryInWorkspace(testsDirectory); + + context.telemetry.properties.unitTestGenerationStatus = 'Success'; + ext.outputChannel.appendLog( + localize('generateCodefulUnitTest', 'Successfully created unit test "{0}" at "{1}".', unitTestName, unitTestFolderPath) + ); } diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTestFromRun.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTestFromRun.ts index e1562154112..c7297c1b94c 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTestFromRun.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/createUnitTestFromRun.ts @@ -13,7 +13,6 @@ import { updateCsprojFile, extractAndValidateRunId, getUnitTestPaths, - parseErrorBeforeTelemetry, parseUnitTestOutputs, getOperationMockClassContent, promptForUnitTestName, @@ -23,7 +22,7 @@ import { } from '../../../utils/unitTest/unitTest'; import { tryGetLogicAppProjectRoot } from '../../../utils/verifyIsProject'; import { ensureDirectoryInWorkspace, getWorkflowNode, getWorkspaceFolder, getWorkspacePath } from '../../../utils/workspace'; -import { callWithTelemetryAndErrorHandling, type IActionContext, parseError } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; import * as vscode from 'vscode'; import * as fse from 'fs-extra'; @@ -42,81 +41,56 @@ import { syncCloudSettings } from '../../syncCloudSettings'; * @returns {Promise} Resolves when the unit test creation process completes. */ export async function createUnitTestFromRun(context: IActionContext, node: vscode.Uri | undefined, runId?: string, nodeOutputOperations?: any): Promise { - try { - // Validate and extract Run ID - context.telemetry.properties.lastStep = 'extractAndValidateRunId'; - const validatedRunId = await extractAndValidateRunId(runId); - - context.telemetry.properties.lastStep = 'ensureWorkspace'; - const isWorkspaceReady = await callWithTelemetryAndErrorHandling('createUnitTestFromRun.ensureWorkspace', async (actionContext: IActionContext) => { - actionContext.errorHandling.rethrow = true; - actionContext.errorHandling.suppressDisplay = true; - return await ensureWorkspace(actionContext); - }); + context.telemetry.properties.lastStep = 'extractAndValidateRunId'; + const validatedRunId = await extractAndValidateRunId(runId); + + context.telemetry.properties.lastStep = 'ensureWorkspace'; + const isWorkspaceReady = await callWithTelemetryAndErrorHandling('createUnitTestFromRun.ensureWorkspace', async (actionContext: IActionContext) => { + actionContext.errorHandling.rethrow = true; + actionContext.errorHandling.suppressDisplay = true; + return await ensureWorkspace(actionContext); + }); + + if (!isWorkspaceReady) { + ext.outputChannel.appendLog( + localize('createUnitTestFromRunCancelled', 'Exiting unit test creation, a workspace is required to create unit tests.') + ); + context.telemetry.properties.result = 'Canceled'; + return; + } + + // Determine workflow node + context.telemetry.properties.lastStep = 'getWorkflowNode'; + let workflowNode = getWorkflowNode(node) as vscode.Uri; + let projectPath: string | undefined; + if (workflowNode) { + context.telemetry.properties.lastStep = 'getProjectRootFromWorkflowNode'; + const workspaceFolder = getWorkspacePath(workflowNode.fsPath); + projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); + } else { + context.telemetry.properties.lastStep = 'getProjectRootFromWorkspaceFolder'; + const workspaceFolder = await getWorkspaceFolder(context); + projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); - if (!isWorkspaceReady) { - ext.outputChannel.appendLog( - localize('createUnitTestFromRunCancelled', 'Exiting unit test creation, a workspace is required to create unit tests.') - ); - context.telemetry.properties.result = 'Canceled'; - return; - } + context.telemetry.properties.lastStep = 'selectWorkflowNode'; + workflowNode = await selectWorkflowNode(context, projectPath); + } - Object.assign(context.telemetry.properties, { - workspaceLocated: 'true', - projectRootLocated: 'true', - userTriggeredCreateUnitTestRun: 'true', - runIdProvided: runId ? 'true' : 'false', - hasNodeUri: node ? 'true' : 'false', - }); - - // Determine workflow node - context.telemetry.properties.lastStep = 'getWorkflowNode'; - let workflowNode = getWorkflowNode(node) as vscode.Uri; - let projectPath: string | undefined; - if (workflowNode) { - context.telemetry.properties.lastStep = 'getProjectRootFromWorkflowNode'; - const workspaceFolder = getWorkspacePath(workflowNode.fsPath); - projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); - } else { - context.telemetry.properties.lastStep = 'getProjectRootFromWorkspaceFolder'; - const workspaceFolder = await getWorkspaceFolder(context); - projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder); - context.telemetry.properties.lastStep = 'selectWorkflowNode'; - workflowNode = await selectWorkflowNode(context, projectPath); - } + context.telemetry.properties.lastStep = 'validateWorkflowPath'; + validateWorkflowPath(projectPath, workflowNode.fsPath); - try { - context.telemetry.properties.lastStep = 'validateWorkflowPath'; - validateWorkflowPath(projectPath, workflowNode.fsPath); - } catch (error) { - vscode.window.showErrorMessage(`Workflow validation failed: ${error.message}`); - context.telemetry.properties.result = 'Failed'; - context.telemetry.properties.errorMessage = error.message; - return; - } + // Get workflow name and prompt for unit test name + context.telemetry.properties.lastStep = 'promptForUnitTestName'; + const workflowName = path.basename(path.dirname(workflowNode.fsPath)); + const unitTestName = await promptForUnitTestName(context, projectPath, workflowName); + Object.assign(context.telemetry.properties, { + workflowName: workflowName, + unitTestName: unitTestName, + runId: validatedRunId, + }); - // Get workflow name and prompt for unit test name - context.telemetry.properties.lastStep = 'promptForUnitTestName'; - const workflowName = path.basename(path.dirname(workflowNode.fsPath)); - const unitTestName = await promptForUnitTestName(context, projectPath, workflowName); - Object.assign(context.telemetry.properties, { - workflowName: workflowName, - unitTestName: unitTestName, - runId: validatedRunId, - }); - - context.telemetry.properties.lastStep = 'generateUnitTestFromRun'; - await generateUnitTestFromRun(context, projectPath, workflowName, unitTestName, validatedRunId, nodeOutputOperations, node.fsPath); - context.telemetry.properties.result = 'Succeeded'; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - context.telemetry.properties.result = 'Failed'; - context.telemetry.properties.errorMessage = errorMessage; - context.telemetry.properties['createUnitTestFromRunError'] = errorMessage; - vscode.window.showErrorMessage(localize('createUnitTestFromRunError', 'An error occurred: {0}', errorMessage)); - ext.outputChannel.appendLog(localize('createUnitTestFromRunLog', 'Error in createUnitTestFromRun: {0}', errorMessage)); - } + context.telemetry.properties.lastStep = 'generateUnitTestFromRun'; + await generateUnitTestFromRun(context, projectPath, workflowName, unitTestName, validatedRunId, nodeOutputOperations, node.fsPath); } /** @@ -139,18 +113,24 @@ async function generateUnitTestFromRun( nodeOutputOperations: any, workflowPath: string ): Promise { - // Initialize telemetry properties - Object.assign(context.telemetry.properties, { - apiCallInitiated: 'false', - apiCallSucceeded: 'false', - filesUnzipped: 'false', - csFileCreated: 'false', - csprojFileCreated: 'false', - nugetConfigFileCreated: 'false', - testsFolderAddedToWorkspace: 'false', - }); + if (!runId) { + throw new Error(localize('runIdMissing', 'Run ID is required to generate a unit test from run.')); + } + + if (!ext.workflowRuntimePort) { + throw new Error(localize('workflowRuntimeNotRunning', 'Workflow runtime is not running. Start the runtime and try again.')); + } + + ext.outputChannel.appendLog( + localize( + 'operationalContext', + 'Creating unit test "{0}" for workflow "{1}", runId "{2}".', + unitTestName, + workflowName, + runId + ) + ); - // Get parsed outputs context.telemetry.properties.lastStep = 'parseUnitTestOutputs'; const parsedOutputs = await parseUnitTestOutputs(nodeOutputOperations); const operationInfo = parsedOutputs['operationInfo']; @@ -160,193 +140,119 @@ async function generateUnitTestFromRun( outputParametersExists: outputParameters ? 'true' : 'false', }); - const startTime = Date.now(); - try { - if (!runId) { - context.telemetry.properties.runIdMissing = 'true'; - throw new Error(localize('runIdMissing', 'Run ID is required to generate a unit test from run.')); - } - - if (!ext.workflowRuntimePort) { - context.telemetry.properties.missingRuntimePort = 'true'; - throw new Error(localize('workflowRuntimeNotRunning', 'Workflow runtime is not running. Start the runtime and try again.')); + const baseUrl = `http://localhost:${ext.workflowRuntimePort}`; + const apiUrl = `${baseUrl}/runtime/webhooks/workflow/api/management/workflows/${encodeURIComponent(workflowName)}/runs/${encodeURIComponent(runId)}/generateUnitTest`; + + ext.outputChannel.appendLog(localize('initiatingApiCall', 'Fetching unit test details from run...')); + + context.telemetry.properties.lastStep = 'postGenerateUnitTest'; + let response: any; + response = await axios.post( + apiUrl, + { UnitTestName: unitTestName }, + { + headers: { + Accept: 'application/zip', + 'Content-Type': 'application/json', + }, + responseType: 'arraybuffer', + timeout: 30000, } + ); - context.telemetry.properties.runtimePort = ext.workflowRuntimePort.toString(); - const baseUrl = `http://localhost:${ext.workflowRuntimePort}`; - const apiUrl = `${baseUrl}/runtime/webhooks/workflow/api/management/workflows/${encodeURIComponent(workflowName)}/runs/${encodeURIComponent(runId)}/generateUnitTest`; - - ext.outputChannel.appendLog(localize('apiUrl', `Calling API URL: ${apiUrl}`)); - ext.outputChannel.appendLog( - localize( - 'operationalContext', - `Operational context: Workflow Name: ${workflowName}, Run ID: ${runId}, Unit Test Name: ${unitTestName}` - ) - ); - ext.outputChannel.appendLog(localize('initiatingApiCall', 'Fetching unit test details from run...')); - context.telemetry.properties.apiCallInitiated = 'true'; - - context.telemetry.properties.lastStep = 'postGenerateUnitTest'; - let response: any; - try { - response = await axios.post( - apiUrl, - { UnitTestName: unitTestName }, - { - headers: { - Accept: 'application/zip', - 'Content-Type': 'application/json', - }, - responseType: 'arraybuffer', - } - ); - - context.telemetry.properties.apiCallSucceeded = 'true'; - context.telemetry.properties.processStage = 'API Call Completed'; - } catch (apiError) { - const failReason = parseErrorBeforeTelemetry(apiError); - context.telemetry.properties.apiCallSucceeded = 'false'; - context.telemetry.properties.apiCallFailReason = failReason; - ext.outputChannel.appendLog(localize('apiCallFailedLog', `API call failed: ${context.telemetry.properties.apiCallFailReason}`)); - throw apiError; - } - - const zipBuffer = Buffer.from(response.data); - const contentType = response.headers['content-type']; - if (contentType !== 'application/zip') { - context.telemetry.properties.apiCallSucceeded = 'false'; - throw new Error(localize('invalidResponseType', `Expected a zip file but received ${contentType}`)); - } - - context.telemetry.properties.lastStep = 'getUnitTestPaths'; - const paths = getUnitTestPaths(projectPath, workflowName, unitTestName); - - context.telemetry.properties.lastStep = 'getOperationMockClassContent'; - const { mockClassContent, foundActionMocks, foundTriggerMocks } = await getOperationMockClassContent( - operationInfo, - outputParameters, - workflowPath, - workflowName, - paths.logicAppName - ); - if (!foundTriggerMocks || Object.keys(foundTriggerMocks).length === 0) { - throw new Error(localize('noTriggersFound', 'No trigger found in the workflow. Unit tests must include a mocked trigger.')); - } - - // Get cleaned versions of strings - const cleanedUnitTestName = unitTestName.replace(/-/g, '_'); - const cleanedWorkflowName = workflowName.replace(/-/g, '_'); - const cleanedLogicAppName = paths.logicAppName.replace(/-/g, '_'); - - try { - context.telemetry.properties.lastStep = 'unzipLogicAppArtifacts'; - await fse.ensureDir(paths.unitTestFolderPath); - ext.outputChannel.appendLog(localize('unzippingFiles', `Unzipping Mock.json into: ${paths.unitTestFolderPath}`)); - await unzipLogicAppArtifacts(zipBuffer, paths.unitTestFolderPath); - context.telemetry.properties.processStage = 'Files Unzipped'; - context.telemetry.properties.filesUnzipped = 'true'; - } catch (unzipError) { - const unzipFailReason = parseError(unzipError).message; - context.telemetry.properties.filesUnzipped = 'false'; - context.telemetry.properties.filesUnzipFailReason = unzipFailReason; - throw unzipError; - } + const zipBuffer = Buffer.from(response.data); + const contentType = response.headers['content-type']; + if (contentType !== 'application/zip') { + throw new Error(localize('invalidResponseType', `Expected a zip file but received ${contentType}`)); + } - try { - // Create the testSettings.config and TestExecutor.cs files - ext.outputChannel.appendLog(localize('ensureTestProjectFiles', 'Ensuring test project files...')); - context.telemetry.properties.lastStep = 'createTestSettingsConfigFile'; - await createTestSettingsConfigFile(paths.workflowTestFolderPath, workflowName, paths.logicAppName); - context.telemetry.properties.lastStep = 'createTestExecutorFile'; - await createTestExecutorFile(paths.logicAppTestFolderPath, cleanedLogicAppName); - - const [actionName, actionOutputClassName] = Object.entries(foundActionMocks)[0] || []; - const [, triggerOutputClassName] = Object.entries(foundTriggerMocks)[0] || []; - - // Create actionMockClassName by replacing "Output" with "Mock" in actionOutputClassName - const actionMockClassName = actionOutputClassName?.replace(/(.*)Output$/, '$1Mock'); - const triggerMockClassName = triggerOutputClassName.replace(/(.*)Output$/, '$1Mock'); - - context.telemetry.properties.lastStep = 'createMockClasses'; - await fse.ensureDir(paths.mocksFolderPath); - for (const [mockClassName, classContent] of Object.entries(mockClassContent)) { - const mockFilePath = path.join(paths.mocksFolderPath, `${mockClassName}.cs`); - await fse.writeFile(mockFilePath, classContent, 'utf-8'); - ext.outputChannel.appendLog(localize('csMockFileCreated', 'Created mock class file at: "{0}".', mockFilePath)); - } - - context.telemetry.properties.lastStep = 'createTestCsFile'; - await createTestCsFile( - paths.unitTestFolderPath!, - unitTestName, - cleanedUnitTestName, - workflowName, - cleanedWorkflowName, - cleanedLogicAppName, - actionName, - actionOutputClassName, - actionMockClassName, - triggerOutputClassName, - triggerMockClassName - ); - context.telemetry.properties.csFileCreated = 'true'; - } catch (csError) { - context.telemetry.properties.csFileCreated = 'false'; - context.telemetry.properties.csFileFailReason = parseError(csError).message; - throw csError; - } + context.telemetry.properties.lastStep = 'getUnitTestPaths'; + const paths = getUnitTestPaths(projectPath, workflowName, unitTestName); - try { - await ensureCsproj(paths.logicAppTestFolderPath, paths.logicAppName); - context.telemetry.properties.nugetConfigFileCreated = 'true'; - } catch (nugetError) { - context.telemetry.properties.nugetConfigFileCreated = 'false'; - context.telemetry.properties.nugetConfigFailReason = parseError(nugetError).message; - throw nugetError; - } + context.telemetry.properties.lastStep = 'getOperationMockClassContent'; + const { mockClassContent, foundActionMocks, foundTriggerMocks } = await getOperationMockClassContent( + operationInfo, + outputParameters, + workflowPath, + workflowName, + paths.logicAppName + ); - context.telemetry.properties.lastStep = 'updateCsprojFile'; - const csprojFilePath = path.join(paths.logicAppTestFolderPath, `${paths.logicAppName}.csproj`); - const isCsprojUpdated = await updateCsprojFile(csprojFilePath, workflowName); - context.telemetry.properties.csprojUpdated = isCsprojUpdated ? 'true' : 'false'; - - try { - context.telemetry.properties.lastStep = 'ensureTestsDirectoryInWorkspace'; - ext.outputChannel.appendLog(localize('ensureTestsDirectory', 'Ensuring tests directory exists in workspace...')); - await ensureDirectoryInWorkspace(paths.testsDirectory); - context.telemetry.properties.testsFolderAddedToWorkspace = 'true'; - } catch (workspaceError) { - context.telemetry.properties.testsFolderAddedToWorkspace = 'false'; - context.telemetry.properties.testsFolderFailReason = parseError(workspaceError).message; - ext.outputChannel.appendLog( - localize('error.addingTestsDirectory', `Error adding tests directory to workspace: ${parseError(workspaceError).message}`) - ); - throw workspaceError; - } + if (!foundTriggerMocks || Object.keys(foundTriggerMocks).length === 0) { + throw new Error(localize('noTriggersFound', 'No trigger found in the workflow. Unit tests must include a mocked trigger.')); + } - ext.outputChannel.appendLog( - localize('generateCodefulUnitTest', 'Successfully created unit test "{0}" at "{1}".', unitTestName, paths.unitTestFolderPath) - ); - context.telemetry.properties.unitTestGenerationStatus = 'Success'; - context.telemetry.measurements.generateCodefulUnitTestMs = Date.now() - startTime; - try { - const csprojFilePath = path.join(paths.logicAppTestFolderPath, `${paths.logicAppName}.csproj`); - - context.telemetry.properties.lastStep = 'updateTestsSln'; - ext.outputChannel.appendLog(`Updating solution in tests folder: ${paths.testsDirectory}`); - await updateTestsSln(paths.testsDirectory, csprojFilePath); - } catch (solutionError) { - ext.outputChannel.appendLog(`Failed to update solution: ${solutionError}`); - } + // Get cleaned versions of strings + const cleanedUnitTestName = unitTestName.replace(/-/g, '_'); + const cleanedWorkflowName = workflowName.replace(/-/g, '_'); + const cleanedLogicAppName = paths.logicAppName.replace(/-/g, '_'); + + context.telemetry.properties.lastStep = 'unzipLogicAppArtifacts'; + await fse.ensureDir(paths.unitTestFolderPath); + ext.outputChannel.appendLog(localize('unzippingFiles', `Unzipping Mock.json into: ${paths.unitTestFolderPath}`)); + await unzipLogicAppArtifacts(zipBuffer, paths.unitTestFolderPath); + + // Create the testSettings.config and TestExecutor.cs files + ext.outputChannel.appendLog(localize('ensureTestProjectFiles', 'Ensuring test project files...')); + context.telemetry.properties.lastStep = 'createTestSettingsConfigFile'; + await createTestSettingsConfigFile(paths.workflowTestFolderPath, workflowName, paths.logicAppName); + + context.telemetry.properties.lastStep = 'createTestExecutorFile'; + await createTestExecutorFile(paths.logicAppTestFolderPath, cleanedLogicAppName); + + const [actionName, actionOutputClassName] = Object.entries(foundActionMocks)[0] || []; + const [, triggerOutputClassName] = Object.entries(foundTriggerMocks)[0] || []; + + // Create actionMockClassName by replacing "Output" with "Mock" in actionOutputClassName + const actionMockClassName = actionOutputClassName?.replace(/(.*)Output$/, '$1Mock'); + const triggerMockClassName = triggerOutputClassName.replace(/(.*)Output$/, '$1Mock'); + + context.telemetry.properties.lastStep = 'createMockClasses'; + await fse.ensureDir(paths.mocksFolderPath); + for (const [mockClassName, classContent] of Object.entries(mockClassContent)) { + const mockFilePath = path.join(paths.mocksFolderPath, `${mockClassName}.cs`); + await fse.writeFile(mockFilePath, classContent, 'utf-8'); + ext.outputChannel.appendLog(localize('csMockFileCreated', 'Created mock class file at: "{0}".', mockFilePath)); + } - context.telemetry.properties.lastStep = 'syncCloudSettings'; - await syncCloudSettings(context, vscode.Uri.file(projectPath)); - } catch (methodError) { - context.telemetry.properties.unitTestGenerationStatus = 'Failed'; - const errorMessage = parseErrorBeforeTelemetry(methodError); - context.telemetry.properties.errorMessage = errorMessage; - vscode.window.showErrorMessage(localize('error.generateCodefulUnitTest', `Failed to generate codeful unit test: ${errorMessage}`)); - ext.outputChannel.appendLog(localize('error.generateCodefulUnitTest', `Failed to generate codeful unit test: ${errorMessage}`)); - throw methodError; + context.telemetry.properties.lastStep = 'createTestCsFile'; + await createTestCsFile( + paths.unitTestFolderPath!, + unitTestName, + cleanedUnitTestName, + workflowName, + cleanedWorkflowName, + cleanedLogicAppName, + actionName, + actionOutputClassName, + actionMockClassName, + triggerOutputClassName, + triggerMockClassName + ); + + context.telemetry.properties.lastStep = 'ensureCsproj'; + await ensureCsproj(paths.logicAppTestFolderPath, paths.logicAppName); + + context.telemetry.properties.lastStep = 'updateCsprojFile'; + const csprojFilePath = path.join(paths.logicAppTestFolderPath, `${paths.logicAppName}.csproj`); + await updateCsprojFile(csprojFilePath, workflowName); + + context.telemetry.properties.lastStep = 'ensureTestsDirectoryInWorkspace'; + ext.outputChannel.appendLog(localize('ensureTestsDirectory', 'Ensuring tests directory exists in workspace...')); + await ensureDirectoryInWorkspace(paths.testsDirectory); + + ext.outputChannel.appendLog( + localize('generateCodefulUnitTest', 'Successfully created unit test "{0}" at "{1}".', unitTestName, paths.unitTestFolderPath) + ); + + context.telemetry.properties.lastStep = 'updateTestsSln'; + try { + ext.outputChannel.appendLog(`Updating solution in tests folder: ${paths.testsDirectory}`); + await updateTestsSln(paths.testsDirectory, csprojFilePath); + } catch (error) { + ext.outputChannel.appendLog(localize('updateTestsSlnError', 'Failed to update solution in tests folder. Error: "{0}".', error instanceof Error ? error.message : String(error))); } + + context.telemetry.properties.lastStep = 'syncCloudSettings'; + await syncCloudSettings(context, vscode.Uri.file(projectPath)); } diff --git a/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts b/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts index 354ff04c9d5..92510e7297c 100644 --- a/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts +++ b/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts @@ -21,7 +21,6 @@ vi.mock('axios', async () => { import { extractAndValidateRunId, removeInvalidCharacters, - parseErrorBeforeTelemetry, generateCSharpClasses, generateMockOutputsClassContent, getOperationMockClassContent, @@ -183,69 +182,6 @@ describe('unitTest', () => { }); }); - describe('parseErrorBeforeTelemetry', () => { - let isAxiosErrorSpy: ReturnType; - let appendLogSpy: ReturnType; - let localizeSpy: ReturnType; - - beforeEach(() => { - isAxiosErrorSpy = vi.mocked(isAxiosError); - appendLogSpy = vi.spyOn(ext.outputChannel, 'appendLog'); - // Create a proper spy on the localize function with type casting to any - localizeSpy = vi.spyOn(localizeModule, 'localize' as any); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should return formatted API error message for Axios error with valid JSON response data', () => { - const responseData = { - error: { - message: 'Not Found', - code: '404', - }, - }; - const encodedData = encoder.encode(JSON.stringify(responseData)); - const error: any = { - message: 'Original error message', - response: { data: encodedData }, - }; - isAxiosErrorSpy.mockReturnValue(true); - const result = parseErrorBeforeTelemetry(error); - const expectedMessage = 'API Error: 404 - Not Found'; - expect(result).toBe(expectedMessage); - expect(localizeSpy).toHaveBeenCalledWith('apiError', `API Error: 404 - Not Found`); - expect(appendLogSpy).toHaveBeenCalledWith(expectedMessage); - }); - - it('should return fallback error message when JSON parsing fails in Axios error', () => { - const invalidData = encoder.encode('invalid json'); - const error: any = { - message: 'Parsing failed', - response: { data: invalidData }, - }; - isAxiosErrorSpy.mockReturnValue(true); - const result = parseErrorBeforeTelemetry(error); - expect(result).toBe('Parsing failed'); - expect(localizeSpy).not.toHaveBeenCalled(); - expect(appendLogSpy).not.toHaveBeenCalled(); - }); - - it('should return error message for non-Axios Error instance', () => { - const error = new Error('Regular error'); - isAxiosErrorSpy.mockReturnValue(false); - const result = parseErrorBeforeTelemetry(error); - expect(result).toBe('Regular error'); - }); - - it('should return string conversion for non-error types', () => { - const error = 42; - const result = parseErrorBeforeTelemetry(error); - expect(result).toBe('42'); - }); - }); - describe('generateCSharpClasses - HTTP Action', () => { let mockClassTemplateContent: string; diff --git a/apps/vs-code-designer/src/app/utils/unitTest/unitTest.ts b/apps/vs-code-designer/src/app/utils/unitTest/unitTest.ts index 92927d98294..bd541dc3ea5 100644 --- a/apps/vs-code-designer/src/app/utils/unitTest/unitTest.ts +++ b/apps/vs-code-designer/src/app/utils/unitTest/unitTest.ts @@ -394,34 +394,6 @@ export const getTestsDirectory = (projectPath: string) => { return testsDirectory; }; -/** - * Parses an error (particularly from Axios) before setting a final errorMessage. - * @param error - The error to parse. - * @returns {string} - A user-friendly error string. - */ -export function parseErrorBeforeTelemetry(error: any): string { - let errorMessage = ''; - - if (isAxiosError(error) && error.response?.data) { - try { - const responseData = JSON.parse(new TextDecoder().decode(error.response.data)); - const { message = '', code = '' } = responseData?.error ?? {}; - errorMessage = localize('apiError', `API Error: ${code} - ${message}`); - ext.outputChannel.appendLog(errorMessage); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (parseError) { - // If we fail to parse, fall back to the original error - errorMessage = error.message; - } - } else if (error instanceof Error) { - errorMessage = error.message; - } else { - // Fallback for non-Error types - errorMessage = String(error); - } - return errorMessage; -} - /** * Parses and transforms raw output parameters from a unit test definition into a structured format. * @param nodeOutputOperations - The operation info and output parameters of the workflow node. @@ -1063,23 +1035,18 @@ export async function updateTestsSln(testsDirectory: string, logicAppCsprojPath: const solutionFile = path.join(testsDirectory, `${solutionName}.sln`); const dotnetBinaryPath = getGlobalSetting(dotNetBinaryPathSettingKey); - try { - // Create a new solution file if it doesn't already exist. - if (await fse.pathExists(solutionFile)) { - ext.outputChannel.appendLog(`Solution file already exists at ${solutionFile}.`); - } else { - ext.outputChannel.appendLog(`Creating new solution file at ${solutionFile}...`); - await executeCommand(ext.outputChannel, testsDirectory, `${dotnetBinaryPath} new sln -n ${solutionName}`); - ext.outputChannel.appendLog(`Solution file created: ${solutionFile}`); - } - - // Compute the relative path from the tests directory to the Logic App .csproj. - const relativeProjectPath = path.relative(testsDirectory, logicAppCsprojPath); - ext.outputChannel.appendLog(`Adding project '${relativeProjectPath}' to solution '${solutionFile}'...`); - await executeCommand(ext.outputChannel, testsDirectory, `${dotnetBinaryPath} sln "${solutionFile}" add "${relativeProjectPath}"`); - ext.outputChannel.appendLog('Project added to solution successfully.'); - } catch (err) { - ext.outputChannel.appendLog(`Error updating solution: ${err}`); - vscode.window.showErrorMessage(`Error updating solution: ${err}`); + // Create a new solution file if it doesn't already exist. + if (await fse.pathExists(solutionFile)) { + ext.outputChannel.appendLog(`Solution file already exists at ${solutionFile}.`); + } else { + ext.outputChannel.appendLog(`Creating new solution file at ${solutionFile}...`); + await executeCommand(ext.outputChannel, testsDirectory, `${dotnetBinaryPath} new sln -n ${solutionName}`); + ext.outputChannel.appendLog(`Solution file created: ${solutionFile}`); } + + // Compute the relative path from the tests directory to the Logic App .csproj. + const relativeProjectPath = path.relative(testsDirectory, logicAppCsprojPath); + ext.outputChannel.appendLog(`Adding project '${relativeProjectPath}' to solution '${solutionFile}'...`); + await executeCommand(ext.outputChannel, testsDirectory, `${dotnetBinaryPath} sln "${solutionFile}" add "${relativeProjectPath}"`); + ext.outputChannel.appendLog('Project added to solution successfully.'); } From ea1e98c440e78d13861195358461a7ecd5e0ec55 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 17 Aug 2026 11:38:30 -0400 Subject: [PATCH 02/23] move promptShouldAutoStartDesignTime to main, update promptShouldEnableLocalManagedIdentityAuth to be consistent --- .../__test__/devContainerIntegration.test.ts | 1 - .../__test__/startDesignTimeApi.test.ts | 40 --------- .../app/utils/codeless/startDesignTimeApi.ts | 74 +--------------- apps/vs-code-designer/src/main.ts | 87 +++++++++++++++---- 4 files changed, 72 insertions(+), 130 deletions(-) diff --git a/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts b/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts index 466dc91a4d4..28a78418173 100644 --- a/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts +++ b/apps/vs-code-designer/src/__test__/devContainerIntegration.test.ts @@ -16,7 +16,6 @@ vi.mock('../app/utils/vsCodeConfig/settings', () => ({ // Mock transitive dependencies of binaries.ts to prevent real module loading. vi.mock('../app/utils/codeless/startDesignTimeApi', () => ({ - promptStartDesignTimeOption: vi.fn(), startAllDesignTimeApis: vi.fn(), stopAllDesignTimeApis: vi.fn(), scheduleStartAllDesignTimeApis: vi.fn(), 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 fd41cdf18bc..2f74736874c 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 @@ -13,11 +13,7 @@ import { startDesignTimeApi, startDesignTimeProcess, stopDesignTimeApi, - promptStartDesignTimeOption, } from '../startDesignTimeApi'; -import { ensureLocalSettingsFile, ensureHostFile } from '../../../projectConsistency/projectFilesConsistency'; -import { getWorkspaceSetting } from '../../vsCodeConfig/settings'; -import { autoStartDesignTimeSetting } from '../../../../constants'; vi.mock('../../appSettings/localSettings', () => ({ addOrUpdateLocalAppSettings: vi.fn(), @@ -401,39 +397,3 @@ describe('startDesignTimeProcess', () => { expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('Conflicting port found when launching func. Restarting design-time process.'); }); }); - -describe('promptStartDesignTimeOption', () => { - const context = { ui: { showWarningMessage: vi.fn() }, telemetry: { properties: {}, measurements: {} } } as any; - - beforeEach(() => { - vi.clearAllMocks(); - ext.designTimeInstances.clear(); - (workspace as any).workspaceFolders = []; - // Default: auto-start disabled and the prompt suppressed (getWorkspaceSetting -> undefined), so - // only the artifact-regeneration loop runs — no scheduled design-time startup, no warning dialog. - vi.mocked(getWorkspaceSetting).mockReturnValue(undefined as any); - }); - - it('logs and skips regeneration when no logic app folders are detected', async () => { - (workspace as any).workspaceFolders = [{ uri: { fsPath: 'D:/workspace' } }]; - vi.mocked(workspaceUtils.getWorkspaceLogicAppRoots).mockResolvedValue([]); - - await promptStartDesignTimeOption(context); - - expect(ensureHostFile).not.toHaveBeenCalled(); - expect(ensureLocalSettingsFile).not.toHaveBeenCalled(); - expect(ext.outputChannel.appendLog).toHaveBeenCalledWith(expect.stringContaining('No logic app project folders were detected')); - }); - - it('logs and skips regeneration when no workspace folders are open', async () => { - (workspace as any).workspaceFolders = undefined; - - await promptStartDesignTimeOption(context); - - expect(workspaceUtils.getWorkspaceLogicAppRoots).not.toHaveBeenCalled(); - expect(ensureHostFile).not.toHaveBeenCalled(); - expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( - 'No workspace folders are open. Skipping host.json and local.settings.json regeneration.' - ); - }); -}); 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 89cd1493c66..29fd62b2d92 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -3,13 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { - autoStartDesignTimeSetting, defaultVersionRange, designTimeDirectoryName, designerStartApi, extensionBundleId, hostFileName, - showStartDesignTimeMessageSetting, designerApiLoadTimeout, type hostFileContent, } from '../../../constants'; @@ -24,13 +22,10 @@ import { localize } from '../../../localize'; import { updateFuncIgnore } from '../codeless/common'; import { writeFormattedJson } from '../fs'; import { getFunctionsCommand } from '../funcCoreTools/funcVersion'; -import { getWorkspaceSetting, updateGlobalSetting } from '../vsCodeConfig/settings'; import { getWorkspaceLogicAppRoots } from '../workspace'; import { ensureProjectFiles } from '../../projectConsistency/projectFilesConsistency'; import { delay } from '../delay'; import { - DialogResponses, - openUrl, type IActionContext, type IAzExtOutputChannel, callWithTelemetryAndErrorHandling, @@ -204,7 +199,7 @@ function stopTrackedDesignTimeProcess(projectPath: string): void { } } -async function tryStartDesignTimeApi(context: IActionContext, projectPath: string): Promise { +export async function tryStartDesignTimeApi(context: IActionContext, projectPath: string): Promise { return startDesignTimeApi(context, projectPath).catch((error) => { ext.outputChannel.appendLog( localize( @@ -708,73 +703,6 @@ export async function startAllDesignTimeApis(): Promise { } } -/** - * Optionally prompts the user to automatically start the design-time process at launch. If auto start is enabled, start the design-time API for all Logic Apps in the workspace. - * TODO(aeldridge): Should be in main.ts for consistency - * @param {IActionContext} context - The action context. - * @returns {Promise} A promise that resolves when each design-time API is in the starting state or the user rejects auto start. - */ -export async function promptStartDesignTimeOption(context: IActionContext) { - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { - const projectPaths = await getWorkspaceLogicAppRoots(); - const showStartDesignTimeMessage = !!getWorkspaceSetting(showStartDesignTimeMessageSetting); - let autoStartDesignTime = !!getWorkspaceSetting(autoStartDesignTimeSetting); - - ext.outputChannel.appendLog( - localize( - 'detectedLogicAppFolders', - 'Detected {0} logic app project folder(s) for artifact regeneration: {1}.', - projectPaths.length, - projectPaths.join(', ') || '(none)' - ) - ); - - if (projectPaths && projectPaths.length > 0) { - if (!autoStartDesignTime && showStartDesignTimeMessage) { - const message = localize( - 'startDesignTimeApi', - 'Always start the background design-time process at launch? The workflow designer will open faster.' - ); - const confirm = { title: localize('yesRecommended', 'Yes (Recommended)') }; - let result: MessageItem; - do { - result = await context.ui.showWarningMessage(message, confirm, DialogResponses.learnMore, DialogResponses.dontWarnAgain); - if (result === confirm) { - await updateGlobalSetting(autoStartDesignTimeSetting, true); - autoStartDesignTime = true; - } else if (result === DialogResponses.learnMore) { - await openUrl('https://learn.microsoft.com/en-us/azure/azure-functions/functions-develop-local'); - } else if (result === DialogResponses.dontWarnAgain) { - await updateGlobalSetting(showStartDesignTimeMessageSetting, false); - } - } while (result === DialogResponses.learnMore); - } - - for (const projectPath of projectPaths) { - if (autoStartDesignTime) { - callWithTelemetryAndErrorHandling('promptStartDesignTime.startDesignTimeApi', async (actionContext: IActionContext) => { - await tryStartDesignTimeApi(actionContext, projectPath); - }); - } - } - } else { - // A folder is only recognized as a logic app project when its host.json is present. If host.json - // itself is missing the folder is not detected here, so host.json and local.settings.json cannot - // be regenerated on this path. Log this so the situation is diagnosable from the output channel. - ext.outputChannel.appendLog( - localize( - 'noLogicAppFoldersForRegen', - 'No logic app project folders were detected in the open workspace, so host.json and local.settings.json were not regenerated. A folder is only recognized as a logic app when its host.json exists; if host.json is missing, restore it (it is normally committed to source control) and reload the window.' - ) - ); - } - } else { - ext.outputChannel.appendLog( - localize('noWorkspaceFoldersForRegen', 'No workspace folders are open. Skipping host.json and local.settings.json regeneration.') - ); - } -} - /** * Creates a JSON file in the specified directory with the given file name and content. * If the file already exists, it will not be overwritten. diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index 45ef0c9994a..1cb28be9efe 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -5,9 +5,9 @@ import { getResourceGroupsApi } from './app/resourcesExtension/getExtensionApi'; import type { AzureAccountTreeItemWithProjects } from './app/tree/AzureAccountTreeItemWithProjects'; import { downloadExtensionBundle } from './app/utils/bundleFeed'; import { - promptStartDesignTimeOption, scheduleStartAllDesignTimeApis, stopAllDesignTimeApis, + tryStartDesignTimeApi, } from './app/utils/codeless/startDesignTimeApi'; import { UriHandler } from './app/utils/codeless/urihandler'; import { getExtensionVersion, initializeCustomExtensionContext, updateLogicAppsContext } from './app/utils/extension'; @@ -16,6 +16,7 @@ import { shouldRequireStrictDependencyValidation } from './app/utils/strictDepen import { ensureVSCodeFiles } from './app/projectConsistency/vscodeConsistency'; import { tryGetLogicAppProjectRoot } from './app/utils/verifyIsProject'; import { + autoStartDesignTimeSetting, DependencyDefaultPath, dotNetBinaryPathSettingKey, extensionCommand, @@ -24,6 +25,7 @@ import { logicAppFilter, nodeJsBinaryPathSettingKey, parameterizeConnectionsInProjectLoadSetting, + showStartDesignTimeMessageSetting, } from './constants'; import { ext } from './extensionVariables'; import { registerAppServiceExtensionVariables } from '@microsoft/vscode-azext-azureappservice'; @@ -50,7 +52,7 @@ import { enableLocalManagedIdentityAuth } from './app/utils/managedIdentity'; import { localize } from './localize'; import { isDevContainerWorkspace } from './app/utils/devContainerUtils'; import { parameterizeAllConnections } from './app/commands/parameterizeConnections'; -import { isManagedIdentityAuthEnabled, shouldParameterizeConnections, updateGlobalSetting } from './app/utils/vsCodeConfig/settings'; +import { getWorkspaceSetting, isManagedIdentityAuthEnabled, shouldParameterizeConnections, updateGlobalSetting } from './app/utils/vsCodeConfig/settings'; import { isManagedIdentityAuthNotificationSuppressed, isParameterizeConnectionsNotificationSuppressed, @@ -109,6 +111,7 @@ export async function activate(context: vscode.ExtensionContext) { callWithTelemetryAndErrorHandling('activate.parameterizeAllConnections', async (actionContext: IActionContext) => { actionContext.telemetry.properties.isActivationEvent = 'true'; if (shouldParameterizeConnections() || (await promptShouldParameterizeConnections(actionContext))) { + actionContext.telemetry.properties.actionTaken = 'true'; await parameterizeAllConnections(actionContext); } }); @@ -142,13 +145,12 @@ export async function activate(context: vscode.ExtensionContext) { ); activateContext.telemetry.properties.lastStep = 'promptEnableManagedIdentityAuth'; - promptEnableLocalManagedIdentityAuth().catch((error) => { - ext.outputChannel?.appendLog( - localize( - 'managedIdentityAuthPromptFailed', - `Managed identity auth startup prompt failed: ${error instanceof Error ? error.message : String(error)}` - ) - ); + callWithTelemetryAndErrorHandling('activate.enableLocalManagedIdentityAuth', async (actionContext: IActionContext) => { + actionContext.telemetry.properties.isActivationEvent = 'true'; + if (await promptShouldEnableLocalManagedIdentityAuth()) { + actionContext.telemetry.properties.actionTaken = 'true'; + await enableLocalManagedIdentityAuth(actionContext); + } }); // Dependencies and environment setup @@ -237,9 +239,9 @@ async function promptShouldParameterizeConnections(context: IActionContext): Pro * - The user has already enabled the setting. * - The user previously selected "Don't show again". */ -async function promptEnableLocalManagedIdentityAuth(): Promise { +async function promptShouldEnableLocalManagedIdentityAuth(): Promise { if (isManagedIdentityAuthNotificationSuppressed() || isManagedIdentityAuthEnabled()) { - return; + return false; } const enableButton = localize('enable', 'Enable'); @@ -250,13 +252,13 @@ async function promptEnableLocalManagedIdentityAuth(): Promise { const selection = await vscode.window.showInformationMessage(message, enableButton, closeButton, dontShowAgain); if (selection === enableButton) { - await callWithTelemetryAndErrorHandling('activate.enableLocalManagedIdentityAuth', async (actionContext: IActionContext) => { - actionContext.telemetry.properties.isActivationEvent = 'true'; - await enableLocalManagedIdentityAuth(actionContext); - }); + return true; } else if (selection === dontShowAgain) { await suppressManagedIdentityAuthNotification(); + return false; } + + return false; } async function ensureExtensionBundle(): Promise { @@ -325,8 +327,61 @@ async function startDesignTime(activateContext: IActionContext, isDevContainer: ); scheduleStartAllDesignTimeApis(); } else { - await promptStartDesignTimeOption(activateContext); + const projectPaths = await getWorkspaceLogicAppRoots(); + if (await promptShouldAutoStartDesignTime(projectPaths)) { + for (const projectPath of projectPaths) { + callWithTelemetryAndErrorHandling('activate.startDesignTimeApi', async (actionContext: IActionContext) => { + await tryStartDesignTimeApi(actionContext, projectPath); + }); + } + } + } +} + +/** + * Prompts the user to automatically start the design-time process at launch. If auto start is enabled, start the design-time API for all Logic Apps in the workspace. + * @param {string[]} projectPaths - The Logic App project paths in the workspace. + * @returns {Promise} A promise that resolves to a value indicating whether the design-time API should be automatically started. + */ +async function promptShouldAutoStartDesignTime(projectPaths: string[]): Promise { + if (!projectPaths || projectPaths.length === 0) { + return false; + } + + const autoStartDesignTime = !!getWorkspaceSetting(autoStartDesignTimeSetting); + if (autoStartDesignTime) { + return true; + } + + ext.outputChannel.appendLog( + localize( + 'detectedLogicAppFolders', + 'Detected {0} logic app project folder(s) for artifact regeneration: {1}.', + projectPaths.length, + projectPaths.join(', ') || '(none)' + ) + ); + + const showStartDesignTimeMessage = !!getWorkspaceSetting(showStartDesignTimeMessageSetting); + if (!showStartDesignTimeMessage) { + return false; + } + + const message = localize( + 'startDesignTimeApi', + 'Always start the background design-time process at launch? The workflow designer will open faster.' + ); + const confirm: vscode.MessageItem = { title: localize('yesRecommended', 'Yes (Recommended)') }; + const dontWarnAgain: vscode.MessageItem = { title: localize('dontWarnAgain', "Don't warn again") }; + const result = await vscode.window.showWarningMessage(message, confirm, dontWarnAgain); + if (result === confirm) { + await updateGlobalSetting(autoStartDesignTimeSetting, true); + return true; + } else if (result === dontWarnAgain) { + await updateGlobalSetting(showStartDesignTimeMessageSetting, false); } + + return false; } export async function deactivate(): Promise { From 597c5f1526bae7f9c0b9f3147d60fc4384c69eb0 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 17 Aug 2026 11:57:01 -0400 Subject: [PATCH 03/23] use global state for auto start design time notification suppressed --- .../src/app/state/notifications.ts | 15 +++++++++++++++ apps/vs-code-designer/src/constants.ts | 1 + apps/vs-code-designer/src/main.ts | 8 ++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/vs-code-designer/src/app/state/notifications.ts b/apps/vs-code-designer/src/app/state/notifications.ts index e89351537e7..1babb78308a 100644 --- a/apps/vs-code-designer/src/app/state/notifications.ts +++ b/apps/vs-code-designer/src/app/state/notifications.ts @@ -4,11 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { ext } from '../../extensionVariables'; import { + suppressAutoStartDesignTimeNotificationState, suppressDesignerVersionNotificationState, suppressManagedIdentityAuthNotificationState, suppressParameterizeConnectionsNotificationState, } from '../../constants'; +/** + * Whether the user has permanently dismissed the auto-start design-time startup prompt. + */ +export function isAutoStartDesignTimeNotificationSuppressed(): boolean { + return ext.context.globalState.get(suppressAutoStartDesignTimeNotificationState) === true; +} + +/** + * Permanently suppresses the auto-start design-time startup prompt. + */ +export async function suppressAutoStartDesignTimeNotification(): Promise { + await ext.context.globalState.update(suppressAutoStartDesignTimeNotificationState, true); +} + /** * Whether the user has permanently dismissed the parameterize-connections startup prompt. */ diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 3f40598bf0f..c74d14743e0 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -263,6 +263,7 @@ export type vscodeCommand = (typeof vscodeCommand)[keyof typeof vscodeCommand]; export const contextValuePrefix = 'azLogicApps'; // Global state +export const suppressAutoStartDesignTimeNotificationState = 'suppressAutoStartDesignTimeNotification'; export const suppressDesignerVersionNotificationState = 'suppressDesignerVersionNotification'; export const suppressManagedIdentityAuthNotificationState = 'suppressManagedIdentityAuthNotification'; export const suppressParameterizeConnectionsNotificationState = 'suppressParameterizeConnectionsNotification'; diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index 1cb28be9efe..6f32c606cc4 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -25,7 +25,6 @@ import { logicAppFilter, nodeJsBinaryPathSettingKey, parameterizeConnectionsInProjectLoadSetting, - showStartDesignTimeMessageSetting, } from './constants'; import { ext } from './extensionVariables'; import { registerAppServiceExtensionVariables } from '@microsoft/vscode-azext-azureappservice'; @@ -54,8 +53,10 @@ import { isDevContainerWorkspace } from './app/utils/devContainerUtils'; import { parameterizeAllConnections } from './app/commands/parameterizeConnections'; import { getWorkspaceSetting, isManagedIdentityAuthEnabled, shouldParameterizeConnections, updateGlobalSetting } from './app/utils/vsCodeConfig/settings'; import { + isAutoStartDesignTimeNotificationSuppressed, isManagedIdentityAuthNotificationSuppressed, isParameterizeConnectionsNotificationSuppressed, + suppressAutoStartDesignTimeNotification, suppressManagedIdentityAuthNotification, suppressParameterizeConnectionsNotification, } from './app/state/notifications'; @@ -362,8 +363,7 @@ async function promptShouldAutoStartDesignTime(projectPaths: string[]): Promise< ) ); - const showStartDesignTimeMessage = !!getWorkspaceSetting(showStartDesignTimeMessageSetting); - if (!showStartDesignTimeMessage) { + if (isAutoStartDesignTimeNotificationSuppressed()) { return false; } @@ -378,7 +378,7 @@ async function promptShouldAutoStartDesignTime(projectPaths: string[]): Promise< await updateGlobalSetting(autoStartDesignTimeSetting, true); return true; } else if (result === dontWarnAgain) { - await updateGlobalSetting(showStartDesignTimeMessageSetting, false); + await suppressAutoStartDesignTimeNotification(); } return false; From c225c3d943632b4b67634bf85d216526c4827930 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 17 Aug 2026 12:03:54 -0400 Subject: [PATCH 04/23] address pr comments --- .../__test__/parameterizeConnections.test.ts | 3 +-- .../src/app/commands/parameterizeConnections.ts | 15 +-------------- .../app/utils/unitTest/__test__/unitTest.test.ts | 5 +---- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts b/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts index a6d688d8c43..7a942aa6ae7 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts @@ -111,11 +111,10 @@ describe('parameterizeConnections', () => { expect(connectionUtil.saveConnectionReferences).toHaveBeenCalledTimes(2); }); - it('should handle errors and log them', async () => { + it('should rethrow errors', async () => { const error = new Error('Test error'); vi.spyOn(connectionUtil, 'getConnectionsJson').mockRejectedValue(error); await expect(parameterizeProjectConnections(testContext, testLogicAppProjectPath1)).rejects.toThrow(); - expect(ext.outputChannel.appendLog).toHaveBeenCalledOnce(); }); }); diff --git a/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts b/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts index 04773ffbeab..6e37728251b 100644 --- a/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts +++ b/apps/vs-code-designer/src/app/commands/parameterizeConnections.ts @@ -66,20 +66,7 @@ export async function parameterizeProjectConnections(context: IActionContext, pr } context.telemetry.properties.projectPath = projectPath; - try { - await parameterizeProjectConnectionsInternal(context, projectPath); - } catch (error) { - const errorMessage = localize( - 'errorParameterizeProjectConnections', - 'Error while parameterizing existing connections for project "{0}": "{1}".', - projectPath, - error instanceof Error ? error.message : String(error) - ); - ext.outputChannel.appendLog(errorMessage); - context.telemetry.properties.result = 'Failed'; - context.telemetry.properties.errorMessage = errorMessage; - throw new Error(errorMessage); - } + await parameterizeProjectConnectionsInternal(context, projectPath); } } diff --git a/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts b/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts index 92510e7297c..b3f95c22401 100644 --- a/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts +++ b/apps/vs-code-designer/src/app/utils/unitTest/__test__/unitTest.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; -import axios, { isAxiosError } from 'axios'; +import axios from 'axios'; import * as childProcess from 'child_process'; import * as fse from 'fs-extra'; import * as util from 'util'; @@ -54,9 +54,6 @@ import { // Global Constants and Test Hooks // ============================================================================ -// Use TextEncoder for encoding/decoding JSON responses -const encoder = new TextEncoder(); - // Fixture path for tests that require a project folder const projectPath = path.join(__dirname, '../../../__mocks__'); // Fake logic app name for tests that need one From 9e19957b74c356dbac88a507caad44c8fc0ad064 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 17 Aug 2026 15:06:49 -0400 Subject: [PATCH 05/23] fix startDesignTimeApi result telemetry --- .../app/utils/codeless/startDesignTimeApi.ts | 18 +++--------------- apps/vs-code-designer/src/main.ts | 4 ++-- 2 files changed, 5 insertions(+), 17 deletions(-) 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 29fd62b2d92..41bc7a8181f 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -199,19 +199,6 @@ function stopTrackedDesignTimeProcess(projectPath: string): void { } } -export async function tryStartDesignTimeApi(context: IActionContext, projectPath: string): Promise { - return startDesignTimeApi(context, projectPath).catch((error) => { - ext.outputChannel.appendLog( - localize( - 'scheduleDesignTimeApiFailed', - 'Background design-time startup failed for project "{0}". Error: {1}', - projectPath, - getErrorMessage(error) - ) - ); - }); -} - export async function startDesignTimeApi(context: IActionContext, projectPath: string): Promise { context.telemetry.properties.projectPath = projectPath; const designTimeInst = getDesignTimeInstance(projectPath); @@ -318,6 +305,7 @@ async function startDesignTimeApiInternal(context: IActionContext, designTimeIns designTimeInst.startupError = errorMessage; designTimeInst.validationRetryCount = 0; stopTrackedDesignTimeProcess(projectPath); + context.telemetry.properties.result = 'Failed'; context.telemetry.properties.errorMessage = errorMessage; ext.outputChannel.appendLog( localize('designTimeApiFailed', 'Design-time startup failed for project "{0}". Error: {1}', projectPath, errorMessage) @@ -583,7 +571,7 @@ export function startDesignTimeProcess( }) .finally(() => { callWithTelemetryAndErrorHandling('designTimeError.languageWorkerFailed.startDesignTimeApi', async (actionContext: IActionContext) => { - await tryStartDesignTimeApi(actionContext, projectPath); + await startDesignTimeApi(actionContext, projectPath); }); }); } @@ -608,7 +596,7 @@ export function startDesignTimeProcess( }) .finally(() => { callWithTelemetryAndErrorHandling('designTimeError.portUnavailable.startDesignTimeApi', async (actionContext: IActionContext) => { - await tryStartDesignTimeApi(actionContext, projectPath); + await startDesignTimeApi(actionContext, projectPath); }); }); } diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index 6f32c606cc4..5dbbccfc4ae 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -7,7 +7,7 @@ import { downloadExtensionBundle } from './app/utils/bundleFeed'; import { scheduleStartAllDesignTimeApis, stopAllDesignTimeApis, - tryStartDesignTimeApi, + startDesignTimeApi, } from './app/utils/codeless/startDesignTimeApi'; import { UriHandler } from './app/utils/codeless/urihandler'; import { getExtensionVersion, initializeCustomExtensionContext, updateLogicAppsContext } from './app/utils/extension'; @@ -332,7 +332,7 @@ async function startDesignTime(activateContext: IActionContext, isDevContainer: if (await promptShouldAutoStartDesignTime(projectPaths)) { for (const projectPath of projectPaths) { callWithTelemetryAndErrorHandling('activate.startDesignTimeApi', async (actionContext: IActionContext) => { - await tryStartDesignTimeApi(actionContext, projectPath); + await startDesignTimeApi(actionContext, projectPath); }); } } From 5987093081facfb9ac6eefff6dfa088917823353 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Mon, 17 Aug 2026 16:54:23 -0400 Subject: [PATCH 06/23] fix infinite loop scenario when orphaned design-time process exists, fix startDesignTimeApi error handling --- .../GenerateADODeploymentScriptsStep.ts | 2 +- .../__test__/startDesignTimeApi.test.ts | 41 ++++++++--- .../app/utils/codeless/startDesignTimeApi.ts | 70 ++++++++----------- 3 files changed, 60 insertions(+), 53 deletions(-) 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 63d486c7546..e8011047e51 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 @@ -23,7 +23,7 @@ import { ext } from '../../../../../extensionVariables'; import { localize } from '../../../../../localize'; import { parameterizeProjectConnections } from '../../../parameterizeConnections'; import { FileManagement } from '../../iacGestureHelperFunctions'; -import { deploymentDirectory, extensionCommand, managementApiPrefix, workflowFileName } from '../../../../../constants'; +import { deploymentDirectory, managementApiPrefix, workflowFileName } from '../../../../../constants'; import { unzipLogicAppArtifacts } from '../../../../utils/taskUtils'; import { startDesignTimeApi } from '../../../../utils/codeless/startDesignTimeApi'; import { getAuthorizationToken, getCloudHost } from '../../../../utils/codeless/getAuthorizationToken'; 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 2f74736874c..fc92eb0d1c7 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 @@ -102,10 +102,14 @@ describe('startAllDesignTimeApis', () => { }); it('logs and exits when no workspace folders are available', async () => { + vi.mocked(workspaceUtils.getWorkspaceLogicAppRoots).mockResolvedValue([]); + await startAllDesignTimeApis(); - expect(workspaceUtils.getWorkspaceLogicAppRoots).not.toHaveBeenCalled(); - expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('No workspace folders found. Skipping design-time startup.'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( + 'No Logic App projects found in the current workspace, skipping design-time startup.' + ); + expect(reserveFreePort).not.toHaveBeenCalled(); }); it('logs zero-project startup when the workspace contains no Logic App folders', async () => { @@ -115,7 +119,7 @@ describe('startAllDesignTimeApis', () => { await startAllDesignTimeApis(); expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( - 'Starting design-time APIs for 0 Logic App project(s) in the current workspace.' + 'No Logic App projects found in the current workspace, skipping design-time startup.' ); expect(reserveFreePort).not.toHaveBeenCalled(); }); @@ -124,17 +128,16 @@ describe('startAllDesignTimeApis', () => { (workspace as any).workspaceFolders = [{ uri: { fsPath: 'D:/workspace' } }]; vi.mocked(workspaceUtils.getWorkspaceLogicAppRoots).mockResolvedValue(['D:/workspace/app-one', 'D:/workspace/app-two']); - await startAllDesignTimeApis(); + // startDesignTimeApi will throw due to the beforeEach createDirectory mock rejecting, + // but startAllDesignTimeApis wraps each call in callWithTelemetryAndErrorHandling which + // (in production) catches errors. The test mock is a passthrough, so we catch here. + await startAllDesignTimeApis().catch(() => {}); expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( - 'Starting design-time APIs for 2 Logic App project(s) in the current workspace.' + 'Starting design-time processes for 2 Logic App project(s) in the current workspace.' ); - expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('Starting Design Time Api for project: D:/workspace/app-one'); - expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('Starting Design Time Api for project: D:/workspace/app-two'); expect(reserveFreePort).toHaveBeenCalledTimes(2); - // Each concurrently started project must receive its own reserved port so - // sibling design-time hosts never collide on the same "free" port. const portOne = ext.designTimeInstances.get('D:/workspace/app-one')?.port; const portTwo = ext.designTimeInstances.get('D:/workspace/app-two')?.port; expect(portOne).toBeDefined(); @@ -150,7 +153,7 @@ describe('startAllDesignTimeApis', () => { }); it('cleans up startup state after a startup failure', async () => { - await startDesignTimeApi(createMockContext(), 'D:/workspace/app-one'); + await expect(startDesignTimeApi(createMockContext(), 'D:/workspace/app-one')).rejects.toThrow(); const designTimeInstance = ext.designTimeInstances.get('D:/workspace/app-one'); @@ -163,6 +166,21 @@ describe('startAllDesignTimeApis', () => { expect(designTimeInstance?.startupPromise).toBeUndefined(); }); + it('terminates process validation restart loop after max retries when an orphan responds on the port', async () => { + // Simulate an orphan process responding on every port (isDesignTimeUp always true) + // but no tracked process (checkFuncProcessId returns false). + // This previously caused an infinite loop because stopDesignTimeApi deleted the instance, + // resetting the retry counter. The fix tracks retry counts in a separate map by projectPath. + vi.mocked(axios.get).mockResolvedValue({} as any); + + await startDesignTimeApi(createMockContext(), 'D:/workspace/app-one'); + + // maxDesignTimeValidationRestarts = 1, so it should attempt one restart then give up. + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( + expect.stringContaining('Unable to validate the func child process PID') + ); + }); + it('reuses the in-flight startup promise for concurrent calls on the same project', async () => { let rejectCreateDirectory: ((error: Error) => void) | undefined; const createDirectoryPromise = new Promise((_resolve, reject) => { @@ -176,7 +194,8 @@ describe('startAllDesignTimeApis', () => { expect(reserveFreePort).toHaveBeenCalledTimes(1); rejectCreateDirectory?.(new Error('startup still failed')); - await Promise.all([firstStart, secondStart]); + await expect(firstStart).rejects.toThrow('startup still failed'); + await expect(secondStart).rejects.toThrow('startup still failed'); const designTimeInstance = ext.designTimeInstances.get('D:/workspace/app-one'); expect(designTimeInstance?.startupPromise).toBeUndefined(); 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 41bc7a8181f..a88a2755e3e 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -52,6 +52,7 @@ import { releaseReservedPort, reserveFreePort } from '../portReservation'; import { warnIfJdbcJavaRuntimeMissing } from '../java/jdbcConnector'; const maxDesignTimeValidationRestarts = 1; +const validationRestartCounts = new Map(); function isFailingHealthCheckLogLine(line: string): boolean { const normalizedLine = line.toLowerCase(); @@ -175,10 +176,6 @@ function getDesignTimeInstance(projectPath: string): FuncInstance { return designTimeInst; } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function stopTrackedDesignTimeProcess(projectPath: string): void { const designTimeInst = ext.designTimeInstances.get(projectPath); if (!designTimeInst?.process) { @@ -205,6 +202,7 @@ export async function startDesignTimeApi(context: IActionContext, projectPath: s if (designTimeInst.startupPromise) { context.telemetry.properties.skippingAlreadyInProgress = 'true'; + context.errorHandling.suppressDisplay = true; await designTimeInst.startupPromise; return; } @@ -295,27 +293,15 @@ async function startDesignTimeApiInternal(context: IActionContext, designTimeIns } } designTimeInst.startupError = undefined; - designTimeInst.validationRetryCount = 0; + validationRestartCounts.delete(projectPath); context.telemetry.properties.didStartDesignTime = 'true'; updateFuncIgnore(projectPath, [`${designTimeDirectoryName}/`]); } catch (error) { - const errorMessage = getErrorMessage(error); - const viewOutput: MessageItem = { title: localize('viewOutput', 'View output') }; - const message = localize('DesignTimeError', "Can't start the background design-time process.") + errorMessage; + const errorMessage = error instanceof Error ? error.message : String(error); designTimeInst.startupError = errorMessage; - designTimeInst.validationRetryCount = 0; + validationRestartCounts.delete(projectPath); stopTrackedDesignTimeProcess(projectPath); - context.telemetry.properties.result = 'Failed'; - context.telemetry.properties.errorMessage = errorMessage; - ext.outputChannel.appendLog( - localize('designTimeApiFailed', 'Design-time startup failed for project "{0}". Error: {1}', projectPath, errorMessage) - ); - - window.showErrorMessage(message, viewOutput).then(async (result) => { - if (result === viewOutput) { - ext.outputChannel.show(); - } - }); + throw error; } finally { designTimeInst.isStarting = false; } @@ -355,13 +341,13 @@ async function validateRunningFuncProcess(projectPath: string): Promise { if (correctFuncProcess) { processValidationCache.set(projectPath, { timestamp: now, isValid: true }); - designTimeInst.validationRetryCount = 0; + validationRestartCounts.delete(projectPath); return; } - const retryCount = designTimeInst.validationRetryCount ?? 0; + const retryCount = validationRestartCounts.get(projectPath) ?? 0; if (retryCount >= maxDesignTimeValidationRestarts) { - designTimeInst.validationRetryCount = 0; + validationRestartCounts.delete(projectPath); ext.outputChannel.appendLog( localize( 'invalidChildFuncPidSkipRestart', @@ -373,7 +359,7 @@ async function validateRunningFuncProcess(projectPath: string): Promise { return; } - designTimeInst.validationRetryCount = retryCount + 1; + validationRestartCounts.set(projectPath, retryCount + 1); ext.outputChannel.appendLog( localize( 'invalidChildFuncPid', @@ -662,7 +648,7 @@ export function scheduleStartAllDesignTimeApis(): void { ); startAllDesignTimeApis().catch((error) => { ext.outputChannel.appendLog( - localize('scheduleAllDesignTimeApisFailed', 'Background design-time startup encountered an error. Error: {0}', getErrorMessage(error)) + localize('scheduleAllDesignTimeApisFailed', 'Background design-time startup encountered an error. Error: {0}', error instanceof Error ? error.message : String(error)) ); }); } @@ -672,23 +658,25 @@ export function scheduleStartAllDesignTimeApis(): void { * @returns {Promise} A promise that resolves when each design-time API is in the starting state. */ export async function startAllDesignTimeApis(): Promise { - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { - const projectPaths = await getWorkspaceLogicAppRoots(); - ext.outputChannel.appendLog( - localize( - 'startingAllDesignTimeApis', - 'Starting design-time APIs for {0} Logic App project(s) in the current workspace.', - projectPaths.length - ) - ); - await Promise.all(projectPaths.map(async (projectPath) => { - await callWithTelemetryAndErrorHandling('startAllDesignTimeApis.startDesignTimeApi', async (actionContext: IActionContext) => { - await startDesignTimeApi(actionContext, projectPath); - }); - })); - } else { - ext.outputChannel.appendLog(localize('noWorkspaceFoldersForDesignTime', 'No workspace folders found. Skipping design-time startup.')); + const projectPaths = await getWorkspaceLogicAppRoots(); + if (projectPaths.length === 0) { + ext.outputChannel.appendLog(localize('noLogicAppsFound', 'No Logic App projects found in the current workspace, skipping design-time startup.')); + return; } + + ext.outputChannel.appendLog( + localize( + 'startingAllDesignTimeApis', + 'Starting design-time processes for {0} Logic App project(s) in the current workspace.', + projectPaths.length + ) + ); + + await Promise.all(projectPaths.map(async (projectPath) => { + await callWithTelemetryAndErrorHandling('startAllDesignTimeApis.startDesignTimeApi', async (actionContext: IActionContext) => { + await startDesignTimeApi(actionContext, projectPath); + }); + })); } /** From 34b3b0c65bcad712733949777298d2b20b2d8384 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 18 Aug 2026 12:53:41 -0400 Subject: [PATCH 07/23] enforce startDesignTimeApi recursion limit for retries, remove unused extension setting --- .../__test__/startDesignTimeApi.test.ts | 38 ++++++++++--------- .../app/utils/codeless/startDesignTimeApi.ts | 34 ++++++++--------- .../src/app/utils/telemetry.ts | 1 - apps/vs-code-designer/src/constants.ts | 1 - .../src/extensionVariables.ts | 1 - apps/vs-code-designer/src/package.json | 5 --- .../test/ui/azuriteAutostartFailure.test.ts | 2 - .../ui/azuriteAutostartFailureAssert.test.ts | 2 - apps/vs-code-designer/src/test/ui/run-e2e.ts | 2 - 9 files changed, 37 insertions(+), 49 deletions(-) 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 fc92eb0d1c7..a55f18c0164 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 @@ -101,17 +101,6 @@ describe('startAllDesignTimeApis', () => { vi.mocked(reserveFreePort).mockImplementation(async () => nextPort++); }); - it('logs and exits when no workspace folders are available', async () => { - vi.mocked(workspaceUtils.getWorkspaceLogicAppRoots).mockResolvedValue([]); - - await startAllDesignTimeApis(); - - expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( - 'No Logic App projects found in the current workspace, skipping design-time startup.' - ); - expect(reserveFreePort).not.toHaveBeenCalled(); - }); - it('logs zero-project startup when the workspace contains no Logic App folders', async () => { (workspace as any).workspaceFolders = [{ uri: { fsPath: 'D:/workspace' } }]; vi.mocked(workspaceUtils.getWorkspaceLogicAppRoots).mockResolvedValue([]); @@ -166,16 +155,31 @@ describe('startAllDesignTimeApis', () => { expect(designTimeInstance?.startupPromise).toBeUndefined(); }); - it('terminates process validation restart loop after max retries when an orphan responds on the port', async () => { - // Simulate an orphan process responding on every port (isDesignTimeUp always true) - // but no tracked process (checkFuncProcessId returns false). - // This previously caused an infinite loop because stopDesignTimeApi deleted the instance, - // resetting the retry counter. The fix tracks retry counts in a separate map by projectPath. + it('restarts design-time when process validation detects an invalid func process', async () => { + // Simulate: first port has an orphan responding (isDesignTimeUp true, but no tracked process), + // after restart the new port has nothing responding so full startup path runs (and fails due + // to the default createDirectory rejection mock). This verifies the restart logic works without + // entering an infinite loop. + vi.mocked(axios.get) + .mockResolvedValueOnce({} as any) // first port: orphan responds + .mockRejectedValue(new Error('API not ready')); // new port: nothing responding + + await expect(startDesignTimeApi(createMockContext(), 'D:/workspace/app-one')).rejects.toThrow(); + + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( + 'Invalid func child process PID set for project at "D:/workspace/app-one". Restarting workflow design-time API.' + ); + // reserveFreePort called twice: once for first attempt, once for restart + expect(reserveFreePort).toHaveBeenCalledTimes(2); + }); + + it('stops retrying process validation after exceeding max retry limit', async () => { + // Simulate: orphan responds on every port (pathological case). + // The retry limit prevents infinite recursion. vi.mocked(axios.get).mockResolvedValue({} as any); await startDesignTimeApi(createMockContext(), 'D:/workspace/app-one'); - // maxDesignTimeValidationRestarts = 1, so it should attempt one restart then give up. expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( expect.stringContaining('Unable to validate the func child process PID') ); 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 a88a2755e3e..6fa3bda18be 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -37,8 +37,7 @@ import * as cp from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import * as vscode from 'vscode'; -import { Uri, window, workspace, type MessageItem } from 'vscode'; +import { Uri, workspace } from 'vscode'; import { findChildProcess } from '../../commands/pickFuncProcess'; import find_process from 'find-process'; import { getChildProcesses } from '../findChildProcess/findChildProcess'; @@ -51,8 +50,7 @@ import { import { releaseReservedPort, reserveFreePort } from '../portReservation'; import { warnIfJdbcJavaRuntimeMissing } from '../java/jdbcConnector'; -const maxDesignTimeValidationRestarts = 1; -const validationRestartCounts = new Map(); +const maxValidationRestarts = 3; function isFailingHealthCheckLogLine(line: string): boolean { const normalizedLine = line.toLowerCase(); @@ -196,7 +194,13 @@ function stopTrackedDesignTimeProcess(projectPath: string): void { } } -export async function startDesignTimeApi(context: IActionContext, projectPath: string): Promise { +/** + * Starts the design-time API for the given project path. + * @param context - The action context for telemetry. + * @param projectPath - The Logic App project path. + * @param currRetry - Current retry depth from process-validation restarts. Defaults to 0. + */ +export async function startDesignTimeApi(context: IActionContext, projectPath: string, currRetry = 0): Promise { context.telemetry.properties.projectPath = projectPath; const designTimeInst = getDesignTimeInstance(projectPath); @@ -207,11 +211,11 @@ export async function startDesignTimeApi(context: IActionContext, projectPath: s return; } - designTimeInst.startupPromise = startDesignTimeApiInternal(context, designTimeInst, projectPath); + designTimeInst.startupPromise = startDesignTimeApiInternal(context, designTimeInst, projectPath, currRetry); await designTimeInst.startupPromise; } -async function startDesignTimeApiInternal(context: IActionContext, designTimeInst: FuncInstance, projectPath: string): Promise { +async function startDesignTimeApiInternal(context: IActionContext, designTimeInst: FuncInstance, projectPath: string, currRetry: number): Promise { try { context.telemetry.properties.didStartDesignTime = 'false'; @@ -226,7 +230,7 @@ async function startDesignTimeApiInternal(context: IActionContext, designTimeIns if (await isDesignTimeUp(url)) { designTimeInst.isStarting = false; context.telemetry.properties.isDesignTimeUp = 'true'; - await validateRunningFuncProcess(projectPath); + await validateRunningFuncProcess(projectPath, currRetry); return; } @@ -293,13 +297,11 @@ async function startDesignTimeApiInternal(context: IActionContext, designTimeIns } } designTimeInst.startupError = undefined; - validationRestartCounts.delete(projectPath); context.telemetry.properties.didStartDesignTime = 'true'; updateFuncIgnore(projectPath, [`${designTimeDirectoryName}/`]); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); designTimeInst.startupError = errorMessage; - validationRestartCounts.delete(projectPath); stopTrackedDesignTimeProcess(projectPath); throw error; } finally { @@ -325,7 +327,7 @@ function extractPinnedVersion(input: string): string | null { return null; } -async function validateRunningFuncProcess(projectPath: string): Promise { +async function validateRunningFuncProcess(projectPath: string, currRetry: number): Promise { const designTimeInst = ext.designTimeInstances.get(projectPath); if (!designTimeInst) { return; @@ -341,25 +343,21 @@ async function validateRunningFuncProcess(projectPath: string): Promise { if (correctFuncProcess) { processValidationCache.set(projectPath, { timestamp: now, isValid: true }); - validationRestartCounts.delete(projectPath); return; } - const retryCount = validationRestartCounts.get(projectPath) ?? 0; - if (retryCount >= maxDesignTimeValidationRestarts) { - validationRestartCounts.delete(projectPath); + if (currRetry >= maxValidationRestarts) { ext.outputChannel.appendLog( localize( 'invalidChildFuncPidSkipRestart', 'Unable to validate the func child process PID for project at "{0}" after {1} restart attempt(s). Keeping the current design-time host running.', projectPath, - retryCount + currRetry ) ); return; } - validationRestartCounts.set(projectPath, retryCount + 1); ext.outputChannel.appendLog( localize( 'invalidChildFuncPid', @@ -370,7 +368,7 @@ async function validateRunningFuncProcess(projectPath: string): Promise { processValidationCache.delete(projectPath); await stopDesignTimeApi(projectPath); await callWithTelemetryAndErrorHandling('validateRunningFuncProcess.startDesignTimeApi', async (actionContext: IActionContext) => { - await startDesignTimeApi(actionContext, projectPath); + await startDesignTimeApi(actionContext, projectPath, currRetry + 1); }); } diff --git a/apps/vs-code-designer/src/app/utils/telemetry.ts b/apps/vs-code-designer/src/app/utils/telemetry.ts index 528ad11294b..12a93c7d06d 100644 --- a/apps/vs-code-designer/src/app/utils/telemetry.ts +++ b/apps/vs-code-designer/src/app/utils/telemetry.ts @@ -42,7 +42,6 @@ export const logExtensionSettings = (context: IActionContext) => { 'autoStartAzurite', 'autoStartDesignTime', 'parameterizeConnectionsInProjectLoad', - 'showStartDesignTimeMessage', 'validateDotNetSDK', 'stopFuncTaskPostDebug', ]; diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index c74d14743e0..c314491b110 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -298,7 +298,6 @@ export const pickProcessTimeoutSetting = 'pickProcessTimeout'; export const show64BitWarningSetting = 'show64BitWarning'; export const enableProjectConsistencyChecksSetting = 'enableProjectConsistencyChecks'; export const showTargetFrameworkWarningSetting = 'showTargetFrameworkWarning'; -export const showStartDesignTimeMessageSetting = 'showStartDesignTimeMessage'; export const autoStartDesignTimeSetting = 'autoStartDesignTime'; export const useNodeDesignTimeWorkerSetting = 'useNodeDesignTimeWorker'; export const autoRuntimeDependenciesValidationAndInstallationSetting = 'autoRuntimeDependenciesValidationAndInstallation'; diff --git a/apps/vs-code-designer/src/extensionVariables.ts b/apps/vs-code-designer/src/extensionVariables.ts index 809eb667dea..c16c42e4361 100644 --- a/apps/vs-code-designer/src/extensionVariables.ts +++ b/apps/vs-code-designer/src/extensionVariables.ts @@ -29,7 +29,6 @@ export type FuncInstance = { isStarting?: boolean; startupError?: string; startupPromise?: Promise; - validationRetryCount?: number; }; // biome-ignore lint/style/noNamespace: diff --git a/apps/vs-code-designer/src/package.json b/apps/vs-code-designer/src/package.json index 787eb1b79ca..718772c18c0 100644 --- a/apps/vs-code-designer/src/package.json +++ b/apps/vs-code-designer/src/package.json @@ -1003,11 +1003,6 @@ "description": "Show a warning after detecting an Azure Functions .NET project with mismatched target frameworks.", "default": true }, - "azureLogicAppsStandard.showStartDesignTimeMessage": { - "type": "boolean", - "description": "Show a message asking customers if they want to start the background design-time process at project load time.", - "default": true - }, "azureLogicAppsStandard.autoStartDesignTime": { "type": "boolean", "description": "Start background design-time process at project load time.", diff --git a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts index 0647ffd78df..43357e9700f 100644 --- a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts +++ b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts @@ -321,7 +321,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { 'azureLogicAppsStandard.autoStartAzurite': true, 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, - 'azureLogicAppsStandard.showStartDesignTimeMessage': false, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, 'azureFunctions.suppressProject': true, @@ -336,7 +335,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { 'azureLogicAppsStandard.autoStartAzurite': true, 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, - 'azureLogicAppsStandard.showStartDesignTimeMessage': false, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, 'azureFunctions.suppressProject': true, diff --git a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts index 73433d6e4a1..ce8d222d6ea 100644 --- a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts +++ b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts @@ -151,7 +151,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { 'azureLogicAppsStandard.autoStartAzurite': true, 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, - 'azureLogicAppsStandard.showStartDesignTimeMessage': false, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, 'azureFunctions.suppressProject': true, @@ -166,7 +165,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { 'azureLogicAppsStandard.autoStartAzurite': true, 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, - 'azureLogicAppsStandard.showStartDesignTimeMessage': false, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, 'azureFunctions.suppressProject': true, diff --git a/apps/vs-code-designer/src/test/ui/run-e2e.ts b/apps/vs-code-designer/src/test/ui/run-e2e.ts index 68479067cd2..c8db6c01cca 100644 --- a/apps/vs-code-designer/src/test/ui/run-e2e.ts +++ b/apps/vs-code-designer/src/test/ui/run-e2e.ts @@ -1314,8 +1314,6 @@ async function main(): Promise { // Design-time auto-start: ON for tests that need the runtime (designer, run), // OFF for tests that only check UI/conversion to save startup time. 'azureLogicAppsStandard.autoStartDesignTime': autoStartDesignTime, - // Suppress the "Start design time?" prompt dialog on project load. - 'azureLogicAppsStandard.showStartDesignTimeMessage': false, // Suppress "wants to sign in" auth dialog — uses silent auth that // returns undefined instead of prompting when no cached token exists. 'azureLogicAppsStandard.silentAuth': true, From 39fac92fdfc59b2de114e722f958953117ef7b8f Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 18 Aug 2026 13:12:21 -0400 Subject: [PATCH 08/23] remove unused settings --- .../src/app/utils/azurite/activateAzurite.ts | 1 - apps/vs-code-designer/src/constants.ts | 1 - apps/vs-code-designer/src/package.json | 20 ------------------- 3 files changed, 22 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts b/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts index 9442b31ff97..1f60d2fcf4f 100644 --- a/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts +++ b/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts @@ -9,7 +9,6 @@ import { azuriteExtensionPrefix, azuriteLocationSetting, defaultAzuritePathValue, - extensionCommand, showAutoStartAzuriteWarning, } from '../../../constants'; import { ext } from '../../../extensionVariables'; diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index c314491b110..7c63da3972f 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -297,7 +297,6 @@ export const preDeployTaskSetting = 'preDeployTask'; export const pickProcessTimeoutSetting = 'pickProcessTimeout'; export const show64BitWarningSetting = 'show64BitWarning'; export const enableProjectConsistencyChecksSetting = 'enableProjectConsistencyChecks'; -export const showTargetFrameworkWarningSetting = 'showTargetFrameworkWarning'; export const autoStartDesignTimeSetting = 'autoStartDesignTime'; export const useNodeDesignTimeWorkerSetting = 'useNodeDesignTimeWorker'; export const autoRuntimeDependenciesValidationAndInstallationSetting = 'autoRuntimeDependenciesValidationAndInstallation'; diff --git a/apps/vs-code-designer/src/package.json b/apps/vs-code-designer/src/package.json index 718772c18c0..c4f06d1590b 100644 --- a/apps/vs-code-designer/src/package.json +++ b/apps/vs-code-designer/src/package.json @@ -841,11 +841,6 @@ { "title": "Azure Logic Apps (Standard)", "properties": { - "azureLogicAppsStandard.showExplorer": { - "type": "boolean", - "default": true, - "description": "Show or hide the Azure Functions Explorer" - }, "azureLogicAppsStandard.projectRuntime": { "scope": "resource", "type": "string", @@ -968,11 +963,6 @@ "description": "Enable remote debugging for Node.js Logic Apps running on Linux App Service plans. Consumption plans are not supported. (experimental)", "default": false }, - "azureLogicAppsStandard.enableOutputTimestamps": { - "type": "boolean", - "description": "Prepends each line displayed in the output channel with a timestamp.", - "default": true - }, "azureLogicAppsStandard.preDeployTask": { "scope": "resource", "type": "string", @@ -988,21 +978,11 @@ "description": "Show a warning to install a 64-bit version of the Azure Functions Core Tools when you create a .NET Framework project.", "default": true }, - "azureLogicAppsStandard.showDeploySubpathWarning": { - "type": "boolean", - "description": "Show a warning when the \"deploySubpath\" setting does not match the selected folder for deploying.", - "default": true - }, "azureLogicAppsStandard.enableProjectConsistencyChecks": { "type": "boolean", "description": "Run consistency check on project files and .vscode configuration on startup and prompt to regenerate when files missing or out of date.", "default": true }, - "azureLogicAppsStandard.showTargetFrameworkWarning": { - "type": "boolean", - "description": "Show a warning after detecting an Azure Functions .NET project with mismatched target frameworks.", - "default": true - }, "azureLogicAppsStandard.autoStartDesignTime": { "type": "boolean", "description": "Start background design-time process at project load time.", From 265cfcfa5afea7408d91ad6d598643713fbd63e5 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 18 Aug 2026 14:20:40 -0400 Subject: [PATCH 09/23] update remaining suppress notifications to use global state, remove unneeded settings --- .../validateFuncCoreToolsIsLatest.test.ts | 4 ++ .../validateFuncCoreToolsIsLatest.ts | 8 +-- .../__test__/validateNodeJsIsLatest.test.ts | 36 +++++----- .../commands/nodeJs/validateNodeJsIsLatest.ts | 12 ++-- .../src/app/state/notifications.ts | 45 +++++++++++++ .../azurite/__test__/activateAzurite.test.ts | 65 ++++++++++++------- .../src/app/utils/azurite/activateAzurite.ts | 9 +-- apps/vs-code-designer/src/constants.ts | 4 +- apps/vs-code-designer/src/package.json | 15 ----- .../test/ui/azuriteAutostartFailure.test.ts | 2 - .../ui/azuriteAutostartFailureAssert.test.ts | 2 - apps/vs-code-designer/src/test/ui/run-e2e.ts | 2 - 12 files changed, 125 insertions(+), 79 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts index e4e1bd58acd..356350d8e23 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts @@ -34,6 +34,10 @@ vi.mock('../../../utils/codeless/startDesignTimeApi', () => ({ // Without these, the real code runs and hits unmocked vscode APIs (e.g. workspace.getConfiguration). vi.mock('../../../utils/devContainerUtils'); vi.mock('../../../utils/vsCodeConfig/settings'); +vi.mock('../../../state/notifications', () => ({ + isMultiCoreToolsWarningSuppressed: vi.fn(() => true), + suppressMultiCoreToolsWarning: vi.fn(), +})); vi.mock('../../../utils/funcCoreTools/funcVersion'); vi.mock('../installFuncCoreTools'); vi.mock('../uninstallFuncCoreTools'); 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 8d7dec684d7..06a00dd2263 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -13,7 +13,8 @@ import { getBrewPackageName } from '../../utils/funcCoreTools/getBrewPackageName import { getFuncPackageManagers } from '../../utils/funcCoreTools/getFuncPackageManagers'; import { getNpmDistTag } from '../../utils/funcCoreTools/getNpmDistTag'; import { sendRequestWithExtTimeout } from '../../utils/requestUtils'; -import { getWorkspaceSetting, updateGlobalSetting } from '../../utils/vsCodeConfig/settings'; +import { getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; +import { isMultiCoreToolsWarningSuppressed, suppressMultiCoreToolsWarning } from '../../state/notifications'; import { installFuncCoreToolsBinaries } from './installFuncCoreTools'; import { uninstallFuncCoreTools } from './uninstallFuncCoreTools'; import { updateFuncCoreTools } from './updateFuncCoreTools'; @@ -74,8 +75,7 @@ async function validateFuncCoreToolsIsLatestSystem(context: IActionContext): Pro context.errorHandling.suppressDisplay = true; context.telemetry.properties.isActivationEvent = 'true'; - const showMultiCoreToolsWarningKey = 'showMultiCoreToolsWarning'; - const showMultiCoreToolsWarning = !!getWorkspaceSetting(showMultiCoreToolsWarningKey); + const showMultiCoreToolsWarning = !isMultiCoreToolsWarningSuppressed(); if (showMultiCoreToolsWarning) { const packageManagers: PackageManager[] = await getFuncPackageManagers(true /* isFuncInstalled */); @@ -98,7 +98,7 @@ async function validateFuncCoreToolsIsLatestSystem(context: IActionContext): Pro if (result === selectUninstall) { await executeOnFunctions(uninstallFuncCoreTools, context, packageManagers); } else if (result === DialogResponses.dontWarnAgain) { - await updateGlobalSetting(showMultiCoreToolsWarningKey, false); + await suppressMultiCoreToolsWarning(); } } 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 2dbbe73fd8f..164ac15e372 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 @@ -7,6 +7,7 @@ import { binariesExist, getLatestNodeJsVersion, verifyDependencyIntegrity } from import { shouldCheckForDependencyUpdates } from '../../../state/dependencies'; import { getLocalNodeJsVersion, getNodeJsCommand, setNodeJsCommand } from '../../../utils/nodeJs/nodeJsVersion'; import { getWorkspaceSetting, updateGlobalSetting } from '../../../utils/vsCodeConfig/settings'; +import { suppressNodeJsWarning, isNodeJsWarningSuppressed } from '../../../state/notifications'; import { installNodeJs } from '../installNodeJs'; import { validateNodeJsIsLatest } from '../validateNodeJsIsLatest'; @@ -53,6 +54,11 @@ vi.mock('../../../utils/vsCodeConfig/settings', () => ({ updateGlobalSetting: vi.fn(), })); +vi.mock('../../../state/notifications', () => ({ + isNodeJsWarningSuppressed: vi.fn(() => true), + suppressNodeJsWarning: vi.fn(), +})); + vi.mock('../installNodeJs', () => ({ installNodeJs: vi.fn(), })); @@ -76,7 +82,7 @@ describe('validateNodeJsIsLatest', () => { vi.clearAllMocks(); context = createContext(); vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined); - vi.mocked(getWorkspaceSetting).mockReturnValue(false); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(true); vi.mocked(getNodeJsCommand).mockReturnValue('node'); vi.mocked(setNodeJsCommand).mockResolvedValue(undefined); vi.mocked(installNodeJs).mockResolvedValue(undefined); @@ -101,7 +107,7 @@ describe('validateNodeJsIsLatest', () => { }); it('does not reinstall when binaries exist and the on-disk integrity check passes', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(verifyDependencyIntegrity).mockReturnValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); @@ -147,7 +153,7 @@ describe('validateNodeJsIsLatest', () => { }); it('checks latest version only when binaries are present and warnings are enabled', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.0.0'); @@ -166,7 +172,7 @@ describe('validateNodeJsIsLatest', () => { }); it('does not block validation when the outdated Node.js warning is unanswered', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); @@ -184,7 +190,7 @@ describe('validateNodeJsIsLatest', () => { }); it('shows the outdated Node.js warning when the target version includes minor and patch', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.20.8'); @@ -200,7 +206,7 @@ describe('validateNodeJsIsLatest', () => { }); it('uses the dependency feed target for a newer same-major minor Node.js warning', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('20.18.3'); @@ -221,7 +227,7 @@ describe('validateNodeJsIsLatest', () => { }); it('shows the outdated Node.js warning for a newer target major version', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.20.8'); @@ -237,7 +243,7 @@ describe('validateNodeJsIsLatest', () => { }); 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(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('20.18.3'); @@ -248,7 +254,7 @@ describe('validateNodeJsIsLatest', () => { }); it('updates the warning setting from the nonblocking outdated Node.js prompt callback', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); @@ -257,11 +263,11 @@ describe('validateNodeJsIsLatest', () => { await validateNodeJsIsLatest(context, '18'); await flushPromises(); - expect(updateGlobalSetting).toHaveBeenCalledWith('showNodeJsWarning', false); + expect(suppressNodeJsWarning).toHaveBeenCalled(); }); 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(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); @@ -291,7 +297,7 @@ describe('validateNodeJsIsLatest', () => { }); it('opens learn more from the nonblocking outdated Node.js prompt callback', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); @@ -305,7 +311,7 @@ describe('validateNodeJsIsLatest', () => { }); it('surfaces update failures from the nonblocking outdated Node.js prompt callback', async () => { - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.1.0'); @@ -327,7 +333,7 @@ describe('validateNodeJsIsLatest', () => { it('skips the newest-version lookup and warning when the update check is throttled', async () => { vi.mocked(shouldCheckForDependencyUpdates).mockReturnValue(false); - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); @@ -342,7 +348,7 @@ describe('validateNodeJsIsLatest', () => { it('still reinstalls a missing local Node.js version when the update check is throttled', async () => { vi.mocked(shouldCheckForDependencyUpdates).mockReturnValue(false); - vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(isNodeJsWarningSuppressed).mockReturnValue(false); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(getLocalNodeJsVersion).mockResolvedValue(null); 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 d7ef7e2d381..927c9f585db 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts @@ -8,7 +8,7 @@ import { localize } from '../../../localize'; import { binariesExist, getLatestNodeJsVersion, verifyDependencyIntegrity } from '../../utils/binaries'; import { shouldCheckForDependencyUpdates } from '../../state/dependencies'; import { getLocalNodeJsVersion, getNodeJsCommand, setNodeJsCommand } from '../../utils/nodeJs/nodeJsVersion'; -import { getWorkspaceSetting, updateGlobalSetting } from '../../utils/vsCodeConfig/settings'; +import { isNodeJsWarningSuppressed, suppressNodeJsWarning } from '../../state/notifications'; import { installNodeJs } from './installNodeJs'; import { DialogResponses, openUrl } from '@microsoft/vscode-azext-utils'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; @@ -18,8 +18,7 @@ import { ProgressLocation, window, type MessageItem } from 'vscode'; export async function validateNodeJsIsLatest(context: IActionContext, majorVersion?: string): Promise { context.errorHandling.suppressDisplay = true; context.telemetry.properties.isActivationEvent = 'true'; - const showNodeJsWarningKey = 'showNodeJsWarning'; - const showNodeJsWarning = !!getWorkspaceSetting(showNodeJsWarningKey); + const showNodeJsWarning = !isNodeJsWarningSuppressed(); await setNodeJsCommand(); const binaries = await binariesExist(nodeJsDependencyName); context.telemetry.properties.binariesExist = `${binaries}`; @@ -50,7 +49,7 @@ export async function validateNodeJsIsLatest(context: IActionContext, majorVersi if (shouldShowOutdatedNodeJsWarning(localVersion, newestVersion, majorVersion)) { context.telemetry.properties.nodeJsWarningDecision = 'shown'; context.telemetry.properties.outOfDateNodeJs = 'true'; - showOutdatedNodeJsWarning(context, localVersion, newestVersion, majorVersion, showNodeJsWarningKey); + showOutdatedNodeJsWarning(context, localVersion, newestVersion, majorVersion); } logNodeJsWarningDecision(context); } else { @@ -145,8 +144,7 @@ function showOutdatedNodeJsWarning( context: IActionContext, localVersion: string, newestVersion: string, - majorVersion: string | undefined, - showNodeJsWarningKey: string + majorVersion: string | undefined ): void { const message: string = localize( 'outdatedNodeJsRuntime', @@ -164,7 +162,7 @@ function showOutdatedNodeJsWarning( } else if (result === update) { await updateNodeJsFromWarning(context, majorVersion); } else if (result === DialogResponses.dontWarnAgain) { - await updateGlobalSetting(showNodeJsWarningKey, false); + await suppressNodeJsWarning(); } }) .catch((error) => { diff --git a/apps/vs-code-designer/src/app/state/notifications.ts b/apps/vs-code-designer/src/app/state/notifications.ts index 1babb78308a..4d375660b55 100644 --- a/apps/vs-code-designer/src/app/state/notifications.ts +++ b/apps/vs-code-designer/src/app/state/notifications.ts @@ -4,12 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import { ext } from '../../extensionVariables'; import { + suppressAutoStartAzuriteNotificationState, suppressAutoStartDesignTimeNotificationState, suppressDesignerVersionNotificationState, suppressManagedIdentityAuthNotificationState, + suppressMultiCoreToolsWarningState, + suppressNodeJsWarningState, suppressParameterizeConnectionsNotificationState, } from '../../constants'; +/** + * Whether the user has permanently dismissed the auto-start Azurite prompt. + */ +export function isAutoStartAzuriteNotificationSuppressed(): boolean { + return ext.context.globalState.get(suppressAutoStartAzuriteNotificationState) === true; +} + +/** + * Permanently suppresses the auto-start Azurite prompt. + */ +export async function suppressAutoStartAzuriteNotification(): Promise { + await ext.context.globalState.update(suppressAutoStartAzuriteNotificationState, true); +} + /** * Whether the user has permanently dismissed the auto-start design-time startup prompt. */ @@ -24,6 +41,34 @@ export async function suppressAutoStartDesignTimeNotification(): Promise { await ext.context.globalState.update(suppressAutoStartDesignTimeNotificationState, true); } +/** + * Whether the user has permanently dismissed the multiple func core tools warning. + */ +export function isMultiCoreToolsWarningSuppressed(): boolean { + return ext.context.globalState.get(suppressMultiCoreToolsWarningState) === true; +} + +/** + * Permanently suppresses the multiple func core tools warning. + */ +export async function suppressMultiCoreToolsWarning(): Promise { + await ext.context.globalState.update(suppressMultiCoreToolsWarningState, true); +} + +/** + * Whether the user has permanently dismissed the Node.js update warning. + */ +export function isNodeJsWarningSuppressed(): boolean { + return ext.context.globalState.get(suppressNodeJsWarningState) === true; +} + +/** + * Permanently suppresses the Node.js update warning. + */ +export async function suppressNodeJsWarning(): Promise { + await ext.context.globalState.update(suppressNodeJsWarningState, true); +} + /** * Whether the user has permanently dismissed the parameterize-connections startup prompt. */ diff --git a/apps/vs-code-designer/src/app/utils/azurite/__test__/activateAzurite.test.ts b/apps/vs-code-designer/src/app/utils/azurite/__test__/activateAzurite.test.ts index 45b9f9a8cc6..a30d7795dca 100644 --- a/apps/vs-code-designer/src/app/utils/azurite/__test__/activateAzurite.test.ts +++ b/apps/vs-code-designer/src/app/utils/azurite/__test__/activateAzurite.test.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { + autoStartAzuriteSetting, azuriteBinariesLocationSetting, azuriteExtensionPrefix, azuriteLocationSetting, defaultAzuritePathValue, extensionCommand, localEmulatorConnectionString, - showAutoStartAzuriteWarning, } from '../../../../constants'; // Distinct sentinels so the function's `result === DialogResponses.*` comparisons are meaningful. @@ -42,6 +42,11 @@ vi.mock('../../vsCodeConfig/settings', () => ({ removeSharedSetting: vi.fn(), })); +vi.mock('../../../state/notifications', () => ({ + isAutoStartAzuriteNotificationSuppressed: vi.fn(() => false), + suppressAutoStartAzuriteNotification: vi.fn(), +})); + vi.mock('../../workspace', () => ({ getWorkspaceFolder: vi.fn(), })); @@ -69,6 +74,7 @@ vi.mock('../../delay', () => ({ import * as vscode from 'vscode'; import { activateAzurite, azuriteStartupRetryCount, azuriteStartupRetryDelayMs } from '../activateAzurite'; import { getWorkspaceSetting, updateGlobalSetting, removeSharedSetting } from '../../vsCodeConfig/settings'; +import { isAutoStartAzuriteNotificationSuppressed, suppressAutoStartAzuriteNotification } from '../../../state/notifications'; import { getWorkspaceFolder } from '../../workspace'; import { tryGetLogicAppProjectRoot } from '../../verifyIsProject'; import { getAzureWebJobsStorage } from '../../appSettings/localSettings'; @@ -105,7 +111,6 @@ const MISSING_EXTENSION_FAILURE = 'Azurite extension is not installed or is unav function mockSettings(values: { globalAzuriteLocation?: string; binariesLocation?: string; - showWarning?: boolean; autoStart?: boolean; }) { (getWorkspaceSetting as any).mockImplementation((section: string) => { @@ -114,8 +119,6 @@ function mockSettings(values: { return values.globalAzuriteLocation; case azuriteBinariesLocationSetting: return values.binariesLocation; - case showAutoStartAzuriteWarning: - return values.showWarning; default: return values.autoStart; } @@ -170,18 +173,18 @@ describe('activateAzurite', () => { }); it('only disables the warning when the user selects "Don\'t warn again"', async () => { - mockSettings({ showWarning: true, autoStart: false }); + mockSettings({ autoStart: false }); const showWarningMessage = vi.fn().mockResolvedValue(dialogDontWarnAgain); (validateEmulatorIsRunning as any).mockResolvedValue(false); await activateAzurite(createContext({ showWarningMessage }), PROJECT_PATH); - expect(updateGlobalSetting).toHaveBeenCalledWith(showAutoStartAzuriteWarning, false); + expect(suppressAutoStartAzuriteNotification).toHaveBeenCalled(); expect(executeOnAzurite).not.toHaveBeenCalled(); }); it('enables autostart and stores the user-provided azurite directory', async () => { - mockSettings({ showWarning: true, autoStart: false, binariesLocation: undefined }); + mockSettings({ autoStart: false, binariesLocation: undefined }); // showWarningMessage returns the first passed item (enableMessage) so result === enableMessage. const showWarningMessage = vi.fn().mockImplementation((_title, enableMessage) => Promise.resolve(enableMessage)); const showInputBox = vi.fn().mockResolvedValue('/custom/azurite/dir'); @@ -193,7 +196,7 @@ describe('activateAzurite', () => { }); it('enables autostart and falls back to the default path when input is cancelled', async () => { - mockSettings({ showWarning: true, autoStart: false, binariesLocation: undefined }); + mockSettings({ autoStart: false, binariesLocation: undefined }); const showWarningMessage = vi.fn().mockImplementation((_title, enableMessage) => Promise.resolve(enableMessage)); const showInputBox = vi.fn().mockResolvedValue(undefined); (validateEmulatorIsRunning as any).mockResolvedValue(true); @@ -204,7 +207,8 @@ describe('activateAzurite', () => { }); it('sets the default binaries location when the warning is off and autostart is on', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: undefined }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: undefined }); (validateEmulatorIsRunning as any).mockResolvedValue(true); await activateAzurite(createContext(), PROJECT_PATH); @@ -214,7 +218,8 @@ describe('activateAzurite', () => { }); it('writes azurite.location to global settings, strips shared copies, and starts azurite (key path)', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); // Not running initially (triggers start), then ready on the readiness poll so waitForAzuriteReady resolves. (validateEmulatorIsRunning as any).mockResolvedValueOnce(false).mockResolvedValue(true); const context = createContext(); @@ -229,7 +234,8 @@ describe('activateAzurite', () => { }); it('defaults the started azurite location when no ext location is configured', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: undefined }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: undefined }); // Not running initially (triggers start), then ready on the readiness poll so waitForAzuriteReady resolves. (validateEmulatorIsRunning as any).mockResolvedValueOnce(false).mockResolvedValue(true); @@ -241,7 +247,8 @@ describe('activateAzurite', () => { }); it('throws when azurite never becomes ready after being started (race-condition guard)', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); // Never ready: first check triggers start, every readiness poll stays false -> waitForAzuriteReady rejects. (validateEmulatorIsRunning as any).mockResolvedValue(false); const context = createContext(); @@ -267,7 +274,8 @@ describe('activateAzurite', () => { }); it('resolves when azurite becomes ready on the final allowed attempt (off-by-one guard)', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); // 1 pre-start check + (azuriteStartupRetryCount - 1) failing polls, then ready on the last // allowed poll. A slip from `attempt <= count` to `attempt < count` would never run that poll // and would reject instead. @@ -290,7 +298,8 @@ describe('activateAzurite', () => { }); it('resolves when azurite.start rejects but the emulator is actually reachable (concurrent debug session)', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); // The realistic concurrent-session shape: a healthy Azurite is already serving another project, // so the port is bound and the third-party extension rejects azurite.start. The start command is // NOT authoritative -- the readiness probe is. Failing the debug here would be the regression. @@ -318,7 +327,8 @@ describe('activateAzurite', () => { }); it('still reports the bounded readiness error, not the raw start error, when azurite.start rejects and the emulator never comes up', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); (executeOnAzurite as any).mockRejectedValue(new Error(START_COMMAND_FAILURE)); (validateEmulatorIsRunning as any).mockResolvedValue(false); const context = createContext(); @@ -340,7 +350,8 @@ describe('activateAzurite', () => { }); it('reads AzureWebJobsStorage once while rechecking emulator readiness on each poll', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); (validateEmulatorIsRunning as any) .mockResolvedValueOnce(false) .mockResolvedValueOnce(false) @@ -370,7 +381,7 @@ describe('activateAzurite', () => { }); it('enables autostart without prompting for a directory when an ext location already exists', async () => { - mockSettings({ showWarning: true, autoStart: false, binariesLocation: '/ext/azurite/loc' }); + mockSettings({ autoStart: false, binariesLocation: '/ext/azurite/loc' }); const showWarningMessage = vi.fn().mockImplementation((_title, enableMessage) => Promise.resolve(enableMessage)); const showInputBox = vi.fn(); (validateEmulatorIsRunning as any).mockResolvedValue(true); @@ -379,11 +390,11 @@ describe('activateAzurite', () => { // The input box is skipped because a binaries location is already configured. expect(showInputBox).not.toHaveBeenCalled(); - expect(updateGlobalSetting).toHaveBeenCalledWith(showAutoStartAzuriteWarning, false); + expect(updateGlobalSetting).toHaveBeenCalledWith(autoStartAzuriteSetting, true); }); it('does nothing to warning/autostart settings when the user dismisses the prompt', async () => { - mockSettings({ showWarning: true, autoStart: false }); + mockSettings({ autoStart: false }); const showWarningMessage = vi.fn().mockResolvedValue(dialogNo); (validateEmulatorIsRunning as any).mockResolvedValue(true); @@ -394,7 +405,8 @@ describe('activateAzurite', () => { }); it('does not start azurite when it is already running', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); (validateEmulatorIsRunning as any).mockResolvedValue(true); await activateAzurite(createContext(), PROJECT_PATH); @@ -408,7 +420,7 @@ describe('activateAzurite', () => { // a directory and reaches the start block below. The binaries location was read into a local // before that prompt, so re-using it here would write defaultAzuritePathValue to // azurite.location and silently discard the directory the user just entered. - mockSettings({ showWarning: true, autoStart: false, binariesLocation: undefined }); + mockSettings({ autoStart: false, binariesLocation: undefined }); const showWarningMessage = vi.fn().mockImplementation((_title, enableMessage) => Promise.resolve(enableMessage)); const showInputBox = vi.fn().mockResolvedValue('/custom/azurite/dir'); // Not running, so the start block is entered; ready on the first poll so the call resolves. @@ -424,7 +436,7 @@ describe('activateAzurite', () => { }); it('starts azurite at the default path when the directory prompt is cancelled', async () => { - mockSettings({ showWarning: true, autoStart: false, binariesLocation: undefined }); + mockSettings({ autoStart: false, binariesLocation: undefined }); const showWarningMessage = vi.fn().mockImplementation((_title, enableMessage) => Promise.resolve(enableMessage)); const showInputBox = vi.fn().mockResolvedValue(undefined); (validateEmulatorIsRunning as any).mockResolvedValueOnce(false).mockResolvedValue(true); @@ -437,7 +449,8 @@ describe('activateAzurite', () => { }); it('names the terminal extension failure as the cause when the probe also never succeeds', async () => { - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); (executeOnAzurite as any).mockRejectedValue(new AzuriteExtensionTerminalError(MISSING_EXTENSION_FAILURE)); (validateEmulatorIsRunning as any).mockResolvedValue(false); const context = createContext(); @@ -458,7 +471,8 @@ describe('activateAzurite', () => { // Docker or `npm -g azurite` with the VS Code extension disabled: getExtension() returns // undefined so the start command fails terminally, yet the emulator IS serving. Failing fast on // the terminal tag would regress exactly this user, so the probe must still run and be believed. - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); (executeOnAzurite as any).mockRejectedValue(new AzuriteExtensionTerminalError(MISSING_EXTENSION_FAILURE)); (validateEmulatorIsRunning as any).mockResolvedValueOnce(false).mockResolvedValue(true); const context = createContext(); @@ -471,7 +485,8 @@ describe('activateAzurite', () => { it('leaves the generic message alone when the start rejection is not a terminal extension failure', async () => { // Fail-open guard: an unrecognised rejection must not be promoted to a cause. - mockSettings({ showWarning: false, autoStart: true, binariesLocation: '/ext/azurite/loc' }); + vi.mocked(isAutoStartAzuriteNotificationSuppressed).mockReturnValue(true); + mockSettings({ autoStart: true, binariesLocation: '/ext/azurite/loc' }); (executeOnAzurite as any).mockRejectedValue(new Error(START_COMMAND_FAILURE)); (validateEmulatorIsRunning as any).mockResolvedValue(false); const context = createContext(); diff --git a/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts b/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts index 1f60d2fcf4f..f3944a2c028 100644 --- a/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts +++ b/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts @@ -9,7 +9,6 @@ import { azuriteExtensionPrefix, azuriteLocationSetting, defaultAzuritePathValue, - showAutoStartAzuriteWarning, } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; @@ -20,6 +19,7 @@ import { getAzureWebJobsStorage } from '../appSettings/localSettings'; import { delay } from '../delay'; import { tryGetLogicAppProjectRoot } from '../verifyIsProject'; import { getWorkspaceSetting, updateGlobalSetting, removeSharedSetting } from '../vsCodeConfig/settings'; +import { isAutoStartAzuriteNotificationSuppressed, suppressAutoStartAzuriteNotification } from '../../state/notifications'; import { getWorkspaceFolder } from '../workspace'; import { DialogResponses, parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; @@ -59,12 +59,10 @@ export async function activateAzurite(context: IActionContext, projectPath?: str // written -- reading a stale copy would silently discard the directory the user just typed. let azuriteLocationExtSetting: string = getWorkspaceSetting(azuriteBinariesLocationSetting); - const showAutoStartAzuriteWarningSetting = !!getWorkspaceSetting(showAutoStartAzuriteWarning); - let autoStartAzurite = !!getWorkspaceSetting(autoStartAzuriteSetting); context.telemetry.properties.autoStartAzurite = `${autoStartAzurite}`; - if (showAutoStartAzuriteWarningSetting) { + if (!autoStartAzurite && !isAutoStartAzuriteNotificationSuppressed()) { const enableMessage: MessageItem = { title: localize('enableAutoStart', 'Enable AutoStart') }; const result = await context.ui.showWarningMessage( @@ -75,9 +73,8 @@ export async function activateAzurite(context: IActionContext, projectPath?: str ); if (result === DialogResponses.dontWarnAgain) { - await updateGlobalSetting(showAutoStartAzuriteWarning, false); + await suppressAutoStartAzuriteNotification(); } else if (result === enableMessage) { - await updateGlobalSetting(showAutoStartAzuriteWarning, false); await updateGlobalSetting(autoStartAzuriteSetting, true); autoStartAzurite = true; context.telemetry.properties.autoStartAzurite = 'true'; diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 7c63da3972f..6291f74a912 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -263,9 +263,12 @@ export type vscodeCommand = (typeof vscodeCommand)[keyof typeof vscodeCommand]; export const contextValuePrefix = 'azLogicApps'; // Global state +export const suppressAutoStartAzuriteNotificationState = 'suppressAutoStartAzuriteNotification'; export const suppressAutoStartDesignTimeNotificationState = 'suppressAutoStartDesignTimeNotification'; export const suppressDesignerVersionNotificationState = 'suppressDesignerVersionNotification'; export const suppressManagedIdentityAuthNotificationState = 'suppressManagedIdentityAuthNotification'; +export const suppressMultiCoreToolsWarningState = 'suppressMultiCoreToolsWarning'; +export const suppressNodeJsWarningState = 'suppressNodeJsWarning'; export const suppressParameterizeConnectionsNotificationState = 'suppressParameterizeConnectionsNotification'; // API @@ -303,7 +306,6 @@ export const autoRuntimeDependenciesValidationAndInstallationSetting = 'autoRunt export const azuriteBinariesLocationSetting = 'azuriteLocationSetting'; export const driveLetterSMBSetting = 'driveLetterSMB'; export const parameterizeConnectionsInProjectLoadSetting = 'parameterizeConnectionsInProjectLoad'; -export const showAutoStartAzuriteWarning = 'showAutoStartAzuriteWarning'; export const autoStartAzuriteSetting = 'autoStartAzurite'; export const autoRuntimeDependenciesPathSettingKey = 'autoRuntimeDependenciesPath'; export const dotNetBinaryPathSettingKey = 'dotnetBinaryPath'; diff --git a/apps/vs-code-designer/src/package.json b/apps/vs-code-designer/src/package.json index c4f06d1590b..6cca83bd977 100644 --- a/apps/vs-code-designer/src/package.json +++ b/apps/vs-code-designer/src/package.json @@ -943,16 +943,6 @@ "description": "Ask to confirm before deploying to a function app in Azure. Deployment overwrites any previous deployment and can't be undone.", "default": true }, - "azureLogicAppsStandard.showNodeJsWarning": { - "type": "boolean", - "description": "Show a warning when your installed version of Node JS is outdated.", - "default": true - }, - "azureLogicAppsStandard.showMultiCoreToolsWarning": { - "type": "boolean", - "description": "Show a warning when multiple installations of the Azure Functions Core Tools are found.", - "default": true - }, "azureLogicAppsStandard.requestTimeout": { "type": "number", "description": "The timeout (in seconds) to be used when making requests, for example getting the latest templates.", @@ -1004,11 +994,6 @@ "description": "Fail dependency and extension-bundle validation instead of showing success-shaped UI fallbacks. Intended for automated VS Code E2E setup only.", "default": false }, - "azureLogicAppsStandard.showAutoStartAzuriteWarning": { - "type": "boolean", - "description": "Show a warning asking if user's would like to configure Azurite auto start.", - "default": false - }, "azureLogicAppsStandard.autoStartAzurite": { "type": "boolean", "description": "Start Azurite when project starts.", diff --git a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts index 43357e9700f..0c42b3eeefb 100644 --- a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts +++ b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts @@ -319,7 +319,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { workspaceJson.settings = { ...(workspaceJson.settings ?? {}), 'azureLogicAppsStandard.autoStartAzurite': true, - 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, @@ -333,7 +332,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { writeJson(settingsPath, { ...settingsJson, 'azureLogicAppsStandard.autoStartAzurite': true, - 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, diff --git a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts index ce8d222d6ea..9c91ba70c99 100644 --- a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts +++ b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts @@ -149,7 +149,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { workspaceJson.settings = { ...(workspaceJson.settings ?? {}), 'azureLogicAppsStandard.autoStartAzurite': true, - 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, @@ -163,7 +162,6 @@ function configureGeneratedWorkspaceForAzuriteFailure(): void { writeJson(settingsPath, { ...settingsJson, 'azureLogicAppsStandard.autoStartAzurite': true, - 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, 'azureLogicAppsStandard.autoStartDesignTime': true, 'azureLogicAppsStandard.showProjectWarning': false, 'azureLogicAppsStandard.verifyConnectionKeys': false, diff --git a/apps/vs-code-designer/src/test/ui/run-e2e.ts b/apps/vs-code-designer/src/test/ui/run-e2e.ts index c8db6c01cca..01dbf336282 100644 --- a/apps/vs-code-designer/src/test/ui/run-e2e.ts +++ b/apps/vs-code-designer/src/test/ui/run-e2e.ts @@ -1322,8 +1322,6 @@ async function main(): Promise { // the generated task chain has started instead of waiting the default // 60 s. Other phases never reach pickProcess so this is harmless. 'azureLogicAppsStandard.pickProcessTimeout': 15, - // Keep dependency validation non-interactive in explicit command tests. - 'azureLogicAppsStandard.showNodeJsWarning': false, // Experimental-bundle opt-ins. Off by default for every phase so the // standard CDN flow continues to be tested. The bundleintegrityonly // phase or any future phase that wants to test a private bundle can From c3a6ea73aeca364c6f576b4e1f8cbc92b1c7c9df Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Wed, 19 Aug 2026 00:20:12 -0400 Subject: [PATCH 10/23] fix ensureWorkspace create new workspace: default to current folder for workspace, prevent selecting child paths --- .../src/app/commands/ensureWorkspace.ts | 141 ++++++++++++------ .../app/createWorkspace/createWorkspace.tsx | 22 ++- .../steps/workspaceNameStep.tsx | 25 +++- .../app/createWorkspace/utils/validation.ts | 18 +++ apps/vs-code-react/src/intl/messages.ts | 6 + .../src/state/createWorkspaceSlice.ts | 23 ++- 6 files changed, 178 insertions(+), 57 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts index fdfab5ce9be..60ccffeece1 100644 --- a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts @@ -21,6 +21,7 @@ import { ext } from '../../extensionVariables'; import * as fse from 'fs-extra'; import * as path from 'path'; import { createWorkspaceWebviewCommandHandler } from './shared/workspaceWebviewCommandHandler'; +import { isPathEqual } from '../utils/fs'; /** * Ensures that the current workspace is properly set up for Azure Logic Apps (Standard) projects. @@ -84,6 +85,11 @@ export async function ensureWorkspace(context: IActionContext): Promise } async function createWorkspaceStructureWebview(): Promise { + const currentFolder = vscode.workspace.workspaceFolders?.[0]; + const currentFolderPath = currentFolder?.uri.fsPath ?? ''; + const defaultWorkspaceProjectPath = currentFolderPath ? path.dirname(currentFolderPath) : ''; + const defaultWorkspaceName = currentFolderPath ? path.basename(currentFolderPath) : ''; + return new Promise((resolve) => { createWorkspaceWebviewCommandHandler({ panelName: localize('createWorkspaceStructure', 'Create workspace structure'), @@ -95,6 +101,11 @@ async function createWorkspaceStructureWebview(): Promise { await createWorkspaceFile(actionContext, data); }); }, + extraInitializeData: { + currentFolderPath, + defaultWorkspaceProjectPath, + defaultWorkspaceName, + }, onResolve: resolve, }); }); @@ -103,65 +114,99 @@ async function createWorkspaceStructureWebview(): Promise { export async function createWorkspaceFile(context: IActionContext, options: any): Promise { addLocalFuncTelemetry(context); - const webviewProjectContext: IWebviewProjectContext = options; + const webviewProjectContext = validateWorkspaceProjectPath(options); - // Add telemetry properties for debugging - context.telemetry.properties.hasWorkspaceProjectPath = String(!!webviewProjectContext.workspaceProjectPath); - context.telemetry.properties.workspaceProjectPathType = typeof webviewProjectContext.workspaceProjectPath; + context.telemetry.properties.hasWorkspaceProjectPath = 'true'; context.telemetry.properties.receivedOptionsKeys = Object.keys(options || {}).join(','); - // Validate that workspaceProjectPath exists and has required properties - if (!webviewProjectContext.workspaceProjectPath || !webviewProjectContext.workspaceProjectPath.fsPath) { - const errorMessage = `[EnsureWorkspace] Invalid workspaceProjectPath: ${JSON.stringify( - { - hasWorkspaceProjectPath: !!webviewProjectContext.workspaceProjectPath, - workspaceProjectPathType: typeof webviewProjectContext.workspaceProjectPath, - workspaceProjectPathValue: webviewProjectContext.workspaceProjectPath, - contextKeys: Object.keys(options || {}), - }, - null, - 2 - )}`; - ext.outputChannel.appendLog(errorMessage); - throw new Error( - `workspaceProjectPath is required and must have an fsPath property. Received: ${JSON.stringify(webviewProjectContext.workspaceProjectPath)}` - ); + const workspaceFolderPath = path.join(webviewProjectContext.workspaceProjectPath.fsPath, webviewProjectContext.workspaceName); + const currentFolder = vscode.workspace.workspaceFolders?.[0]; + const currentFolderPath = currentFolder?.uri.fsPath; + const isInPlace = currentFolderPath !== undefined && isPathEqual(workspaceFolderPath, currentFolderPath); + + context.telemetry.properties.isInPlace = String(isInPlace); + + if (isInPlace) { + // In-place: the workspace folder IS the current project folder. + // Just write the .code-workspace file — no copying needed. + const workspaceFolders = await buildInPlaceWorkspaceFolders(currentFolderPath); + const workspaceFilePath = path.join(workspaceFolderPath, `${webviewProjectContext.workspaceName}.code-workspace`); + await fse.writeJson(workspaceFilePath, { folders: workspaceFolders }, { spaces: 2 }); + await vscode.commands.executeCommand(vscodeCommand.openFolder, vscode.Uri.file(workspaceFilePath), true); + } else { + // Different location: copy project files into the new workspace folder. + await fse.ensureDir(workspaceFolderPath); + const workspaceFolders = await copyWorkspaceFolders(workspaceFolderPath); + const workspaceFilePath = path.join(workspaceFolderPath, `${webviewProjectContext.workspaceName}.code-workspace`); + await fse.writeJson(workspaceFilePath, { folders: workspaceFolders }, { spaces: 2 }); + await vscode.commands.executeCommand(vscodeCommand.openFolder, vscode.Uri.file(workspaceFilePath), true); } +} - const workspaceFolderPath = path.join(webviewProjectContext.workspaceProjectPath.fsPath, webviewProjectContext.workspaceName); +/** + * Builds workspace folder descriptors for the in-place case (no copying). + * If the current folder is a Logic App project, it becomes a single entry referencing ".". + * Otherwise, each child directory becomes a workspace entry. + */ +async function buildInPlaceWorkspaceFolders(currentFolderPath: string): Promise> { + if (await isLogicAppProject(currentFolderPath)) { + return [{ name: path.basename(currentFolderPath), path: '.' }]; + } - await fse.ensureDir(workspaceFolderPath); - const workspaceFilePath = path.join(workspaceFolderPath, `${webviewProjectContext.workspaceName}.code-workspace`); + // Each child is a separate workspace entry + const entries: Array<{ name: string; path: string }> = []; + const children = await fse.readdir(currentFolderPath, { withFileTypes: true }); + for (const child of children) { + if (child.isDirectory()) { + entries.push({ name: child.name, path: `./${child.name}` }); + } + } + return entries; +} - // Start with an empty folders array - const workspaceFolders = []; +/** + * Copies workspace folders from the current VS Code workspace into a new location + * and returns workspace folder descriptors. + */ +async function copyWorkspaceFolders(workspaceFolderPath: string): Promise> { const foldersToAdd = vscode.workspace.workspaceFolders; - - if (foldersToAdd && foldersToAdd.length === 1) { - const folder = foldersToAdd[0]; - const folderPath = folder.uri.fsPath; - if (await isLogicAppProject(folderPath)) { - const destinationPath = path.join(workspaceFolderPath, folder.name); - await fse.copy(folderPath, destinationPath); - workspaceFolders.push({ name: folder.name, path: `./${folder.name}` }); - } else { - const subpaths: string[] = await fse.readdir(folderPath); - for (const subpath of subpaths) { - const fullPath = path.join(folderPath, subpath); - const destinationPath = path.join(workspaceFolderPath, subpath); - await fse.copy(fullPath, destinationPath); - workspaceFolders.push({ name: subpath, path: `./${subpath}` }); - } - } + if (!foldersToAdd || foldersToAdd.length !== 1) { + return []; } - const workspaceData = { - folders: workspaceFolders, - }; + const folder = foldersToAdd[0]; + const sourcePath = folder.uri.fsPath; - await fse.writeJson(workspaceFilePath, workspaceData, { spaces: 2 }); + if (await isLogicAppProject(sourcePath)) { + const destPath = path.join(workspaceFolderPath, folder.name); + await fse.copy(sourcePath, destPath); + return [{ name: folder.name, path: `./${folder.name}` }]; + } - const uri = vscode.Uri.file(workspaceFilePath); + // Each child becomes a separate workspace entry + const entries: Array<{ name: string; path: string }> = []; + const children = await fse.readdir(sourcePath); + for (const child of children) { + const fullPath = path.join(sourcePath, child); + await fse.copy(fullPath, path.join(workspaceFolderPath, child)); + entries.push({ name: child, path: `./${child}` }); + } + return entries; +} - await vscode.commands.executeCommand(vscodeCommand.openFolder, uri, true /* forceNewWindow */); +function validateWorkspaceProjectPath(options: any): IWebviewProjectContext { + const ctx: IWebviewProjectContext = options; + if (!ctx.workspaceProjectPath?.fsPath) { + const detail = JSON.stringify({ + hasWorkspaceProjectPath: !!ctx.workspaceProjectPath, + type: typeof ctx.workspaceProjectPath, + value: ctx.workspaceProjectPath, + keys: Object.keys(options || {}), + }); + ext.outputChannel.appendLog(`[EnsureWorkspace] Invalid workspaceProjectPath: ${detail}`); + throw new Error( + `workspaceProjectPath is required and must have an fsPath property. Received: ${JSON.stringify(ctx.workspaceProjectPath)}` + ); + } + return ctx; } diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx index 3b6309b111c..59be424a46d 100644 --- a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx @@ -12,7 +12,13 @@ import { useContext, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; // Import validation patterns and functions for navigation blocking import { ExtensionCommand, ProjectType } from '@microsoft/vscode-extension-logic-apps'; -import { functionNameValidation, getValidationRequirements, nameValidation, namespaceValidation } from './utils/validation'; +import { + functionNameValidation, + getValidationRequirements, + isWorkspaceDescendantOfCurrentFolder, + nameValidation, + namespaceValidation, +} from './utils/validation'; import { useIntlMessages, useIntlFormatters, workspaceMessages } from '../../intl'; import { CreateWorkflowSetup } from '../createWorkflow/createWorkflowSetup'; @@ -62,6 +68,7 @@ const CreateWorkspaceInternal = () => { separator, isDevContainerProject, availableProjects, + currentFolderPath, } = createWorkspaceState; // Calculate total steps - always 2: Setup and Review + Create @@ -168,7 +175,10 @@ const CreateWorkspaceInternal = () => { const isWorkspaceNameAvailable = () => { const { workspaceFolder, workspaceFile } = getWorkspaceExistencePaths(); - return workspaceExistenceResults[workspaceFolder] === false && workspaceExistenceResults[workspaceFile] === false; + // In the in-place case the folder already exists by definition — only block on .code-workspace file + const isInPlace = currentFolderPath && workspaceFolder.toLowerCase() === currentFolderPath.toLowerCase(); + const folderAvailable = isInPlace || workspaceExistenceResults[workspaceFolder] === false; + return folderAvailable && workspaceExistenceResults[workspaceFile] === false; }; // Helper function to validate logic app name with support for existing logic apps @@ -333,7 +343,13 @@ const CreateWorkspaceInternal = () => { if (flowType === FLOW_TYPES.ENSURE_WORKSPACE) { const workspacePathValid = workspaceProjectPath.fsPath !== '' && pathValidationResults[workspaceProjectPath.fsPath] === true; const workspaceNameValid = workspaceName.trim() !== '' && nameValidation.test(workspaceName.trim()) && isWorkspaceNameAvailable(); - return workspacePathValid && workspaceNameValid; + const workspaceLocationValid = !isWorkspaceDescendantOfCurrentFolder( + workspaceProjectPath.fsPath, + workspaceName.trim(), + currentFolderPath, + separator + ); + return workspacePathValid && workspaceNameValid && workspaceLocationValid; } // For other flow types, use the full validation diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx index 6df1cd027ba..5d8218d2083 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx @@ -13,7 +13,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { VSCodeContext } from '../../../webviewCommunication'; import { useContext, useState, useCallback, useEffect } from 'react'; import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; -import { nameValidation } from '../utils/validation'; +import { nameValidation, isWorkspaceDescendantOfCurrentFolder } from '../utils/validation'; export const WorkspaceNameStep: React.FC = () => { const dispatch = useDispatch(); @@ -22,8 +22,15 @@ export const WorkspaceNameStep: React.FC = () => { const vscode = useContext(VSCodeContext); const styles = useCreateWorkspaceStyles(); const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; - const { workspaceName, workspaceProjectPath, pathValidationResults, workspaceExistenceResults, isValidatingWorkspace, separator } = - createWorkspaceState; + const { + workspaceName, + workspaceProjectPath, + pathValidationResults, + workspaceExistenceResults, + isValidatingWorkspace, + separator, + currentFolderPath, + } = createWorkspaceState; const projectPathInputId = useId(); const workspaceNameId = useId(); @@ -58,12 +65,18 @@ export const WorkspaceNameStep: React.FC = () => { return intlText.WORKSPACE_NAME_VALIDATION; } - // Check if workspace folder or file already exists + // Block workspace locations that are descendants of the currently open folder + if (isWorkspaceDescendantOfCurrentFolder(workspaceProjectPath.fsPath, name, currentFolderPath, separator)) { + return intlText.WORKSPACE_LOCATION_INSIDE_PROJECT; + } + + // Check if workspace folder or file already exists (skip folder check for in-place case) if (workspaceProjectPath.fsPath && name) { const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${name}`; const workspaceFile = `${workspaceFolder}${separator}${name}.code-workspace`; + const isInPlace = currentFolderPath && workspaceFolder.toLowerCase() === currentFolderPath.toLowerCase(); - if (workspaceExistenceResults[workspaceFolder] === true) { + if (!isInPlace && workspaceExistenceResults[workspaceFolder] === true) { return format.FOLDER_EXISTS_MESSAGE({ name }); } if (workspaceExistenceResults[workspaceFile] === true) { @@ -77,7 +90,9 @@ export const WorkspaceNameStep: React.FC = () => { workspaceProjectPath.fsPath, intlText.WORKSPACE_NAME_EMPTY, intlText.WORKSPACE_NAME_VALIDATION, + intlText.WORKSPACE_LOCATION_INSIDE_PROJECT, separator, + currentFolderPath, workspaceExistenceResults, format, ] diff --git a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts index 60a1944b625..e3ad9360227 100644 --- a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts +++ b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts @@ -61,6 +61,24 @@ export const validateFunctionName = (name: string, intlText: any) => { return undefined; }; +/** + * Checks whether the workspace path (parentPath + separator + name) is a strict + * descendant of the currently open folder. Equal paths are allowed (in-place case). + */ +export const isWorkspaceDescendantOfCurrentFolder = ( + parentPath: string, + name: string, + currentFolderPath: string, + separator: string +): boolean => { + if (!currentFolderPath || !parentPath || !name) { + return false; + } + const workspacePath = `${parentPath}${separator}${name}`.toLowerCase(); + const current = currentFolderPath.toLowerCase(); + return workspacePath !== current && workspacePath.startsWith(`${current}${separator}`); +}; + // Get validation requirements based on flow type export const getValidationRequirements = (flowType: string, logicAppType: string) => { const requirements = { diff --git a/apps/vs-code-react/src/intl/messages.ts b/apps/vs-code-react/src/intl/messages.ts index 8c6442fac47..4f53e9a378e 100644 --- a/apps/vs-code-react/src/intl/messages.ts +++ b/apps/vs-code-react/src/intl/messages.ts @@ -245,6 +245,12 @@ export const workspaceMessages = defineMessages({ id: 'RRuHNc', description: 'Workspace name validation message text', }, + WORKSPACE_LOCATION_INSIDE_PROJECT: { + defaultMessage: + 'Workspace location cannot be inside the currently open project folder. Choose the current folder or a location outside it.', + id: 'Karw2Q', + description: 'Workspace location is a descendant of the currently open folder error text', + }, USE_DEV_CONTAINER_LABEL: { defaultMessage: 'Use Dev Container', id: '0Va6gs', diff --git a/apps/vs-code-react/src/state/createWorkspaceSlice.ts b/apps/vs-code-react/src/state/createWorkspaceSlice.ts index 9f8615e80bc..571ac30edc1 100644 --- a/apps/vs-code-react/src/state/createWorkspaceSlice.ts +++ b/apps/vs-code-react/src/state/createWorkspaceSlice.ts @@ -48,6 +48,7 @@ export interface CreateWorkspaceState { availableProjects: AvailableProject[]; isAddCustomCodeFlow: boolean; workspaceRootFolder: string; + currentFolderPath: string; } const initialState: CreateWorkspaceState = { @@ -88,6 +89,7 @@ const initialState: CreateWorkspaceState = { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', }; export const createWorkspaceSlice = createSlice, 'createWorkspace'>({ @@ -130,12 +132,30 @@ export const createWorkspaceSlice = createSlice) => { - const { separator, platform, logicAppType, logicAppName, availableProjects } = action.payload; + const { + separator, + platform, + logicAppType, + logicAppName, + availableProjects, + currentFolderPath, + defaultWorkspaceProjectPath, + defaultWorkspaceName, + } = action.payload; state.separator = separator; state.platform = platform; state.logicAppType = logicAppType || ''; state.logicAppName = logicAppName || ''; state.availableProjects = availableProjects || []; + if (currentFolderPath) { + state.currentFolderPath = currentFolderPath; + } + if (defaultWorkspaceProjectPath) { + state.workspaceProjectPath = { fsPath: defaultWorkspaceProjectPath, path: defaultWorkspaceProjectPath }; + } + if (defaultWorkspaceName) { + state.workspaceName = defaultWorkspaceName; + } }, setCurrentStep: (state, action: PayloadAction) => { state.currentStep = action.payload; @@ -248,6 +268,7 @@ export const createWorkspaceSlice = createSlice = { platform: state.platform, separator: state.separator, + currentFolderPath: state.currentFolderPath, ...(preserveLogicAppData ? { logicAppType: state.logicAppType, From 0a731f05df00c3e18ccda010d91c5d96ca85d2f2 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Wed, 19 Aug 2026 11:05:42 -0400 Subject: [PATCH 11/23] fix 'createWorkspaceStructure' defaults not being loaded --- .../src/app/commands/ensureWorkspace.ts | 4 ---- .../src/app/createWorkspace/createWorkspace.tsx | 17 +++++++++++++++-- .../src/state/createWorkspaceSlice.ts | 17 +---------------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts index 60ccffeece1..18e24a04862 100644 --- a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts @@ -87,8 +87,6 @@ export async function ensureWorkspace(context: IActionContext): Promise async function createWorkspaceStructureWebview(): Promise { const currentFolder = vscode.workspace.workspaceFolders?.[0]; const currentFolderPath = currentFolder?.uri.fsPath ?? ''; - const defaultWorkspaceProjectPath = currentFolderPath ? path.dirname(currentFolderPath) : ''; - const defaultWorkspaceName = currentFolderPath ? path.basename(currentFolderPath) : ''; return new Promise((resolve) => { createWorkspaceWebviewCommandHandler({ @@ -103,8 +101,6 @@ async function createWorkspaceStructureWebview(): Promise { }, extraInitializeData: { currentFolderPath, - defaultWorkspaceProjectPath, - defaultWorkspaceName, }, onResolve: resolve, }); diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx index 59be424a46d..cfaf233a452 100644 --- a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx @@ -7,7 +7,7 @@ import { Button, Spinner, Text } from '@fluentui/react-components'; import { VSCodeContext } from '../../webviewCommunication'; import type { RootState } from '../../state/store'; import type { CreateWorkspaceState } from '../../state/createWorkspaceSlice'; -import { nextStep, previousStep, setCurrentStep, setFlowType, setLoading, resetState } from '../../state/createWorkspaceSlice'; +import { nextStep, previousStep, setCurrentStep, setFlowType, setLoading, resetState, setProjectPath, setWorkspaceName } from '../../state/createWorkspaceSlice'; import { useContext, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; // Import validation patterns and functions for navigation blocking @@ -763,11 +763,24 @@ export const CreateWorkspaceFromPackage = () => { export const CreateWorkspaceStructure = () => { const dispatch = useDispatch(); + const { currentFolderPath, separator } = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; useEffect(() => { dispatch(resetState(undefined)); dispatch(setFlowType(FLOW_TYPES.ENSURE_WORKSPACE)); - }, [dispatch]); + + // Auto-populate defaults from the current folder path (preserved across resetState). + // Default: workspaceProjectPath = parent of current folder, workspaceName = folder name. + if (currentFolderPath) { + const lastSep = currentFolderPath.lastIndexOf(separator); + if (lastSep > 0) { + const parentPath = currentFolderPath.substring(0, lastSep); + const folderName = currentFolderPath.substring(lastSep + 1); + dispatch(setProjectPath(parentPath)); + dispatch(setWorkspaceName(folderName)); + } + } + }, [dispatch, currentFolderPath, separator]); return ; }; diff --git a/apps/vs-code-react/src/state/createWorkspaceSlice.ts b/apps/vs-code-react/src/state/createWorkspaceSlice.ts index 571ac30edc1..a3cb81220bf 100644 --- a/apps/vs-code-react/src/state/createWorkspaceSlice.ts +++ b/apps/vs-code-react/src/state/createWorkspaceSlice.ts @@ -132,16 +132,7 @@ export const createWorkspaceSlice = createSlice) => { - const { - separator, - platform, - logicAppType, - logicAppName, - availableProjects, - currentFolderPath, - defaultWorkspaceProjectPath, - defaultWorkspaceName, - } = action.payload; + const { separator, platform, logicAppType, logicAppName, availableProjects, currentFolderPath } = action.payload; state.separator = separator; state.platform = platform; state.logicAppType = logicAppType || ''; @@ -150,12 +141,6 @@ export const createWorkspaceSlice = createSlice) => { state.currentStep = action.payload; From c960d2fa7dcbae008eb01153df71e3095f3cf17a Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Wed, 19 Aug 2026 11:56:51 -0400 Subject: [PATCH 12/23] fix pathEquals check in createWorkspace validation --- .../__test__/createWorkspace.test.tsx | 1 + .../app/createWorkspace/createWorkspace.tsx | 18 +++++- .../__test__/dotNetFrameworkStep.test.tsx | 1 + .../steps/__test__/logicAppTypeStep.test.tsx | 1 + .../steps/__test__/reviewCreateStep.test.tsx | 1 + .../steps/__test__/workflowTypeStep.test.tsx | 1 + .../steps/__test__/workspaceNameStep.test.tsx | 1 + .../steps/workspaceNameStep.tsx | 8 ++- .../app/createWorkspace/utils/validation.ts | 63 ++++++++++++++++--- 9 files changed, 80 insertions(+), 15 deletions(-) diff --git a/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx b/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx index 07deb94a7c6..d41ed90a87b 100644 --- a/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx @@ -80,6 +80,7 @@ const createDefaultState = (overrides: Partial = {}): Crea availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; }; diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx index cfaf233a452..d6f473f8df2 100644 --- a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx @@ -7,7 +7,16 @@ import { Button, Spinner, Text } from '@fluentui/react-components'; import { VSCodeContext } from '../../webviewCommunication'; import type { RootState } from '../../state/store'; import type { CreateWorkspaceState } from '../../state/createWorkspaceSlice'; -import { nextStep, previousStep, setCurrentStep, setFlowType, setLoading, resetState, setProjectPath, setWorkspaceName } from '../../state/createWorkspaceSlice'; +import { + nextStep, + previousStep, + setCurrentStep, + setFlowType, + setLoading, + resetState, + setProjectPath, + setWorkspaceName, +} from '../../state/createWorkspaceSlice'; import { useContext, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; // Import validation patterns and functions for navigation blocking @@ -18,6 +27,7 @@ import { isWorkspaceDescendantOfCurrentFolder, nameValidation, namespaceValidation, + pathsEqual, } from './utils/validation'; import { useIntlMessages, useIntlFormatters, workspaceMessages } from '../../intl'; import { CreateWorkflowSetup } from '../createWorkflow/createWorkflowSetup'; @@ -66,6 +76,7 @@ const CreateWorkspaceInternal = () => { logicAppsWithoutCustomCode, existingFolders, separator, + platform, isDevContainerProject, availableProjects, currentFolderPath, @@ -176,7 +187,7 @@ const CreateWorkspaceInternal = () => { const isWorkspaceNameAvailable = () => { const { workspaceFolder, workspaceFile } = getWorkspaceExistencePaths(); // In the in-place case the folder already exists by definition — only block on .code-workspace file - const isInPlace = currentFolderPath && workspaceFolder.toLowerCase() === currentFolderPath.toLowerCase(); + const isInPlace = currentFolderPath && pathsEqual(workspaceFolder, currentFolderPath, platform); const folderAvailable = isInPlace || workspaceExistenceResults[workspaceFolder] === false; return folderAvailable && workspaceExistenceResults[workspaceFile] === false; }; @@ -347,7 +358,8 @@ const CreateWorkspaceInternal = () => { workspaceProjectPath.fsPath, workspaceName.trim(), currentFolderPath, - separator + separator, + platform ); return workspacePathValid && workspaceNameValid && workspaceLocationValid; } diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx index 3baa6a1bd37..0142773da10 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx @@ -51,6 +51,7 @@ const createTestStore = (overrides: Partial = {}) => { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx index 45d973aef41..2eb122c2b74 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx @@ -114,6 +114,7 @@ function createState(overrides: Partial = {}): CreateWorks availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; } diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx index 74465543fa9..0dca00250e5 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx @@ -51,6 +51,7 @@ const createTestStore = (overrides: Partial = {}) => { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx index 978ed37a2a3..37f153f589b 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx @@ -96,6 +96,7 @@ function createState(overrides: Partial = {}): CreateWorks availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; } diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx index 829efa37eee..b8e012340f7 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx @@ -61,6 +61,7 @@ const createTestStore = (overrides: Partial = {}) => { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx index 5d8218d2083..a7b421efb62 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx @@ -13,7 +13,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { VSCodeContext } from '../../../webviewCommunication'; import { useContext, useState, useCallback, useEffect } from 'react'; import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; -import { nameValidation, isWorkspaceDescendantOfCurrentFolder } from '../utils/validation'; +import { nameValidation, isWorkspaceDescendantOfCurrentFolder, pathsEqual } from '../utils/validation'; export const WorkspaceNameStep: React.FC = () => { const dispatch = useDispatch(); @@ -30,6 +30,7 @@ export const WorkspaceNameStep: React.FC = () => { isValidatingWorkspace, separator, currentFolderPath, + platform, } = createWorkspaceState; const projectPathInputId = useId(); const workspaceNameId = useId(); @@ -66,7 +67,7 @@ export const WorkspaceNameStep: React.FC = () => { } // Block workspace locations that are descendants of the currently open folder - if (isWorkspaceDescendantOfCurrentFolder(workspaceProjectPath.fsPath, name, currentFolderPath, separator)) { + if (isWorkspaceDescendantOfCurrentFolder(workspaceProjectPath.fsPath, name, currentFolderPath, separator, platform)) { return intlText.WORKSPACE_LOCATION_INSIDE_PROJECT; } @@ -74,7 +75,7 @@ export const WorkspaceNameStep: React.FC = () => { if (workspaceProjectPath.fsPath && name) { const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${name}`; const workspaceFile = `${workspaceFolder}${separator}${name}.code-workspace`; - const isInPlace = currentFolderPath && workspaceFolder.toLowerCase() === currentFolderPath.toLowerCase(); + const isInPlace = currentFolderPath && pathsEqual(workspaceFolder, currentFolderPath, platform); if (!isInPlace && workspaceExistenceResults[workspaceFolder] === true) { return format.FOLDER_EXISTS_MESSAGE({ name }); @@ -93,6 +94,7 @@ export const WorkspaceNameStep: React.FC = () => { intlText.WORKSPACE_LOCATION_INSIDE_PROJECT, separator, currentFolderPath, + platform, workspaceExistenceResults, format, ] diff --git a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts index e3ad9360227..91daa8b9ceb 100644 --- a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts +++ b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts @@ -64,23 +64,27 @@ export const validateFunctionName = (name: string, intlText: any) => { /** * Checks whether the workspace path (parentPath + separator + name) is a strict * descendant of the currently open folder. Equal paths are allowed (in-place case). + * Uses platform-aware comparison for case sensitivity and normalizes trailing separators. */ -export const isWorkspaceDescendantOfCurrentFolder = ( +export function isWorkspaceDescendantOfCurrentFolder( parentPath: string, name: string, currentFolderPath: string, - separator: string -): boolean => { + separator: string, + platform: string | null = null +): boolean { if (!currentFolderPath || !parentPath || !name) { return false; } - const workspacePath = `${parentPath}${separator}${name}`.toLowerCase(); - const current = currentFolderPath.toLowerCase(); - return workspacePath !== current && workspacePath.startsWith(`${current}${separator}`); -}; + const workspacePath = joinPath(parentPath, name, separator); + if (pathsEqual(workspacePath, currentFolderPath, platform)) { + return false; + } + return pathStartsWith(workspacePath, currentFolderPath, separator, platform); +} // Get validation requirements based on flow type -export const getValidationRequirements = (flowType: string, logicAppType: string) => { +export function getValidationRequirements(flowType: string, logicAppType: string) { const requirements = { needsPackagePath: flowType === 'createWorkspaceFromPackage', needsWorkspacePath: flowType !== 'createLogicApp', @@ -109,4 +113,45 @@ export const getValidationRequirements = (flowType: string, logicAppType: string } return requirements; -}; +} + +/** + * Joins a parent path and a name with the given separator, + * handling trailing separators on the parent to avoid doubling. + */ +function joinPath(parentPath: string, name: string, separator: string): string { + return `${stripTrailingSeparator(parentPath, separator)}${separator}${name}`; +} + +/** + * Compares two path strings with platform-aware case sensitivity. + * Windows/macOS are case-insensitive; Linux is case-sensitive. + */ +export function pathsEqual(a: string, b: string, platform: string | null): boolean { + if (platform === 'linux') { + return a === b; + } + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * Checks whether `child` starts with `parent` + separator, using platform-aware comparison. + */ +function pathStartsWith(child: string, parent: string, separator: string, platform: string | null): boolean { + const prefix = `${stripTrailingSeparator(parent, separator)}${separator}`; + if (platform === 'linux') { + return child.startsWith(prefix); + } + return child.toLowerCase().startsWith(prefix.toLowerCase()); +} + +/** + * Strips trailing separator(s) from a path string. + * Preserves root paths like "/" or "C:\". + */ +function stripTrailingSeparator(p: string, sep: string): string { + while (p.length > 1 && p.endsWith(sep)) { + p = p.slice(0, -sep.length); + } + return p; +} From 85bd1c55b3ebee15f385c15dcb4147b2d66b2a98 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Wed, 19 Aug 2026 12:04:10 -0400 Subject: [PATCH 13/23] fix path normalization issues --- .../vs-code-react/src/app/createWorkspace/createWorkspace.tsx | 3 ++- .../src/app/createWorkspace/steps/workspaceNameStep.tsx | 4 ++-- .../vs-code-react/src/app/createWorkspace/utils/validation.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx index d6f473f8df2..c07278edc9f 100644 --- a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx @@ -25,6 +25,7 @@ import { functionNameValidation, getValidationRequirements, isWorkspaceDescendantOfCurrentFolder, + joinPath, nameValidation, namespaceValidation, pathsEqual, @@ -179,7 +180,7 @@ const CreateWorkspaceInternal = () => { }; const getWorkspaceExistencePaths = () => { - const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${workspaceName}`; + const workspaceFolder = joinPath(workspaceProjectPath.fsPath, workspaceName, separator); const workspaceFile = `${workspaceFolder}${separator}${workspaceName}.code-workspace`; return { workspaceFolder, workspaceFile }; }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx index a7b421efb62..52dd7d3ac70 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx @@ -13,7 +13,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { VSCodeContext } from '../../../webviewCommunication'; import { useContext, useState, useCallback, useEffect } from 'react'; import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; -import { nameValidation, isWorkspaceDescendantOfCurrentFolder, pathsEqual } from '../utils/validation'; +import { nameValidation, isWorkspaceDescendantOfCurrentFolder, pathsEqual, joinPath } from '../utils/validation'; export const WorkspaceNameStep: React.FC = () => { const dispatch = useDispatch(); @@ -73,7 +73,7 @@ export const WorkspaceNameStep: React.FC = () => { // Check if workspace folder or file already exists (skip folder check for in-place case) if (workspaceProjectPath.fsPath && name) { - const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${name}`; + const workspaceFolder = joinPath(workspaceProjectPath.fsPath, name, separator); const workspaceFile = `${workspaceFolder}${separator}${name}.code-workspace`; const isInPlace = currentFolderPath && pathsEqual(workspaceFolder, currentFolderPath, platform); diff --git a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts index 91daa8b9ceb..6b8fc341041 100644 --- a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts +++ b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts @@ -119,7 +119,7 @@ export function getValidationRequirements(flowType: string, logicAppType: string * Joins a parent path and a name with the given separator, * handling trailing separators on the parent to avoid doubling. */ -function joinPath(parentPath: string, name: string, separator: string): string { +export function joinPath(parentPath: string, name: string, separator: string): string { return `${stripTrailingSeparator(parentPath, separator)}${separator}${name}`; } From 155cdbfc54ab25f2c4259709c2150ec876f7eefc Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Wed, 19 Aug 2026 12:14:38 -0400 Subject: [PATCH 14/23] add check for invalid new workspace location in ensureWorkspace --- .../src/app/commands/ensureWorkspace.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts index 18e24a04862..e7b2a7e4100 100644 --- a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts @@ -21,7 +21,7 @@ import { ext } from '../../extensionVariables'; import * as fse from 'fs-extra'; import * as path from 'path'; import { createWorkspaceWebviewCommandHandler } from './shared/workspaceWebviewCommandHandler'; -import { isPathEqual } from '../utils/fs'; +import { isPathEqual, isSubpath } from '../utils/fs'; /** * Ensures that the current workspace is properly set up for Azure Logic Apps (Standard) projects. @@ -118,6 +118,18 @@ export async function createWorkspaceFile(context: IActionContext, options: any) const workspaceFolderPath = path.join(webviewProjectContext.workspaceProjectPath.fsPath, webviewProjectContext.workspaceName); const currentFolder = vscode.workspace.workspaceFolders?.[0]; const currentFolderPath = currentFolder?.uri.fsPath; + + if (currentFolderPath && isSubpath(currentFolderPath, workspaceFolderPath)) { + throw new Error( + localize( + 'workspaceLocationInsideCurrentFolder', + 'Workspace location "{0}" is inside the currently open folder "{1}". Choose the current folder (in-place) or a location outside it.', + workspaceFolderPath, + currentFolderPath + ) + ); + } + const isInPlace = currentFolderPath !== undefined && isPathEqual(workspaceFolderPath, currentFolderPath); context.telemetry.properties.isInPlace = String(isInPlace); @@ -201,7 +213,11 @@ function validateWorkspaceProjectPath(options: any): IWebviewProjectContext { }); ext.outputChannel.appendLog(`[EnsureWorkspace] Invalid workspaceProjectPath: ${detail}`); throw new Error( - `workspaceProjectPath is required and must have an fsPath property. Received: ${JSON.stringify(ctx.workspaceProjectPath)}` + localize( + 'invalidWorkspaceProjectPath', + 'Invalid workspaceProjectPath: {0}.', + detail + ) ); } return ctx; From df3cdde8a6addca96f223e473deff1a7ebec308c Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Wed, 19 Aug 2026 12:50:54 -0400 Subject: [PATCH 15/23] update tests --- .../commands/__test__/ensureWorkspace.test.ts | 131 +++++++++++++++++- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts b/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts index 8b53d2c13be..bd4f4cc7a75 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ensureWorkspace } from '../ensureWorkspace'; +import { ensureWorkspace, createWorkspaceFile } from '../ensureWorkspace'; import * as vscode from 'vscode'; import * as workspaceUtils from '../../utils/workspace'; import * as verifyProject from '../../utils/verifyIsProject'; @@ -35,6 +35,18 @@ vi.mock('../shared/workspaceWebviewCommandHandler', () => ({ createWorkspaceWebviewCommandHandler: vi.fn(), })); +vi.mock('fs-extra', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + writeJson: vi.fn(), + copy: vi.fn(), + ensureDir: vi.fn(), + readdir: vi.fn(), + pathExists: vi.fn(), + }; +}); + describe('ensureWorkspace', () => { const testWorkspaceName = 'TestWorkspace'; const testWorkspaceFolder: vscode.WorkspaceFolder = { @@ -133,13 +145,13 @@ describe('ensureWorkspace', () => { vi.spyOn(verifyProject, 'getFirstLogicAppProjectRoot').mockImplementation(async (f: vscode.WorkspaceFolder | string | undefined) => { return f === testLogicAppChildFolder ? testLogicAppChildFolder : undefined; }); - vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => { + vi.mocked(fse.readdir).mockImplementation(async (p: fse.PathLike) => { if (p === testWorkspaceFolder.uri.fsPath) { - return [new MockDirent(testLogicAppName, true)]; + return [new MockDirent(testLogicAppName, true)] as any; } return []; }); - vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + vi.mocked(fse.pathExists).mockImplementation(async (p: fse.PathLike) => { return p === testWorkspaceFolder.uri.fsPath || p === testLogicAppChildFolder; }); const isLogicAppProjectInRootSpy = vi @@ -209,3 +221,114 @@ describe('ensureWorkspace', () => { expect(result).toBe(true); }); }); + +describe('createWorkspaceFile', () => { + let context: any; + + beforeEach(() => { + context = { + telemetry: { + properties: {}, + measurements: {}, + }, + }; + vi.spyOn(funcCoreTools, 'addLocalFuncTelemetry').mockImplementation(() => {}); + vi.mocked(fse.writeJson).mockResolvedValue(undefined); + vi.mocked(fse.copy).mockResolvedValue(undefined); + vi.mocked(fse.ensureDir).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('in-place Logic App root: writes .code-workspace without copying', async () => { + const projectPath = path.resolve('/users/dev/MyLogicApp'); + (vscode.workspace as any).workspaceFolders = [{ name: 'MyLogicApp', uri: { fsPath: projectPath } as vscode.Uri, index: 0 }]; + vi.spyOn(verifyProject, 'isLogicAppProject').mockResolvedValue(true); + const executeCommandSpy = vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + await createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: path.dirname(projectPath), path: path.dirname(projectPath) }, + workspaceName: 'MyLogicApp', + }); + + expect(fse.copy).not.toHaveBeenCalled(); + expect(fse.writeJson).toHaveBeenCalledWith( + path.join(projectPath, 'MyLogicApp.code-workspace'), + { folders: [{ name: 'MyLogicApp', path: '.' }] }, + { spaces: 2 } + ); + expect(executeCommandSpy).toHaveBeenCalledWith( + vscodeCommand.openFolder, + expect.objectContaining({ fsPath: path.join(projectPath, 'MyLogicApp.code-workspace') }), + true + ); + }); + + it('in-place container with child directories: lists children without copying', async () => { + const containerPath = path.resolve('/users/dev/workspace-root'); + (vscode.workspace as any).workspaceFolders = [{ name: 'workspace-root', uri: { fsPath: containerPath } as vscode.Uri, index: 0 }]; + vi.spyOn(verifyProject, 'isLogicAppProject').mockResolvedValue(false); + vi.mocked(fse.readdir).mockResolvedValue([ + new MockDirent('app1', true), + new MockDirent('app2', true), + new MockDirent('readme.md', false), + ] as any); + const executeCommandSpy = vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + await createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: path.dirname(containerPath), path: path.dirname(containerPath) }, + workspaceName: 'workspace-root', + }); + + expect(fse.copy).not.toHaveBeenCalled(); + expect(fse.writeJson).toHaveBeenCalledWith( + path.join(containerPath, 'workspace-root.code-workspace'), + { + folders: [ + { name: 'app1', path: './app1' }, + { name: 'app2', path: './app2' }, + ], + }, + { spaces: 2 } + ); + expect(executeCommandSpy).toHaveBeenCalled(); + }); + + it('external copy: copies project to new location', async () => { + const currentPath = path.resolve('/users/dev/MyLogicApp'); + const externalParent = path.resolve('/users/workspaces'); + const externalDest = path.join(externalParent, 'NewWorkspace'); + + (vscode.workspace as any).workspaceFolders = [{ name: 'MyLogicApp', uri: { fsPath: currentPath } as vscode.Uri, index: 0 }]; + vi.spyOn(verifyProject, 'isLogicAppProject').mockResolvedValue(true); + const executeCommandSpy = vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + await createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: externalParent, path: externalParent }, + workspaceName: 'NewWorkspace', + }); + + expect(fse.ensureDir).toHaveBeenCalledWith(externalDest); + expect(fse.copy).toHaveBeenCalledWith(currentPath, path.join(externalDest, 'MyLogicApp')); + expect(fse.writeJson).toHaveBeenCalledWith( + path.join(externalDest, 'NewWorkspace.code-workspace'), + { folders: [{ name: 'MyLogicApp', path: './MyLogicApp' }] }, + { spaces: 2 } + ); + expect(executeCommandSpy).toHaveBeenCalled(); + }); + + it('descendant rejection: throws when workspace target is inside current folder', async () => { + const currentPath = path.resolve('/users/dev/MyLogicApp'); + (vscode.workspace as any).workspaceFolders = [{ name: 'MyLogicApp', uri: { fsPath: currentPath } as vscode.Uri, index: 0 }]; + + await expect( + createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: currentPath, path: currentPath }, + workspaceName: 'subdir', + }) + ).rejects.toThrow(/inside the currently open folder/); + }); +}); From d8a16a31f37cba157f7356c8399a7dd2e3ad9698 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Thu, 20 Aug 2026 00:51:52 -0400 Subject: [PATCH 16/23] fix errors in pick custom code worker process events due to race condition --- .../pickCustomCodeWorkerProcess.test.ts | 31 ++++++--- .../src/app/commands/debugLogicApp.ts | 1 - .../commands/pickCustomCodeWorkerProcess.ts | 68 ++++++++++++------- 3 files changed, 65 insertions(+), 35 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts b/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts index cd4300cb2ad..0e61694ef46 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as vscode from 'vscode'; -import { - pickCustomCodeNetHostProcessInternal, - pickCustomCodeWorkerChildProcess, -} from '../pickCustomCodeWorkerProcess'; +import { pickCustomCodeNetHostProcessInternal, pickCustomCodeWorkerChildProcess } from '../pickCustomCodeWorkerProcess'; import * as validatePreDebug from '../../debug/validatePreDebug'; import { IRunningFuncTask, runningFuncTaskMap } from '../../utils/funcCoreTools/funcHostTask'; import * as pickFuncProcessModule from '../pickFuncProcess'; @@ -21,6 +18,14 @@ vi.mock('vscode', () => ({ }, })); +vi.mock('../../utils/delay', () => ({ + delay: vi.fn((ms: number) => { + // Advance fake timers so the polling loop's Date.now() check terminates + vi.advanceTimersByTime(ms); + return Promise.resolve(); + }), +})); + describe('pickCustomCodeNetHostProcessInternal', () => { const testLogicAppName = 'LogicApp'; const testLogicAppPath = path.join('path', 'to', testLogicAppName); @@ -31,15 +36,17 @@ describe('pickCustomCodeNetHostProcessInternal', () => { name: testLogicAppName, index: 0, }; - const testActionContext = { - telemetry: { properties: {} }, - } as IActionContext; + let testActionContext: IActionContext; const testFuncTask: IRunningFuncTask = { startTime: Date.now(), processId: Number(testFuncPid), }; beforeEach(() => { + vi.useFakeTimers(); + testActionContext = { + telemetry: { properties: {}, measurements: {} }, + } as IActionContext; (vscode.workspace as any).workspaceFolders = [testLogicAppWorkspaceFolder]; vi.spyOn(validatePreDebug, 'getMatchingWorkspaceFolder').mockReturnValue(testLogicAppWorkspaceFolder); @@ -49,15 +56,17 @@ describe('pickCustomCodeNetHostProcessInternal', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); runningFuncTaskMap.clear(); }); - it('should return undefined when no child dotnet process exists on the logic app functions host', async () => { + it('should throw when no child dotnet process exists on the logic app functions host after polling', async () => { runningFuncTaskMap.set(testLogicAppWorkspaceFolder, testFuncTask); - await expect( - pickCustomCodeNetHostProcessInternal(testActionContext, testLogicAppWorkspaceFolder, testLogicAppPath) - ).resolves.toBeUndefined(); + await expect(pickCustomCodeNetHostProcessInternal(testActionContext, testLogicAppWorkspaceFolder, testLogicAppPath)).rejects.toThrow( + /Failed to find the .NET host child process/ + ); + expect(testActionContext.telemetry.properties.result).toBe('Failed'); expect(testActionContext.telemetry.properties.lastStep).toBe('pickNetHostChildProcess'); }); diff --git a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts index 6a549518dcf..fe71e668aa3 100644 --- a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts +++ b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts @@ -10,7 +10,6 @@ import { localize } from '../../localize'; import { ext } from '../../extensionVariables'; import { tryGetLogicAppProjectRoot } from '../utils/verifyIsProject'; import { pickCustomCodeNetFxWorkerProcessInternal, pickCustomCodeNetHostProcessInternal } from './pickCustomCodeWorkerProcess'; -import { extensionCommand } from '../../constants'; export async function debugLogicApp( context: IActionContext, diff --git a/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts b/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts index c56dcc3bf80..f5d46d4b161 100644 --- a/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts +++ b/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts @@ -9,17 +9,17 @@ import type * as vscode from 'vscode'; import * as path from 'path'; import { getUnixChildren, getWindowsChildren, pickChildProcess } from './pickFuncProcess'; import { localize } from '../../localize'; -import { ext } from '../../extensionVariables'; +import { delay } from '../utils/delay'; import { Platform } from '@microsoft/vscode-extension-logic-apps'; type OSAgnosticProcess = { command: string | undefined; pid: number | string }; +const WORKER_POLL_INTERVAL_MS = 2000; +const WORKER_POLL_TIMEOUT_MS = 30000; + /** * Picks the .NET host child process of the running function task for the custom code project. - * @param context The action context. - * @param workspaceFolder The workspace folder containing the logic app. - * @param projectPath The path to the logic app project root. - * @returns A promise that resolves to the .NET host child process ID or undefined if not found. + * Polls with a timeout because the worker is spawned lazily by the Functions host. */ export async function pickCustomCodeNetHostProcessInternal( context: IActionContext, @@ -40,22 +40,22 @@ export async function pickCustomCodeNetHostProcessInternal( } context.telemetry.properties.lastStep = 'pickNetHostChildProcess'; - const customCodeNetHostProcess = await pickCustomCodeWorkerChildProcess(taskInfo, false, isCodeless); + const customCodeNetHostProcess = await pollForWorkerProcess(context, taskInfo, false, isCodeless); if (!customCodeNetHostProcess) { + const errorMessage = + 'Failed to find the .NET host child process for the functions project for logic app "{0}". This may be due to the logic app not having a custom code action.'; context.telemetry.properties.result = 'Failed'; - ext.outputChannel.appendLog( - localize( - 'customCodeNet8ChildProcessNotFound', - `Failed to find the .NET host child process for the functions project for logic app "${logicAppName}". This may be due to the logic app not having a custom code action.` - ) - ); - return undefined; + context.telemetry.properties.errorMessage = errorMessage.replace('{0}', logicAppName); + throw new Error(localize('customCodeNet8ChildProcessNotFound', errorMessage, logicAppName)); } - context.telemetry.properties.result = 'Succeeded'; return customCodeNetHostProcess; } +/** + * Picks the CustomCodeNetFxWorker child process of the running function task. + * Polls with a timeout because the worker is spawned lazily by the Functions host. + */ export async function pickCustomCodeNetFxWorkerProcessInternal( context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, @@ -74,22 +74,44 @@ export async function pickCustomCodeNetFxWorkerProcessInternal( } context.telemetry.properties.lastStep = 'pickNetFxWorkerChildProcess'; - const customCodeNetFxWorkerProcess = await pickCustomCodeWorkerChildProcess(taskInfo, true); + const customCodeNetFxWorkerProcess = await pollForWorkerProcess(context, taskInfo, true, true); if (!customCodeNetFxWorkerProcess) { + const errorMessage = + 'Failed to find the CustomCodeNetFxWorker process for logic app "{0}". This may be due to the logic app not having a custom code action.'; context.telemetry.properties.result = 'Failed'; - ext.outputChannel.appendLog( - localize( - 'customCodeNetFxChildProcessNotFound', - `Failed to find the CustomCodeNetFxWorker process for logic app "${logicAppName}". This may be due to the logic app not having a custom code action.` - ) - ); - return undefined; + context.telemetry.properties.errorMessage = errorMessage.replace('{0}', logicAppName); + throw new Error(localize('customCodeNetFxChildProcessNotFound', errorMessage, logicAppName)); } - context.telemetry.properties.result = 'Succeeded'; return customCodeNetFxWorkerProcess; } +/** + * Polls for a custom code worker child process until it appears or the timeout elapses. + * Worker processes are spawned lazily by the Functions host, so a single snapshot may + * miss them if they haven't started yet. + */ +async function pollForWorkerProcess( + context: IActionContext, + taskInfo: IRunningFuncTask, + isNetFxWorker: boolean, + isCodeless: boolean +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < WORKER_POLL_TIMEOUT_MS) { + const pid = await pickCustomCodeWorkerChildProcess(taskInfo, isNetFxWorker, isCodeless); + if (pid) { + context.telemetry.measurements.workerWaitDuration = (Date.now() - startTime) / 1000; + return pid; + } + await delay(WORKER_POLL_INTERVAL_MS); + } + + context.telemetry.measurements.workerWaitDuration = (Date.now() - startTime) / 1000; + return undefined; +} + export async function pickCustomCodeWorkerChildProcess( taskInfo: IRunningFuncTask, isNetFxWorker: boolean, From 1dc6dfe094e976d1133c66b543a5faba4670eeb1 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Thu, 20 Aug 2026 01:17:40 -0400 Subject: [PATCH 17/23] fix path comparisons in buildCustomCodeFunctionsProject, remove redundant telemetry results --- .../src/app/commands/buildCustomCodeFunctionsProject.ts | 6 ++++-- .../createCustomCodeFunction/createCustomCodeFunction.ts | 1 - .../src/app/commands/dataMapper/dataMapper.ts | 2 -- apps/vs-code-designer/src/app/commands/debugLogicApp.ts | 1 - .../app/commands/enableDevContainer/enableDevContainer.ts | 2 -- .../generateDeploymentScripts/generateDeploymentScripts.ts | 1 - .../src/app/commands/publishCodefulProject.ts | 1 - apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts | 1 - 8 files changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts b/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts index 4edffbc7fa1..cb7971a3640 100644 --- a/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts +++ b/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts @@ -9,6 +9,7 @@ import { getWorkspaceRoot } from '../utils/workspace'; import { isCustomCodeFunctionsProject, tryGetLogicAppCustomCodeFunctionsProjects } from '../utils/customCodeUtils'; import * as vscode from 'vscode'; import { isNullOrUndefined } from '@microsoft/logic-apps-shared'; +import { isPathEqual } from '../utils/fs'; /** * Builds a custom code functions project if exists. @@ -65,7 +66,7 @@ async function buildCustomCodeProject(functionsProjectPath: string): Promise { const currTaskPath = (task.scope as vscode.WorkspaceFolder)?.uri.fsPath; - return task.name === 'build' && currTaskPath === functionsProjectPath; + return task.name === 'build' && !!currTaskPath && isPathEqual(currTaskPath, functionsProjectPath); }); if (!buildTask) { @@ -75,7 +76,8 @@ async function buildCustomCodeProject(functionsProjectPath: string): Promise((resolve, reject) => { const disposable: vscode.Disposable = vscode.tasks.onDidEndTaskProcess((e) => { const isMatchingTask = - (e.execution.task.scope as vscode.WorkspaceFolder)?.uri.fsPath === functionsProjectPath && e.execution.task.name === buildTask.name; + isPathEqual((e.execution.task.scope as vscode.WorkspaceFolder)?.uri.fsPath ?? '', functionsProjectPath) && + e.execution.task.name === buildTask.name; if (isMatchingTask) { disposable.dispose(); diff --git a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts index 79aa55eb524..0a5940f78bf 100644 --- a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts +++ b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts @@ -107,6 +107,5 @@ export async function createCustomCodeFunction(context: IActionContext, folderPa context.telemetry.properties.errorMessage = err.message; throw err; } - context.telemetry.properties.result = 'Succeeded'; } } diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts b/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts index ac35ee1288f..afd4b5feaac 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts @@ -138,8 +138,6 @@ export async function loadDataMapFile(context: IActionContext, uri: Uri): Promis if (fileUris && fileUris.length > 0) { // Copy the schema file they selected to the Schemas folder (can safely continue map definition loading) await fs.copyFile(fileUris[0].fsPath, schemaPath); - context.telemetry.properties.result = 'Succeeded'; - return true; } } diff --git a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts index fe71e668aa3..3545e4be30b 100644 --- a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts +++ b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts @@ -141,5 +141,4 @@ export async function debugLogicApp( ) ); } - context.telemetry.properties.result = 'Succeeded'; } diff --git a/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts b/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts index 2a3502f0344..4aa1a5fdb1f 100644 --- a/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts +++ b/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts @@ -74,8 +74,6 @@ export async function enableDevContainer(context: IActionContext, workspaceFileP await addDevContainerToWorkspace(workspaceFile, devContainerFolderName); context.telemetry.properties.step = 'devcontainerAddedToWorkspace'; - context.telemetry.properties.result = 'Succeeded'; - const message = localize( 'devContainerEnabled', 'Devcontainer support has been enabled for this workspace. The .devcontainer folder has been created and tasks.json files have been updated to use devcontainer-compatible paths.' diff --git a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts index 09aa349e018..805104480fb 100644 --- a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts +++ b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts @@ -95,7 +95,6 @@ export async function generateDeploymentScripts(context: IActionContext, node?: await wizard.prompt(); await wizard.execute(); - context.telemetry.properties.result = 'Succeeded'; ext.outputChannel.appendLog(localize('completeAzureDeploymentScriptsWizard', 'Azure deployment scripts wizard executed successfully.')); } catch (error) { context.telemetry.properties.pinnedBundleVersion = ext.pinnedBundleVersion.has(projectPath) diff --git a/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts b/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts index 6a6b722ffed..f2eb7a65481 100644 --- a/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts +++ b/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts @@ -85,7 +85,6 @@ export async function publishCodefulProject( try { context.telemetry.properties.lastStep = 'publishCodefulProject'; await runPublishCommand(nodePath); - context.telemetry.properties.result = 'Succeeded'; } catch (error) { context.telemetry.properties.result = 'Failed'; context.telemetry.properties.errorMessage = (error as Error).message ?? String(error); diff --git a/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts b/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts index 85d66af0561..4d16ee2c2d1 100644 --- a/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts +++ b/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts @@ -417,7 +417,6 @@ function runPostExtractSteps(cache: { projectPath: string; textDocumentPath: str if (getContainingWorkspaceFolder(cache.projectPath) && (await fse.pathExists(cache.textDocumentPath))) { window.showTextDocument(await workspace.openTextDocument(Uri.file(cache.textDocumentPath))); } - context.telemetry.properties.result = 'Succeeded'; ext.outputChannel.appendLog(localize('finishedImporting', 'Successfully imported project.')); }); } From c1cec43458cb23b703a711ce1ba2f86f38db997a Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Thu, 20 Aug 2026 02:05:11 -0400 Subject: [PATCH 18/23] update settingsToExclude on deploy --- .../vs-code-designer/src/app/commands/deploy/deploy.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/deploy/deploy.ts b/apps/vs-code-designer/src/app/commands/deploy/deploy.ts index 4c488b63132..ad6e34f4838 100644 --- a/apps/vs-code-designer/src/app/commands/deploy/deploy.ts +++ b/apps/vs-code-designer/src/app/commands/deploy/deploy.ts @@ -101,16 +101,16 @@ async function deploy( addLocalFuncTelemetry(context); let deployProjectPathForWorkflowApp: string | undefined; - const settingsToExclude: string[] = [ + const settingsToExclude: (RegExp | string)[] = [ webhookRedirectHostUri, azureWebJobsStorageKey, ProjectDirectoryPathKey, workflowAuthenticationMethodKey, 'REMOTEDEBUGGINGVERSION', - // Local-debug-only: absolute path to the local Node.js binary used by the inline-code - // language worker. Deploying it points the cloud worker at a non-existent local path, - // which hangs "Execute JavaScript Code" actions in a Running state indefinitely. inlineCodeNodeExecutablePathKey, + /^WEBSITE_/, + /^FUNCTIONS_RUNTIME/, + 'ScmType', ]; context.telemetry.properties.lastStep = 'getDeployFsPath'; @@ -410,7 +410,7 @@ async function managedApiConnectionsExists(workspaceFolder: WorkspaceFolder): Pr async function getProjectPathToDeploy( node: SlotTreeItem, workspaceFolder: WorkspaceFolder, - settingsToExclude: string[], + settingsToExclude: (RegExp | string)[], originalDeployFsPath: string, identityWizardContext: IIdentityWizardContext, actionContext: IActionContext From db50a91cb206761c46b2d5816af1fef7ceef575c Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Thu, 20 Aug 2026 02:11:58 -0400 Subject: [PATCH 19/23] add explicit 'Succeeded' back to some commands --- .../src/app/commands/enableDevContainer/enableDevContainer.ts | 1 + apps/vs-code-designer/src/app/commands/publishCodefulProject.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts b/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts index 4aa1a5fdb1f..914aba0035c 100644 --- a/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts +++ b/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts @@ -73,6 +73,7 @@ export async function enableDevContainer(context: IActionContext, workspaceFileP // Add .devcontainer folder to workspace file await addDevContainerToWorkspace(workspaceFile, devContainerFolderName); context.telemetry.properties.step = 'devcontainerAddedToWorkspace'; + context.telemetry.properties.result = 'Succeeded'; const message = localize( 'devContainerEnabled', diff --git a/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts b/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts index f2eb7a65481..6a6b722ffed 100644 --- a/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts +++ b/apps/vs-code-designer/src/app/commands/publishCodefulProject.ts @@ -85,6 +85,7 @@ export async function publishCodefulProject( try { context.telemetry.properties.lastStep = 'publishCodefulProject'; await runPublishCommand(nodePath); + context.telemetry.properties.result = 'Succeeded'; } catch (error) { context.telemetry.properties.result = 'Failed'; context.telemetry.properties.errorMessage = (error as Error).message ?? String(error); From f42f2a0a987360075e28754c109eb71dc6f3b5a3 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Thu, 20 Aug 2026 11:58:07 -0400 Subject: [PATCH 20/23] use default .net dependency version when feed unavailable --- .../validateAndInstallBinaries.test.ts | 6 +++--- .../binaries/validateAndInstallBinaries.ts | 10 +++++----- .../__test__/validateDotNetIsLatest.test.ts | 10 ++++++++++ .../commands/dotnet/validateDotNetIsLatest.ts | 8 +++++++- .../validateFuncCoreToolsIsLatest.ts | 1 - .../src/app/utils/__test__/bundleFeed.test.ts | 6 +++--- .../src/app/utils/bundleFeed.ts | 20 +++++-------------- apps/vs-code-designer/src/constants.ts | 2 ++ .../src/graphify-out/GRAPH_REPORT.md | 6 +++--- .../src/graphify-out/graph.json | 8 ++++---- .../src/lib/models/bundleFeed.ts | 2 +- 11 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts b/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts index ddcc2986129..994ca05d341 100644 --- a/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts +++ b/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { defaultDependencyPathValue } from '../../../../constants'; import { ext } from '../../../../extensionVariables'; import { getDependencyTimeout, ensureRuntimeDependenciesDir } from '../../../utils/binaries'; -import { ensureExtensionBundleHealthy, getDependenciesVersion } from '../../../utils/bundleFeed'; +import { ensureExtensionBundleHealthy, getBundleDependencyFeed } from '../../../utils/bundleFeed'; import { recordDependencyUpdateCheck, shouldCheckForDependencyUpdates } from '../../../state/dependencies'; import { setDotNetCommand } from '../../../utils/dotnet/dotnet'; import { setFunctionsCommand } from '../../../utils/funcCoreTools/funcVersion'; @@ -28,7 +28,7 @@ vi.mock('../../../utils/binaries', () => ({ })); vi.mock('../../../utils/bundleFeed', () => ({ - getDependenciesVersion: vi.fn(), + getBundleDependencyFeed: vi.fn(), ensureExtensionBundleHealthy: vi.fn().mockResolvedValue(undefined), })); @@ -106,7 +106,7 @@ describe('validateAndInstallBinaries', () => { (shouldRequireStrictDependencyValidation as Mock).mockReturnValue(false); (shouldCheckForDependencyUpdates as Mock).mockReturnValue(true); (recordDependencyUpdateCheck as Mock).mockResolvedValue(undefined); - (getDependenciesVersion as Mock).mockResolvedValue({ + (getBundleDependencyFeed as Mock).mockResolvedValue({ nodejs: '18.0.0', funcCoreTools: '4.0.0', dotnetVersions: '8.0.100', diff --git a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts index 2dcb29c9005..02be2831221 100644 --- a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts +++ b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts @@ -5,7 +5,7 @@ import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { ensureRuntimeDependenciesDir, getDependencyTimeout } from '../../utils/binaries'; -import { getDependenciesVersion, ensureExtensionBundleHealthy } from '../../utils/bundleFeed'; +import { ensureExtensionBundleHealthy, getBundleDependencyFeed } from '../../utils/bundleFeed'; import { recordDependencyUpdateCheck, shouldCheckForDependencyUpdates } from '../../state/dependencies'; import { setDotNetCommand } from '../../utils/dotnet/dotnet'; import { setFunctionsCommand } from '../../utils/funcCoreTools/funcVersion'; @@ -17,7 +17,7 @@ import { validateDotNetIsLatest } from '../dotnet/validateDotNetIsLatest'; import { validateFuncCoreToolsIsLatest } from '../funcCoreTools/validateFuncCoreToolsIsLatest'; import { validateNodeJsIsLatest } from '../nodeJs/validateNodeJsIsLatest'; import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IBundleDependencyFeed } from '@microsoft/vscode-extension-logic-apps'; +import type { IRuntimeDependencyVersions } from '@microsoft/vscode-extension-logic-apps'; import * as vscode from 'vscode'; export async function validateAndInstallBinaries(context: IActionContext) { @@ -50,11 +50,11 @@ export async function validateAndInstallBinaries(context: IActionContext) { const performedUpdateCheck = shouldCheckForDependencyUpdates(); context.telemetry.properties.performedDependencyUpdateCheck = `${performedUpdateCheck}`; - context.telemetry.properties.lastStep = 'getDependenciesVersion'; + context.telemetry.properties.lastStep = 'getBundleDependencyFeed'; progress.report({ increment: 10, message: 'Get dependency version from CDN' }); - let dependenciesVersions: IBundleDependencyFeed; + let dependenciesVersions: IRuntimeDependencyVersions; try { - dependenciesVersions = await getDependenciesVersion(context); + dependenciesVersions = await getBundleDependencyFeed(context); context.telemetry.properties.dependenciesVersions = JSON.stringify(dependenciesVersions); } catch (error) { // Unable to get dependency.json, will default to fallback versions 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 index c4e8b7e09da..3e30229d4d0 100644 --- 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 @@ -93,4 +93,14 @@ describe('validateDotNetIsLatest', () => { expect(installDotNet).toHaveBeenCalledWith(context, '8'); expect(getLatestDotNetVersion).not.toHaveBeenCalled(); }); + + it('does not throw when majorVersion is undefined (CDN feed unavailable)', async () => { + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalDotNetVersionFromBinaries).mockResolvedValue('8.0.318'); + vi.mocked(getLatestDotNetVersion).mockResolvedValue('8.0.318'); + + await expect(validateDotNetIsLatest(context, undefined)).resolves.not.toThrow(); + expect(getLocalDotNetVersionFromBinaries).toHaveBeenCalledWith('8'); + expect(context.telemetry.properties.dotnetVersionSource).toBe('fallback'); + }); }); 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 c40505a5ca0..6d721143b9b 100644 --- a/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { isNullOrUndefined } from '@microsoft/logic-apps-shared'; -import { dotnetDependencyName } from '../../../constants'; +import { defaultDotnetMajorVersion, dotnetDependencyName } from '../../../constants'; import { binariesExist, getLatestDotNetVersion } from '../../utils/binaries'; import { shouldCheckForDependencyUpdates } from '../../state/dependencies'; import { getDotNetCommand, getLocalDotNetVersionFromBinaries } from '../../utils/dotnet/dotnet'; @@ -14,6 +14,12 @@ import type { IActionContext } from '@microsoft/vscode-azext-utils'; export async function validateDotNetIsLatest(context: IActionContext, majorVersion?: string): Promise { context.errorHandling.suppressDisplay = true; context.telemetry.properties.isActivationEvent = 'true'; + + if (!majorVersion) { + context.telemetry.properties.dotnetVersionSource = 'fallback'; + majorVersion = defaultDotnetMajorVersion; + } + const majorVersions = majorVersion.split(','); const binaries = await binariesExist(dotnetDependencyName); 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 06a00dd2263..e2188283ac1 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -13,7 +13,6 @@ import { getBrewPackageName } from '../../utils/funcCoreTools/getBrewPackageName import { getFuncPackageManagers } from '../../utils/funcCoreTools/getFuncPackageManagers'; import { getNpmDistTag } from '../../utils/funcCoreTools/getNpmDistTag'; import { sendRequestWithExtTimeout } from '../../utils/requestUtils'; -import { getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; import { isMultiCoreToolsWarningSuppressed, suppressMultiCoreToolsWarning } from '../../state/notifications'; import { installFuncCoreToolsBinaries } from './installFuncCoreTools'; import { uninstallFuncCoreTools } from './uninstallFuncCoreTools'; diff --git a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts index 0b81af9fa68..a180078fa14 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts @@ -1,6 +1,6 @@ import { getBundleVersionNumber, - getDependenciesVersion, + getBundleDependencyFeed, getExtensionBundleFolder, getLatestVersionRange, addDefaultBundle, @@ -582,7 +582,7 @@ describe('getLatestVersionRange', () => { }); }); -describe('getDependenciesVersion', () => { +describe('getBundleDependencyFeed', () => { it('loads dependency feed using a local settings source URI override', async () => { const verifyIsProjectMod = await import('../verifyIsProject'); vi.mocked(verifyIsProjectMod.tryGetLogicAppProjectRoot).mockResolvedValue('/mock/project/path'); @@ -595,7 +595,7 @@ describe('getDependenciesVersion', () => { vi.mocked(feedModule.getJsonFeed).mockResolvedValue({ id: extensionBundleId } as any); const context = { telemetry: { properties: {}, measurements: {} } }; - await expect(getDependenciesVersion(context as any)).resolves.toEqual({ id: extensionBundleId }); + await expect(getBundleDependencyFeed(context as any)).resolves.toEqual({ id: extensionBundleId }); expect(feedModule.getJsonFeed).toHaveBeenCalledWith( context, diff --git a/apps/vs-code-designer/src/app/utils/bundleFeed.ts b/apps/vs-code-designer/src/app/utils/bundleFeed.ts index ca2a29861a9..78d0e81c62e 100644 --- a/apps/vs-code-designer/src/app/utils/bundleFeed.ts +++ b/apps/vs-code-designer/src/app/utils/bundleFeed.ts @@ -20,7 +20,7 @@ import { getJsonFeed } from './feed'; import { recordDependencyUpdateCheck, shouldCheckForDependencyUpdates } from '../state/dependencies'; import { getGlobalSetting } from './vsCodeConfig/settings'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IBundleDependencyFeed, IBundleMetadata, IHostJsonV2 } from '@microsoft/vscode-extension-logic-apps'; +import type { IRuntimeDependencyVersions, IBundleMetadata, IHostJsonV2 } from '@microsoft/vscode-extension-logic-apps'; import { AsyncLocalStorage } from 'node:async_hooks'; import * as path from 'path'; import * as semver from 'semver'; @@ -124,12 +124,12 @@ async function getWorkflowBundleFeed(context: IActionContext): Promise * Gets extension bundle dependency feed. * @param {IActionContext} context - Command context. * @param {IBundleMetadata | undefined} bundleMetadata - Bundle meta data. - * @returns {Promise} Returns bundle extension object. + * @returns {Promise} Returns bundle extension object. */ -async function getBundleDependencyFeed( +export async function getBundleDependencyFeed( context: IActionContext, - bundleMetadata: IBundleMetadata | undefined -): Promise { + bundleMetadata?: IBundleMetadata +): Promise { const bundleId: string = (bundleMetadata && bundleMetadata?.id) || extensionBundleId; const { baseUrl } = await getExtensionBundleBaseUrl(context); const url = `${baseUrl}/ExtensionBundles/${bundleId}/dependency.json`; @@ -144,16 +144,6 @@ export function getLatestVersionRange(): string { return defaultVersionRange; } -/** - * Gets latest bundle extension dependencies versions. - * @param {IActionContext} context - Command context. - * @returns {Promise} Returns dependency versions. - */ -export async function getDependenciesVersion(context: IActionContext): Promise { - const feed: IBundleDependencyFeed = await getBundleDependencyFeed(context, undefined); - return feed; -} - /** * Add bundle extension version to host.json configuration. * @param {IHostJsonV2} hostJson - Host.json configuration. diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 6291f74a912..913946cfcdc 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -391,6 +391,8 @@ export const DependencyVersion = { } as const; export type DependencyVersion = (typeof DependencyVersion)[keyof typeof DependencyVersion]; +export const defaultDotnetMajorVersion = '8'; + export const hostFileContent = { version: '2.0', extensionBundle: { diff --git a/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md b/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md index ff1b01e2ca6..a93f88206ee 100644 --- a/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md +++ b/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md @@ -68,7 +68,7 @@ Nodes (18): Artifacts, FileDetails, IArtifactFile, IGitHubReleaseInfo, IParamete ### Community 3 - "Community 3" Cohesion: 0.15 -Nodes (16): IBundleDependencyFeed, IBundleFeed, BindingSettingValue, IBindingSetting, IBindingTemplate, IEnumValue, ResourceType, ValueType (+8 more) +Nodes (16): IRuntimeDependencyVersions, IBundleFeed, BindingSettingValue, IBindingSetting, IBindingTemplate, IEnumValue, ResourceType, ValueType (+8 more) ### Community 4 - "Community 4" Cohesion: 0.11 @@ -91,13 +91,13 @@ Cohesion: 0.20 Nodes (9): FetchSchemaData, InitializeData, MapDefinitionData, MessageToVsix, MessageToWebview, SchemaPathData, XsltData, ExtensionCommand (+1 more) ## Knowledge Gaps -- **91 isolated node(s):** `IArtifactFile`, `IGitHubReleaseInfo`, `IBundleDependencyFeed`, `ICliFeed`, `IRelease` (+86 more) +- **91 isolated node(s):** `IArtifactFile`, `IGitHubReleaseInfo`, `IRuntimeDependencyVersions`, `ICliFeed`, `IRelease` (+86 more) These have ≤1 connection - possible missing edges or undocumented components. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **What connects `IArtifactFile`, `IGitHubReleaseInfo`, `IBundleDependencyFeed` to the rest of the system?** +- **What connects `IArtifactFile`, `IGitHubReleaseInfo`, `IRuntimeDependencyVersions` to the rest of the system?** _91 weakly-connected nodes found - possible documentation gaps or missing edges._ - **Should `Community 0` be split into smaller, more focused modules?** _Cohesion score 0.07881773399014778 - nodes in this community are weakly interconnected._ diff --git a/libs/vscode-extension/src/graphify-out/graph.json b/libs/vscode-extension/src/graphify-out/graph.json index 3ed9a9118f4..8087b327a21 100644 --- a/libs/vscode-extension/src/graphify-out/graph.json +++ b/libs/vscode-extension/src/graphify-out/graph.json @@ -124,14 +124,14 @@ "norm_label": "ibundlefeed" }, { - "label": "IBundleDependencyFeed", + "label": "IRuntimeDependencyVersions", "file_type": "code", "source_file": "lib/models/bundleFeed.ts", "source_location": "L17", "_origin": "ast", - "id": "models_bundlefeed_ibundledependencyfeed", + "id": "models_bundlefeed_IRuntimeDependencyVersions", "community": 3, - "norm_label": "ibundledependencyfeed" + "norm_label": "IRuntimeDependencyVersions" }, { "label": "cliFeed.ts", @@ -1894,7 +1894,7 @@ "source_location": "L17", "weight": 1.0, "source": "models_bundlefeed", - "target": "models_bundlefeed_ibundledependencyfeed", + "target": "models_bundlefeed_IRuntimeDependencyVersions", "confidence_score": 1.0 }, { diff --git a/libs/vscode-extension/src/lib/models/bundleFeed.ts b/libs/vscode-extension/src/lib/models/bundleFeed.ts index 4692bf50dde..07d11b4bbb1 100644 --- a/libs/vscode-extension/src/lib/models/bundleFeed.ts +++ b/libs/vscode-extension/src/lib/models/bundleFeed.ts @@ -14,7 +14,7 @@ export interface IBundleFeed { }; } -export interface IBundleDependencyFeed { +export interface IRuntimeDependencyVersions { dotnet?: string; funcCoreTools?: string; nodejs?: string; From a65014cc0e83dc03d3a29f6dcfb47d3a7cacb182 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:51:35 +0000 Subject: [PATCH 21/23] fix(vscode): seed NuGet E2E workspace before open --- Localize/lang/strings.json | 2 ++ .../vs-code-designer/src/test/ui/nugetDebugConversion.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index 5b1175f1782..84e18c20378 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -1142,6 +1142,7 @@ "KY5eNe": "On", "KYX5Do": "Underline (⌘U)", "KZOa5l": "Cancel", + "Karw2Q": "Workspace location cannot be inside the currently open project folder. Choose the current folder or a location outside it.", "Kasmd1": "Go to workflow", "Kb5u9F": "About tab", "KlDW+5": "(UTC+02:00) Beirut", @@ -3099,6 +3100,7 @@ "_KY5eNe.comment": "Label for the enabled switch", "_KYX5Do.comment": "Command for underline text for Mac users", "_KZOa5l.comment": "Label for cancel button", + "_Karw2Q.comment": "Workspace location is a descendant of the currently open folder error text", "_Kasmd1.comment": "Label to indicate go to the workflow", "_Kb5u9F.comment": "An accessibility label that describes the about tab", "_KlDW+5.comment": "Time zone value ", diff --git a/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts b/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts index 07857400aa5..31cfc4b84be 100644 --- a/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts +++ b/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts @@ -753,12 +753,12 @@ describe('NuGet conversion debug lifecycle', function () { const workbench = new Workbench(); const driver = workbench.getDriver(); + seedBundleProjectFilesIfNeeded(entry); + seedRunnableWorkflow(entry); await openWorkspaceFileInSession(workbench, entry.wsFilePath); if (process.env.LA_E2E_SKIP_VALIDATION_WAIT !== '1') { await waitForDependencyValidation(driver); } - seedBundleProjectFilesIfNeeded(entry); - seedRunnableWorkflow(entry); await startDebugging(workbench, driver); await runAndVerifyWorkflow('bundle', workbench, driver, entry); From 41f32555ac3c5fa1891e92780410529dd6a5715d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:23:15 +0000 Subject: [PATCH 22/23] fix(templates): mount only the active template panel to avoid stale aria-hidden --- .../gallery/templatesfullgalleryview.tsx | 10 ++++-- .../src/lib/ui/templates/templateview.tsx | 22 +++++++----- .../gallery/templatesfullgalleryview.tsx | 10 ++++-- .../src/lib/ui/templates/templateview.tsx | 36 +++++++++++-------- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx b/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx index f6e3d44e8ca..383ffc6932d 100644 --- a/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx +++ b/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx @@ -50,11 +50,17 @@ export const TemplatesFullGalleryView = ({ detailFilters, createWorkflowCall, is const WorkflowView = ({ createWorkflowCall }: { createWorkflowCall: CreateWorkflowHandler }) => { const { templateName, workflows } = useSelector((state: RootState) => state.template); + const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); + // Only one panel is mounted at a time, otherwise the two modal drawers race when switching + // between them and the newly opened drawer can be left with aria-hidden set on it. return templateName === undefined || Object.keys(workflows).length !== 1 ? null : ( <> - - + {currentPanelView === TemplatePanelView.CreateWorkflow ? ( + + ) : ( + + )} ); }; diff --git a/libs/designer-v2/src/lib/ui/templates/templateview.tsx b/libs/designer-v2/src/lib/ui/templates/templateview.tsx index 0803c437cac..91f63f6715f 100644 --- a/libs/designer-v2/src/lib/ui/templates/templateview.tsx +++ b/libs/designer-v2/src/lib/ui/templates/templateview.tsx @@ -65,6 +65,7 @@ const SingleTemplateView = ({ const { workflows } = useSelector((state: RootState) => ({ workflows: state.template.workflows, })); + const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); useEffect(() => { if (showSummary) { @@ -75,14 +76,19 @@ const SingleTemplateView = ({ }, [dispatch, showSummary]); return ( <> - - + {/* Only one panel is mounted at a time, otherwise the two modal drawers race when switching + between them and the newly opened drawer can be left with aria-hidden set on it. */} + {currentPanelView === TemplatePanelView.CreateWorkflow ? ( + + ) : ( + + )}
{ const { templateName, workflows } = useSelector((state: RootState) => state.template); + const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); const containerRef = useRef(null); + // Only one panel is mounted at a time, otherwise the two modal drawers race when switching + // between them and the newly opened drawer can be left with aria-hidden set on it. return templateName === undefined || Object.keys(workflows).length !== 1 ? null : (
- - + {currentPanelView === TemplatePanelView.CreateWorkflow ? ( + + ) : ( + + )}
); }; diff --git a/libs/designer/src/lib/ui/templates/templateview.tsx b/libs/designer/src/lib/ui/templates/templateview.tsx index 06e97f9233a..b0be2ab2195 100644 --- a/libs/designer/src/lib/ui/templates/templateview.tsx +++ b/libs/designer/src/lib/ui/templates/templateview.tsx @@ -65,6 +65,7 @@ const SingleTemplateView = ({ const { workflows } = useSelector((state: RootState) => ({ workflows: state.template.workflows, })); + const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); useEffect(() => { if (showSummary) { @@ -79,21 +80,26 @@ const SingleTemplateView = ({ return (
- - + {/* Only one panel is mounted at a time, otherwise the two modal drawers race when switching + between them and the newly opened drawer can be left with aria-hidden set on it. */} + {currentPanelView === TemplatePanelView.CreateWorkflow ? ( + + ) : ( + + )}
Date: Thu, 20 Aug 2026 18:37:22 -0400 Subject: [PATCH 23/23] Revert "fix(templates): mount only the active template panel to avoid stale aria-hidden" This reverts commit 41f32555ac3c5fa1891e92780410529dd6a5715d. --- .../gallery/templatesfullgalleryview.tsx | 10 ++---- .../src/lib/ui/templates/templateview.tsx | 22 +++++------- .../gallery/templatesfullgalleryview.tsx | 10 ++---- .../src/lib/ui/templates/templateview.tsx | 36 ++++++++----------- 4 files changed, 27 insertions(+), 51 deletions(-) diff --git a/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx b/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx index 383ffc6932d..f6e3d44e8ca 100644 --- a/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx +++ b/libs/designer-v2/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx @@ -50,17 +50,11 @@ export const TemplatesFullGalleryView = ({ detailFilters, createWorkflowCall, is const WorkflowView = ({ createWorkflowCall }: { createWorkflowCall: CreateWorkflowHandler }) => { const { templateName, workflows } = useSelector((state: RootState) => state.template); - const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); - // Only one panel is mounted at a time, otherwise the two modal drawers race when switching - // between them and the newly opened drawer can be left with aria-hidden set on it. return templateName === undefined || Object.keys(workflows).length !== 1 ? null : ( <> - {currentPanelView === TemplatePanelView.CreateWorkflow ? ( - - ) : ( - - )} + + ); }; diff --git a/libs/designer-v2/src/lib/ui/templates/templateview.tsx b/libs/designer-v2/src/lib/ui/templates/templateview.tsx index 91f63f6715f..0803c437cac 100644 --- a/libs/designer-v2/src/lib/ui/templates/templateview.tsx +++ b/libs/designer-v2/src/lib/ui/templates/templateview.tsx @@ -65,7 +65,6 @@ const SingleTemplateView = ({ const { workflows } = useSelector((state: RootState) => ({ workflows: state.template.workflows, })); - const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); useEffect(() => { if (showSummary) { @@ -76,19 +75,14 @@ const SingleTemplateView = ({ }, [dispatch, showSummary]); return ( <> - {/* Only one panel is mounted at a time, otherwise the two modal drawers race when switching - between them and the newly opened drawer can be left with aria-hidden set on it. */} - {currentPanelView === TemplatePanelView.CreateWorkflow ? ( - - ) : ( - - )} + +
{ const { templateName, workflows } = useSelector((state: RootState) => state.template); - const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); const containerRef = useRef(null); - // Only one panel is mounted at a time, otherwise the two modal drawers race when switching - // between them and the newly opened drawer can be left with aria-hidden set on it. return templateName === undefined || Object.keys(workflows).length !== 1 ? null : (
- {currentPanelView === TemplatePanelView.CreateWorkflow ? ( - - ) : ( - - )} + +
); }; diff --git a/libs/designer/src/lib/ui/templates/templateview.tsx b/libs/designer/src/lib/ui/templates/templateview.tsx index b0be2ab2195..06e97f9233a 100644 --- a/libs/designer/src/lib/ui/templates/templateview.tsx +++ b/libs/designer/src/lib/ui/templates/templateview.tsx @@ -65,7 +65,6 @@ const SingleTemplateView = ({ const { workflows } = useSelector((state: RootState) => ({ workflows: state.template.workflows, })); - const currentPanelView = useSelector((state: RootState) => state.panel.currentPanelView); useEffect(() => { if (showSummary) { @@ -80,26 +79,21 @@ const SingleTemplateView = ({ return (
- {/* Only one panel is mounted at a time, otherwise the two modal drawers race when switching - between them and the newly opened drawer can be left with aria-hidden set on it. */} - {currentPanelView === TemplatePanelView.CreateWorkflow ? ( - - ) : ( - - )} + +