From 9bdb574e423b042fe8d3668c401e8d4a0f73f354 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 1 Sep 2026 12:08:29 -0400 Subject: [PATCH 1/3] remove defaultLogicAppPath --- .../app/commands/dataMapper/DataMapperExt.ts | 9 ++-- .../commands/dataMapper/DataMapperPanel.ts | 41 ++++++++--------- .../src/app/commands/dataMapper/dataMapper.ts | 44 ++++++++----------- .../src/extensionVariables.ts | 1 - apps/vs-code-designer/src/main.ts | 13 ------ 5 files changed, 44 insertions(+), 64 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts index 96884d5490c..69f6bf430b5 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts @@ -18,10 +18,11 @@ import { assetsFolderName, dataMapNameValidation } from '../../../constants'; export default class DataMapperExt { public static async openDataMapperPanel( context: IActionContext, + projectPath: string, dataMapName?: string, mapDefinitionData?: MapDefinitionData ): Promise { - await startBackendRuntime(context, ext.defaultLogicAppPath); + await startBackendRuntime(context, projectPath); const name = dataMapName ?? (await context.ui.showInputBox({ @@ -29,7 +30,7 @@ export default class DataMapperExt { prompt: localize('dataMapNamePrompt', 'Enter a name for your Data Map'), validateInput: async (input: string): Promise => await DataMapperExt.validateDataMapName(input), })); - DataMapperExt.createOrShow(name, mapDefinitionData); + DataMapperExt.createOrShow(name, projectPath, mapDefinitionData); } /* @@ -70,7 +71,7 @@ export default class DataMapperExt { return undefined; } - private static createOrShow(dataMapName: string, mapDefinitionData?: MapDefinitionData) { + private static createOrShow(dataMapName: string, projectPath: string, mapDefinitionData?: MapDefinitionData) { // If a panel has already been created, re-show it if (ext.dataMapPanelManagers[dataMapName]) { // NOTE: Shouldn't need to re-send runtime port if webview has already been loaded/set up @@ -92,7 +93,7 @@ export default class DataMapperExt { } ); - ext.dataMapPanelManagers[dataMapName] = new DataMapperPanel(panel, dataMapName); + ext.dataMapPanelManagers[dataMapName] = new DataMapperPanel(panel, dataMapName, projectPath); ext.dataMapPanelManagers[dataMapName].panel.iconPath = { light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'wand.png')), dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'wand.png')), diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts index 292488caeda..d0d76da6843 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { dataMapperVersionSetting, defaultDataMapperVersion, extensionCommand, vscodeFolderName } from '../../../constants'; +import { dataMapperVersionSetting, defaultDataMapperVersion, vscodeFolderName } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { getWebViewHTML } from '../../utils/codeless/getWebViewHTML'; @@ -45,15 +45,17 @@ export default class DataMapperPanel { public panel: WebviewPanel; public dataMapVersion: number; public dataMapName: string; + public projectPath: string; public dataMapStateIsDirty: boolean; public mapDefinitionData: MapDefinitionData | undefined; private telemetryPrefix = 'data-mapper-vscode-extension'; - constructor(panel: WebviewPanel, dataMapName: string) { + constructor(panel: WebviewPanel, dataMapName: string, projectPath: string) { this.panel = panel; this.dataMapVersion = this.getDataMapperVersion(); this.dataMapName = dataMapName; + this.projectPath = projectPath; this.dataMapStateIsDirty = false; this.handleReadSchemaFileOptions = this.handleReadSchemaFileOptions.bind(this); // Bind these as they're used as callbacks this._handleWebviewMsg = this._handleWebviewMsg.bind(this); @@ -97,7 +99,7 @@ export default class DataMapperPanel { private watchFolderForChanges(folderPath: string, fileExtensions: string[], fn: () => void) { // Watch folder for changes to update available file list within Data Mapper - const absoluteFolderPath = path.join(ext.defaultLogicAppPath, folderPath); + const absoluteFolderPath = path.join(this.projectPath, folderPath); if (fileExistsSync(absoluteFolderPath)) { const folderWatcher = workspace.createFileSystemWatcher(new RelativePattern(absoluteFolderPath, `**/*.{${fileExtensions.join()}}`)); folderWatcher.onDidCreate(fn); @@ -108,10 +110,10 @@ export default class DataMapperPanel { } private setCustomFolders() { - const customXsltFullPath = path.join(ext.defaultLogicAppPath, customXsltPath); + const customXsltFullPath = path.join(this.projectPath, customXsltPath); mkdirSync(customXsltFullPath, { recursive: true }); - const customFunctionsFullPath = path.join(ext.defaultLogicAppPath, customFunctionsPath); + const customFunctionsFullPath = path.join(this.projectPath, customFunctionsPath); mkdirSync(customFunctionsFullPath, { recursive: true }); } @@ -134,7 +136,7 @@ export default class DataMapperPanel { // Send runtime port to webview this.panel.webview.postMessage({ command: ExtensionCommand.setRuntimePort, - data: `${ext.designTimeInstances.get(ext.defaultLogicAppPath)?.port}`, + data: `${ext.designTimeInstances.get(this.projectPath)?.port}`, }); // If loading a data map, handle that + xslt filename @@ -249,7 +251,7 @@ export default class DataMapperPanel { } public getNestedFilePaths(fileName: string, parentPath: string, relativePath: string, filesToDisplay: string[], filetypes: string[]) { - const rootPath = path.join(ext.defaultLogicAppPath, relativePath); + const rootPath = path.join(this.projectPath, relativePath); const absolutePath = path.join(rootPath, parentPath, fileName); if (statSync(absolutePath).isDirectory()) { readdirSync(absolutePath).forEach((childFileName) => { @@ -272,7 +274,7 @@ export default class DataMapperPanel { filesToDisplay: IFileSysTreeItem[], filetypes: string[] ) { - const rootPath = path.join(ext.defaultLogicAppPath, relativePath); + const rootPath = path.join(this.projectPath, relativePath); const absolutePath = path.join(rootPath, parentPath, fileName); if (statSync(absolutePath).isDirectory()) { const childrenFilesToDisplay: IFileSysTreeItem[] = []; @@ -311,7 +313,7 @@ export default class DataMapperPanel { if (this.dataMapVersion === 2) { return this.getFilesTreeForPath(customXsltPath, supportedCustomXsltFileExts, ExtensionCommand.getAvailableCustomXsltPathsV2); } - const absoluteFolderPath = path.join(ext.defaultLogicAppPath, customXsltPath); + const absoluteFolderPath = path.join(this.projectPath, customXsltPath); if (fileExistsSync(absoluteFolderPath)) { return this.getFilesForPath(customXsltPath, ExtensionCommand.getAvailableCustomXsltPaths, supportedCustomXsltFileExts); } @@ -344,7 +346,7 @@ export default class DataMapperPanel { command: typeof ExtensionCommand.showAvailableSchemas | typeof ExtensionCommand.getAvailableCustomXsltPaths, fileTypes: string[] ) { - fs.readdir(path.join(ext.defaultLogicAppPath, folderPath)).then((result) => { + fs.readdir(path.join(this.projectPath, folderPath)).then((result) => { const filesToDisplay: string[] = []; result.forEach((file) => { this.getNestedFilePaths(file, '', folderPath, filesToDisplay, fileTypes); @@ -361,7 +363,7 @@ export default class DataMapperPanel { fileTypes: string[], command: typeof ExtensionCommand.showAvailableSchemasV2 | typeof ExtensionCommand.getAvailableCustomXsltPathsV2 ) { - fs.readdir(path.join(ext.defaultLogicAppPath, folderPath)).then((result) => { + fs.readdir(path.join(this.projectPath, folderPath)).then((result) => { const filesToDisplay: IFileSysTreeItem[] = []; result.forEach((file) => { this.getNestedFileTreePaths(file, '', folderPath, filesToDisplay, fileTypes); @@ -399,7 +401,7 @@ export default class DataMapperPanel { } const selectedFile = files[0]; - const pathToWorkspaceSchemaFolder = path.join(ext.defaultLogicAppPath, schemasPath); + const pathToWorkspaceSchemaFolder = path.join(this.projectPath, schemasPath); const primarySchemaFullPath = selectedFile.fsPath; const pathToContainingFolder = path.dirname(primarySchemaFullPath); const primarySchemaFileName = path.basename(primarySchemaFullPath); @@ -433,7 +435,7 @@ export default class DataMapperPanel { this.setDataMapperVersionForLogging(context); const fileName = `${this.dataMapName}${mapDefinitionExtension}`; - const dataMapFolderPath = path.join(ext.defaultLogicAppPath, dataMapDefinitionsPath); + const dataMapFolderPath = path.join(this.projectPath, dataMapDefinitionsPath); const filePath = path.join(dataMapFolderPath, fileName); // Mkdir as extra insurance that directory exists so file can be written @@ -465,7 +467,7 @@ export default class DataMapperPanel { this.setDataMapperVersionForLogging(context); const fileName = `${this.dataMapName}${mapXsltExtension}`; - const dataMapFolderPath = path.join(ext.defaultLogicAppPath, dataMapsPath); + const dataMapFolderPath = path.join(this.projectPath, dataMapsPath); const filePath = path.join(dataMapFolderPath, fileName); // Mkdir as extra insurance that directory exists so file can be written @@ -489,7 +491,7 @@ export default class DataMapperPanel { public saveDraftDataMapDefinition(mapDefFileContents: string) { const mapDefileName = `${this.dataMapName}${draftMapDefinitionSuffix}${mapDefinitionExtension}`; - const dataMapDefFolderPath = path.join(ext.defaultLogicAppPath, dataMapDefinitionsPath); + const dataMapDefFolderPath = path.join(this.projectPath, dataMapDefinitionsPath); const filePath = path.join(dataMapDefFolderPath, mapDefileName); // Mkdir as extra insurance that directory exists so file can be written @@ -542,7 +544,7 @@ export default class DataMapperPanel { public deleteDraftDataMapDefinition() { const draftMapDefinitionPath = path.join( - ext.defaultLogicAppPath, + this.projectPath, dataMapDefinitionsPath, `${this.dataMapName}${draftMapDefinitionSuffix}${mapDefinitionExtension}` ); @@ -552,7 +554,7 @@ export default class DataMapperPanel { } public checkAndSetXslt() { - const expectedXsltPath = path.join(ext.defaultLogicAppPath, dataMapsPath, `${this.dataMapName}${mapXsltExtension}`); + const expectedXsltPath = path.join(this.projectPath, dataMapsPath, `${this.dataMapName}${mapXsltExtension}`); if (fileExistsSync(expectedXsltPath)) { fs.readFile(expectedXsltPath, 'utf-8').then((fileContents) => { @@ -592,12 +594,11 @@ export default class DataMapperPanel { } private getMapMetadataPath() { - const projectPath = ext.defaultLogicAppPath; let vscodeFolderPath = ''; if (this.dataMapVersion === 2) { - vscodeFolderPath = path.join(projectPath, vscodeFolderName, `${this.dataMapName}DataMapMetadata-v2.json`); + vscodeFolderPath = path.join(this.projectPath, vscodeFolderName, `${this.dataMapName}DataMapMetadata-v2.json`); } else { - vscodeFolderPath = path.join(projectPath, vscodeFolderName, `${this.dataMapName}DataMapMetadata.json`); + vscodeFolderPath = path.join(this.projectPath, vscodeFolderName, `${this.dataMapName}DataMapMetadata.json`); } return vscodeFolderPath; } 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 afd4b5feaac..e45e6c43f6c 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts @@ -17,42 +17,34 @@ import { getWorkspaceFolder } from '../../utils/workspace'; import { verifyAndPromptToCreateProject } from '../../utils/verifyIsProject'; export async function createDataMap(context: IActionContext): Promise { - if (isNullOrUndefined(ext.defaultLogicAppPath)) { - const workspaceFolder = await getWorkspaceFolder( - context, - localize('openLogicAppsProject', 'You must have a logic apps project open to use the Data Mapper.') - ); - const projectPath: string | undefined = - !isNullOrUndefined(workspaceFolder) && (await verifyAndPromptToCreateProject(context, workspaceFolder?.uri?.fsPath)); - if (!projectPath) { - return; - } - ext.defaultLogicAppPath = projectPath; + const workspaceFolder = await getWorkspaceFolder( + context, + localize('openLogicAppsProject', 'You must have a logic apps project open to use the Data Mapper.') + ); + const projectPath = !isNullOrUndefined(workspaceFolder) && (await verifyAndPromptToCreateProject(context, workspaceFolder?.uri?.fsPath)); + if (!projectPath) { + return; } - DataMapperExt.openDataMapperPanel(context); + DataMapperExt.openDataMapperPanel(context, projectPath); } export async function loadDataMapFile(context: IActionContext, uri: Uri): Promise { let mapDefinitionPath: string | undefined = uri?.fsPath; let draftFileIsFoundAndShouldBeUsed = false; - if (isNullOrUndefined(ext.defaultLogicAppPath)) { - const workspaceFolder = await getWorkspaceFolder( - context, - localize('openLogicAppsProject', 'You must have a logic apps project open to use the Data Mapper.') - ); - const projectPath: string | undefined = - !isNullOrUndefined(workspaceFolder) && (await verifyAndPromptToCreateProject(context, workspaceFolder?.uri?.fsPath)); - if (!projectPath) { - return; - } - ext.defaultLogicAppPath = projectPath; + const workspaceFolder = await getWorkspaceFolder( + context, + localize('openLogicAppsProject', 'You must have a logic apps project open to use the Data Mapper.') + ); + const projectPath = !isNullOrUndefined(workspaceFolder) && (await verifyAndPromptToCreateProject(context, workspaceFolder?.uri?.fsPath)); + if (!projectPath) { + return; } // Handle if Uri isn't provided/defined (cmd pallette or btn) if (!mapDefinitionPath) { const fileUris = await window.showOpenDialog({ title: 'Select a data map definition to load', - defaultUri: Uri.file(path.join(ext.defaultLogicAppPath, dataMapDefinitionsPath)), + defaultUri: Uri.file(path.join(projectPath, dataMapDefinitionsPath)), canSelectMany: false, canSelectFiles: true, canSelectFolders: false, @@ -112,7 +104,7 @@ export async function loadDataMapFile(context: IActionContext, uri: Uri): Promis } // Attempt to load schema files if specified - const schemasFolder = path.join(ext.defaultLogicAppPath, schemasPath); + const schemasFolder = path.join(projectPath, schemasPath); const srcSchemaPath = path.join(schemasFolder, mapDefinition.$sourceSchema); const tgtSchemaPath = path.join(schemasFolder, mapDefinition.$targetSchema); @@ -179,7 +171,7 @@ export async function loadDataMapFile(context: IActionContext, uri: Uri): Promis const dataMapName = path.basename(mapDefinitionPath, path.extname(mapDefinitionPath)).replace(draftMapDefinitionSuffix, ''); // Gets filename w/o ext (and w/o draft suffix) // Set map definition data to be loaded once webview sends webviewLoaded msg - DataMapperExt.openDataMapperPanel(context, dataMapName, { + DataMapperExt.openDataMapperPanel(context, projectPath, dataMapName, { mapDefinition, sourceSchemaFileName: path.basename(srcSchemaPath), targetSchemaFileName: path.basename(tgtSchemaPath), diff --git a/apps/vs-code-designer/src/extensionVariables.ts b/apps/vs-code-designer/src/extensionVariables.ts index c16c42e4361..beaece4acaf 100644 --- a/apps/vs-code-designer/src/extensionVariables.ts +++ b/apps/vs-code-designer/src/extensionVariables.ts @@ -38,7 +38,6 @@ export namespace ext { export const runtimeInstances: Map = new Map(); export let workflowDotNetProcess: cp.ChildProcess | undefined; export let workflowNodeProcess: cp.ChildProcess | undefined; - export let defaultLogicAppPath: string; export let outputChannel: IAzExtOutputChannel; // TODO(aeldridge): Multiple runtime processes are supported with runningFuncTaskMap, but only a single runtime port is tracked. // This will cause issues if multiple runtime processes are started on different ports. Currently we use the default port (7071) diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index 5dbbccfc4ae..c13e25441a0 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -179,19 +179,6 @@ export async function activate(context: vscode.ExtensionContext) { // @ts-expect-error _rootTreeItem does not exist on type AzExtTreeDataProvider ext.azureAccountTreeItem = ext.rgApi.appResourceTree._rootTreeItem as AzureAccountTreeItemWithProjects; - // TODO(aeldridge): This was added to avoid behavior change after modifying .vscode config validation to not set - // ext.defaultLogicAppPath. This should be revisited - a default logic app shouldn't be needed in ext context. - activateContext.telemetry.properties.lastStep = 'setDefaultLogicAppPath'; - if (vscode.workspace.workspaceFolders) { - for (const folder of vscode.workspace.workspaceFolders) { - const projectPath = await tryGetLogicAppProjectRoot(activateContext, folder, true); - if (projectPath) { - ext.defaultLogicAppPath = projectPath; - break; - } - } - } - context.subscriptions.push(ext.outputChannel); context.subscriptions.push(ext.azureAccountTreeItem); From 9efbffdf5aab1ceaeed7494fa0c1e87cfa678ec1 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 1 Sep 2026 13:24:14 -0400 Subject: [PATCH 2/3] address pr comments --- .../app/commands/dataMapper/DataMapperExt.ts | 18 ++++++++++++------ .../app/commands/dataMapper/DataMapperPanel.ts | 6 ++++-- .../src/app/commands/dataMapper/dataMapper.ts | 1 - .../vs-code-designer/src/extensionVariables.ts | 2 +- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts index 69f6bf430b5..e3240c8acb8 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts @@ -15,6 +15,10 @@ import { parse } from 'yaml'; import { localize } from '../../../localize'; import { assetsFolderName, dataMapNameValidation } from '../../../constants'; +export function getDataMapPanelKey(projectPath: string, dataMapName: string): string { + return `${projectPath}::${dataMapName}`; +} + export default class DataMapperExt { public static async openDataMapperPanel( context: IActionContext, @@ -72,12 +76,14 @@ export default class DataMapperExt { } private static createOrShow(dataMapName: string, projectPath: string, mapDefinitionData?: MapDefinitionData) { + const panelKey = getDataMapPanelKey(projectPath, dataMapName); + // If a panel has already been created, re-show it - if (ext.dataMapPanelManagers[dataMapName]) { + if (ext.dataMapPanelManagers[panelKey]) { // NOTE: Shouldn't need to re-send runtime port if webview has already been loaded/set up window.showInformationMessage(`A Data Mapper panel is already open for this data map (${dataMapName}).`); - ext.dataMapPanelManagers[dataMapName].panel.reveal(ViewColumn.Active); + ext.dataMapPanelManagers[panelKey].panel.reveal(ViewColumn.Active); return; } @@ -93,13 +99,13 @@ export default class DataMapperExt { } ); - ext.dataMapPanelManagers[dataMapName] = new DataMapperPanel(panel, dataMapName, projectPath); - ext.dataMapPanelManagers[dataMapName].panel.iconPath = { + ext.dataMapPanelManagers[panelKey] = new DataMapperPanel(panel, dataMapName, panelKey, projectPath); + ext.dataMapPanelManagers[panelKey].panel.iconPath = { light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'wand.png')), dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'wand.png')), }; - ext.dataMapPanelManagers[dataMapName].updateWebviewPanelTitle(); - ext.dataMapPanelManagers[dataMapName].mapDefinitionData = mapDefinitionData; + ext.dataMapPanelManagers[panelKey].updateWebviewPanelTitle(); + ext.dataMapPanelManagers[panelKey].mapDefinitionData = mapDefinitionData; // From here, VSIX will handle any other initial-load-time events once receive webviewLoaded msg } diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts index d0d76da6843..ce0e0dd87f9 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts @@ -45,16 +45,18 @@ export default class DataMapperPanel { public panel: WebviewPanel; public dataMapVersion: number; public dataMapName: string; + public panelKey: string; public projectPath: string; public dataMapStateIsDirty: boolean; public mapDefinitionData: MapDefinitionData | undefined; private telemetryPrefix = 'data-mapper-vscode-extension'; - constructor(panel: WebviewPanel, dataMapName: string, projectPath: string) { + constructor(panel: WebviewPanel, dataMapName: string, panelKey: string, projectPath: string) { this.panel = panel; this.dataMapVersion = this.getDataMapperVersion(); this.dataMapName = dataMapName; + this.panelKey = panelKey; this.projectPath = projectPath; this.dataMapStateIsDirty = false; this.handleReadSchemaFileOptions = this.handleReadSchemaFileOptions.bind(this); // Bind these as they're used as callbacks @@ -82,7 +84,7 @@ export default class DataMapperPanel { this.panel.onDidDispose( () => { - delete ext.dataMapPanelManagers[this.dataMapName]; + delete ext.dataMapPanelManagers[this.panelKey]; if (schemaFolderWatcher) { schemaFolderWatcher.dispose(); } 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 e45e6c43f6c..c856c2ab282 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { extensionCommand } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import DataMapperExt from './DataMapperExt'; diff --git a/apps/vs-code-designer/src/extensionVariables.ts b/apps/vs-code-designer/src/extensionVariables.ts index beaece4acaf..5bf16afeab3 100644 --- a/apps/vs-code-designer/src/extensionVariables.ts +++ b/apps/vs-code-designer/src/extensionVariables.ts @@ -19,7 +19,7 @@ import type { LanguageClient } from 'vscode-languageclient/node'; * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts */ -type DataMapperPanelDictionary = { [key: string]: DataMapperPanel }; // key == dataMapName +type DataMapperPanelDictionary = { [key: string]: DataMapperPanel }; // key == projectPath::dataMapName type LogicAppMap = Map; type SubscriptionMap = Map; export type FuncInstance = { From c22477b8d48c150941329043af32d44167d0d153 Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 1 Sep 2026 14:55:22 -0400 Subject: [PATCH 3/3] add tests --- .../dataMapper/__test__/DataMapperExt.test.ts | 134 +++++++ .../__test__/DataMapperPanel.test.ts | 338 ++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperExt.test.ts create mode 100644 apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperPanel.test.ts diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperExt.test.ts b/apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperExt.test.ts new file mode 100644 index 00000000000..8444c6806e7 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperExt.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import { ViewColumn, window } from 'vscode'; +import { ext } from '../../../../extensionVariables'; +import DataMapperExt, { getDataMapPanelKey } from '../DataMapperExt'; + +const mocks = vi.hoisted(() => { + const dataMapperPanelConstructor = vi.fn(function (panel: any, dataMapName: string, panelKey: string, projectPath: string) { + return { + panel, + dataMapName, + panelKey, + projectPath, + updateWebviewPanelTitle: vi.fn(), + mapDefinitionData: undefined, + }; + }); + + return { + dataMapperPanelConstructor, + startBackendRuntime: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../DataMapperPanel', () => ({ + default: mocks.dataMapperPanelConstructor, +})); + +vi.mock('../FxWorkflowRuntime', () => ({ + startBackendRuntime: mocks.startBackendRuntime, +})); + +vi.mock('../../../../localize', () => ({ + localize: (_key: string, defaultMessage: string) => defaultMessage, +})); + +interface MockWebviewPanel { + iconPath?: unknown; + reveal: ReturnType; + webview: { + html: string; + onDidReceiveMessage: ReturnType; + postMessage: ReturnType; + }; +} + +describe('DataMapperExt panel identity', () => { + const context = { + ui: { + showInputBox: vi.fn(), + }, + } as unknown as IActionContext; + + beforeEach(() => { + vi.clearAllMocks(); + (ext as any).dataMapPanelManagers ??= {}; + for (const panelKey of Object.keys(ext.dataMapPanelManagers)) { + delete ext.dataMapPanelManagers[panelKey]; + } + + (ext as any).context = { + extensionPath: '/extension', + subscriptions: [], + }; + + vi.mocked(window.createWebviewPanel).mockImplementation( + () => + ({ + iconPath: undefined, + reveal: vi.fn(), + webview: { + html: '', + onDidReceiveMessage: vi.fn(), + postMessage: vi.fn(), + }, + }) as any + ); + }); + + it('reveals the existing panel for the same project path and map name', async () => { + const projectPath = '/projects/alpha'; + const dataMapName = 'orders'; + + await DataMapperExt.openDataMapperPanel(context, projectPath, dataMapName); + const existingManager = ext.dataMapPanelManagers[getDataMapPanelKey(projectPath, dataMapName)]; + const existingPanel = existingManager.panel as unknown as MockWebviewPanel; + + await DataMapperExt.openDataMapperPanel(context, projectPath, dataMapName); + + expect(window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mocks.dataMapperPanelConstructor).toHaveBeenCalledTimes(1); + expect(existingPanel.reveal).toHaveBeenCalledWith(ViewColumn.Active); + expect(ext.dataMapPanelManagers[getDataMapPanelKey(projectPath, dataMapName)]).toBe(existingManager); + }); + + it('registers identically named maps from different projects under separate composite keys', async () => { + const dataMapName = 'orders'; + const firstProjectPath = '/projects/alpha'; + const secondProjectPath = '/projects/beta'; + + await DataMapperExt.openDataMapperPanel(context, firstProjectPath, dataMapName); + await DataMapperExt.openDataMapperPanel(context, secondProjectPath, dataMapName); + + const firstKey = getDataMapPanelKey(firstProjectPath, dataMapName); + const secondKey = getDataMapPanelKey(secondProjectPath, dataMapName); + const firstManager = ext.dataMapPanelManagers[firstKey]; + const secondManager = ext.dataMapPanelManagers[secondKey]; + + expect(firstKey).not.toBe(secondKey); + expect(firstManager).toBeDefined(); + expect(secondManager).toBeDefined(); + expect(firstManager).not.toBe(secondManager); + expect(mocks.dataMapperPanelConstructor).toHaveBeenNthCalledWith(1, expect.anything(), dataMapName, firstKey, firstProjectPath); + expect(mocks.dataMapperPanelConstructor).toHaveBeenNthCalledWith(2, expect.anything(), dataMapName, secondKey, secondProjectPath); + }); + + it('attaches map definition data only to the manager for its project', async () => { + const dataMapName = 'orders'; + const firstProjectPath = '/projects/alpha'; + const secondProjectPath = '/projects/beta'; + const firstMapDefinitionData = { mapDefinition: { source: 'alpha' } } as any; + const secondMapDefinitionData = { mapDefinition: { source: 'beta' } } as any; + + await DataMapperExt.openDataMapperPanel(context, firstProjectPath, dataMapName, firstMapDefinitionData); + await DataMapperExt.openDataMapperPanel(context, secondProjectPath, dataMapName, secondMapDefinitionData); + + expect(ext.dataMapPanelManagers[getDataMapPanelKey(firstProjectPath, dataMapName)].mapDefinitionData).toBe(firstMapDefinitionData); + expect(ext.dataMapPanelManagers[getDataMapPanelKey(secondProjectPath, dataMapName)].mapDefinitionData).toBe(secondMapDefinitionData); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperPanel.test.ts b/apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperPanel.test.ts new file mode 100644 index 00000000000..54f5892763b --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/dataMapper/__test__/DataMapperPanel.test.ts @@ -0,0 +1,338 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; +import * as path from 'path'; +import { mkdirSync, promises as fs, readFileSync, unlinkSync } from 'fs'; +import { RelativePattern, workspace } from 'vscode'; +import { ext } from '../../../../extensionVariables'; +import { + customFunctionsPath, + customXsltPath, + dataMapDefinitionsPath, + dataMapsPath, + draftMapDefinitionSuffix, + mapDefinitionExtension, + mapXsltExtension, + schemasPath, +} from '../extensionConfig'; +import DataMapperPanel from '../DataMapperPanel'; + +const fsMocks = vi.hoisted(() => ({ + copyFileSync: vi.fn(), + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ isDirectory: () => false })), + unlinkSync: vi.fn(), + promises: { + mkdir: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue('generated xslt'), + readdir: vi.fn().mockResolvedValue([]), + writeFile: vi.fn().mockResolvedValue(undefined), + }, +})); + +const vscodeMocks = vi.hoisted(() => { + class MockRelativePattern { + constructor( + public base: string, + public pattern: string + ) {} + } + + return { + RelativePattern: MockRelativePattern, + commands: { + executeCommand: vi.fn(), + }, + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn().mockResolvedValue(undefined), + showOpenDialog: vi.fn().mockResolvedValue([]), + showTextDocument: vi.fn(), + showWarningMessage: vi.fn(), + }, + workspace: { + createFileSystemWatcher: vi.fn(), + fs: { + readFile: vi.fn(), + }, + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => undefined), + })), + openTextDocument: vi.fn(), + }, + }; +}); + +const extensionState = vi.hoisted(() => ({ + context: { + extensionPath: '/extension', + subscriptions: [] as any[], + }, + dataMapPanelManagers: {} as Record, + designTimeInstances: new Map(), + outputChannel: { + appendLine: vi.fn(), + }, + prefix: 'azureLogicAppsStandard', + showError: vi.fn(), + telemetryReporter: { + sendTelemetryEvent: vi.fn(), + }, +})); + +vi.mock('fs', () => ({ + ...fsMocks, + promises: fsMocks.promises, +})); + +vi.mock('vscode', () => ({ + RelativePattern: vscodeMocks.RelativePattern, + commands: vscodeMocks.commands, + window: vscodeMocks.window, + workspace: vscodeMocks.workspace, +})); + +vi.mock('../../../../extensionVariables', () => ({ + ext: extensionState, +})); + +vi.mock('../../../../localize', () => ({ + localize: (_key: string, defaultMessage: string, ...values: string[]) => + values.reduce((message, value, index) => message.replace(`{${index}}`, value), defaultMessage), +})); + +vi.mock('../../../utils/codeless/getWebViewHTML', () => ({ + getWebViewHTML: vi.fn().mockResolvedValue(''), +})); + +vi.mock('../DataMapperPanelUtils', () => ({ + copyOverImportedSchemas: vi.fn(), +})); + +vi.mock('../../setDataMapperVersion', () => ({ + switchToDataMapperV2: vi.fn(), +})); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + callWithTelemetryAndErrorHandlingSync: (_command: string, callback: (context: any) => unknown) => + callback({ + telemetry: { + properties: {}, + }, + }), +})); + +interface MockWatcher { + dispose: ReturnType; + onDidCreate: ReturnType; + onDidDelete: ReturnType; +} + +interface MockPanel { + title: string; + webview: { + html: string; + onDidReceiveMessage: ReturnType; + postMessage: ReturnType; + }; + onDidDispose: ReturnType; +} + +function createPanel(): MockPanel { + return { + title: '', + webview: { + html: '', + onDidReceiveMessage: vi.fn(), + postMessage: vi.fn(), + }, + onDidDispose: vi.fn(), + }; +} + +function createWatcher(): MockWatcher { + return { + dispose: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + }; +} + +function createManager(projectPath = '/projects/alpha', dataMapName = 'orders') { + const panel = createPanel(); + const panelKey = `${projectPath}::${dataMapName}`; + const manager = new DataMapperPanel(panel as any, dataMapName, panelKey, projectPath); + return { manager, panel, panelKey }; +} + +describe('DataMapperPanel project binding', () => { + beforeEach(() => { + vi.clearAllMocks(); + extensionState.context.subscriptions.length = 0; + extensionState.designTimeInstances.clear(); + for (const panelKey of Object.keys(extensionState.dataMapPanelManagers)) { + delete extensionState.dataMapPanelManagers[panelKey]; + } + + fsMocks.existsSync.mockReturnValue(false); + fsMocks.promises.mkdir.mockResolvedValue(undefined); + fsMocks.promises.readFile.mockResolvedValue('generated xslt'); + fsMocks.promises.readdir.mockResolvedValue([]); + fsMocks.promises.writeFile.mockResolvedValue(undefined); + vscodeMocks.window.showInformationMessage.mockResolvedValue(undefined); + vscodeMocks.workspace.createFileSystemWatcher.mockImplementation(() => createWatcher()); + }); + + it('posts the runtime port belonging to the panel project when the webview loads', () => { + const projectPath = '/projects/alpha'; + const otherProjectPath = '/projects/beta'; + extensionState.designTimeInstances.set(projectPath, { port: 4101 }); + extensionState.designTimeInstances.set(otherProjectPath, { port: 5299 }); + const { panel } = createManager(projectPath); + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + panel.webview.postMessage.mockClear(); + + messageHandler({ command: ExtensionCommand.webviewLoaded }); + + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + command: ExtensionCommand.setRuntimePort, + data: '4101', + }); + expect(panel.webview.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + command: ExtensionCommand.setRuntimePort, + data: '5299', + }) + ); + }); + + it('roots constructor-created folders and file watchers under the panel project', () => { + const projectPath = '/projects/alpha'; + const schemaPath = path.join(projectPath, schemasPath); + const xsltExtensionsPath = path.join(projectPath, customXsltPath); + fsMocks.existsSync.mockImplementation((candidate) => candidate === schemaPath || candidate === xsltExtensionsPath); + + createManager(projectPath); + + expect(mkdirSync).toHaveBeenCalledWith(path.join(projectPath, customXsltPath), { recursive: true }); + expect(mkdirSync).toHaveBeenCalledWith(path.join(projectPath, customFunctionsPath), { recursive: true }); + expect(workspace.createFileSystemWatcher).toHaveBeenCalledTimes(2); + expect(workspace.createFileSystemWatcher).toHaveBeenCalledWith( + expect.objectContaining({ + base: schemaPath, + }) + ); + expect(workspace.createFileSystemWatcher).toHaveBeenCalledWith( + expect.objectContaining({ + base: xsltExtensionsPath, + }) + ); + }); + + it('removes only its composite-key entry and disposes only its own watchers', () => { + fsMocks.existsSync.mockReturnValue(true); + const firstProjectPath = '/projects/alpha'; + const secondProjectPath = '/projects/beta'; + const firstWatchers = [createWatcher(), createWatcher()]; + const secondWatchers = [createWatcher(), createWatcher()]; + vscodeMocks.workspace.createFileSystemWatcher + .mockReturnValueOnce(firstWatchers[0]) + .mockReturnValueOnce(firstWatchers[1]) + .mockReturnValueOnce(secondWatchers[0]) + .mockReturnValueOnce(secondWatchers[1]); + + const first = createManager(firstProjectPath); + const second = createManager(secondProjectPath); + extensionState.dataMapPanelManagers[first.panelKey] = first.manager; + extensionState.dataMapPanelManagers[second.panelKey] = second.manager; + + const firstDisposeHandler = first.panel.onDidDispose.mock.calls[0][0]; + firstDisposeHandler(); + + expect(extensionState.dataMapPanelManagers[first.panelKey]).toBeUndefined(); + expect(extensionState.dataMapPanelManagers[second.panelKey]).toBe(second.manager); + for (const watcher of firstWatchers) { + expect(watcher.dispose).toHaveBeenCalledTimes(1); + } + for (const watcher of secondWatchers) { + expect(watcher.dispose).not.toHaveBeenCalled(); + } + }); + + it.each([ + { + category: 'map definition', + expectedRelativePath: path.join(dataMapDefinitionsPath, `orders${mapDefinitionExtension}`), + run: (manager: DataMapperPanel, contents: string) => manager.saveMapDefinition(contents), + }, + { + category: 'draft map definition', + expectedRelativePath: path.join(dataMapDefinitionsPath, `orders${draftMapDefinitionSuffix}${mapDefinitionExtension}`), + run: (manager: DataMapperPanel, contents: string) => manager.saveDraftDataMapDefinition(contents), + }, + { + category: 'generated XSLT', + expectedRelativePath: path.join(dataMapsPath, `orders${mapXsltExtension}`), + run: (manager: DataMapperPanel, contents: string) => manager.saveMapXslt(contents), + }, + { + category: 'map metadata', + expectedRelativePath: path.join('.vscode', 'ordersDataMapMetadata-v2.json'), + run: (manager: DataMapperPanel, contents: string) => manager.saveMapMetadata(contents), + }, + ])('writes $category under the panel project', async ({ expectedRelativePath, run }) => { + const projectPath = '/projects/alpha'; + const contents = 'project-specific contents'; + const { manager } = createManager(projectPath); + + run(manager, contents); + + await vi.waitFor(() => { + expect(fs.writeFile).toHaveBeenCalledWith(path.join(projectPath, expectedRelativePath), contents, 'utf8'); + }); + }); + + it('deletes the draft map definition only from the panel project', () => { + const projectPath = '/projects/alpha'; + const expectedDraftPath = path.join(projectPath, dataMapDefinitionsPath, `orders${draftMapDefinitionSuffix}${mapDefinitionExtension}`); + fsMocks.existsSync.mockImplementation((candidate) => candidate === expectedDraftPath); + const { manager } = createManager(projectPath); + + manager.deleteDraftDataMapDefinition(); + + expect(unlinkSync).toHaveBeenCalledWith(expectedDraftPath); + }); + + it('reads XSLT and metadata from the panel project', async () => { + const projectPath = '/projects/alpha'; + const expectedXsltPath = path.join(projectPath, dataMapsPath, `orders${mapXsltExtension}`); + const expectedMetadataPath = path.join(projectPath, '.vscode', 'ordersDataMapMetadata-v2.json'); + fsMocks.existsSync.mockImplementation((candidate) => candidate === expectedXsltPath || candidate === expectedMetadataPath); + fsMocks.readFileSync.mockReturnValue(Buffer.from('{"position":{"x":1}}')); + fsMocks.promises.readFile.mockResolvedValue(''); + const { manager, panel } = createManager(projectPath); + manager.mapDefinitionData = { mapDefinition: { source: 'alpha' } } as any; + panel.webview.postMessage.mockClear(); + + manager.handleLoadMapDefinitionIfAny(); + + expect(readFileSync).toHaveBeenCalledWith(expectedMetadataPath); + await vi.waitFor(() => { + expect(fs.readFile).toHaveBeenCalledWith(expectedXsltPath, 'utf-8'); + }); + expect(panel.webview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + command: ExtensionCommand.loadDataMap, + data: expect.objectContaining({ + metadata: { position: { x: 1 } }, + }), + }) + ); + }); +});