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 2835d8d8f8c..5eb4462e4c1 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts @@ -119,14 +119,10 @@ export default class DataMapperPanel { this.panel.webview.html = await getWebViewHTML('vs-code-react', this.panel); } - public sendMsgToWebview(msg: MessageToWebview) { - this.panel.webview.postMessage(msg); - } - private _handleWebviewMsg(msg: MessageToVsix) { switch (msg.command) { case ExtensionCommand.initialize: { - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.initialize_frame, data: { project: ProjectName.dataMapper, @@ -136,7 +132,7 @@ export default class DataMapperPanel { } case ExtensionCommand.webviewLoaded: { // Send runtime port to webview - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.setRuntimePort, data: `${ext.designTimeInstances.get(ext.defaultLogicAppPath)?.port}`, }); @@ -214,7 +210,7 @@ export default class DataMapperPanel { } public isTestDisabledForOS() { - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.isTestDisabledForOS, data: process.platform === Platform.mac, }); @@ -237,7 +233,7 @@ export default class DataMapperPanel { public handleLoadMapDefinitionIfAny() { if (this.mapDefinitionData) { const mapMetadata = this.readMapMetadataFile(); - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.loadDataMap, data: { ...this.mapDefinitionData, @@ -353,7 +349,7 @@ export default class DataMapperPanel { result.forEach((file) => { this.getNestedFilePaths(file, '', folderPath, filesToDisplay, fileTypes); }); - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command, data: filesToDisplay, }); @@ -370,7 +366,7 @@ export default class DataMapperPanel { result.forEach((file) => { this.getNestedFileTreePaths(file, '', folderPath, filesToDisplay, fileTypes); }); - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: command, data: filesToDisplay, }); @@ -418,7 +414,7 @@ export default class DataMapperPanel { copyFileSync(primarySchemaFullPath, newPath); } - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.fetchSchema, data: { fileName: primarySchemaFileName, @@ -560,7 +556,7 @@ export default class DataMapperPanel { if (fileExistsSync(expectedXsltPath)) { fs.readFile(expectedXsltPath, 'utf-8').then((fileContents) => { - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.setXsltData, data: { filename: this.dataMapName, @@ -576,14 +572,14 @@ export default class DataMapperPanel { public getConfigurationSetting(configSetting: string) { const azureDataMapperConfig = workspace.getConfiguration(ext.prefix); const configValue = azureDataMapperConfig.get(configSetting) ?? true; - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.getConfigurationSetting, data: configValue, }); } public handleGetDataMapperVersion() { - this.sendMsgToWebview({ + this.panel.webview.postMessage({ command: ExtensionCommand.getDataMapperVersion, data: this.dataMapVersion, }); diff --git a/apps/vs-code-designer/src/app/commands/registerCommands.ts b/apps/vs-code-designer/src/app/commands/registerCommands.ts index f98a68251c1..775540c3c22 100644 --- a/apps/vs-code-designer/src/app/commands/registerCommands.ts +++ b/apps/vs-code-designer/src/app/commands/registerCommands.ts @@ -50,8 +50,8 @@ import { viewProperties } from './viewProperties'; import { configureWebhookRedirectEndpoint } from './workflows/configureWebhookRedirectEndpoint/configureWebhookRedirectEndpoint'; import { enableAzureConnectors } from './workflows/enableAzureConnectors'; import { exportLogicApp } from './workflows/exportLogicApp'; -import { openDesigner } from './workflows/openDesigner/openDesigner'; -import { openOverview } from './workflows/openOverview'; +import { openDesigner } from './workflows/designer/openDesigner'; +import { openOverview } from './workflows/overview/openOverview'; import { reviewValidation } from './workflows/reviewValidation'; import { switchDebugMode } from './workflows/switchDebugMode/switchDebugMode'; import { switchToDotnetProjectCommand } from './workflows/switchToDotnetProject'; @@ -78,7 +78,7 @@ import { switchToDataMapperV2 } from './setDataMapperVersion'; import { reportAnIssue } from '../utils/reportAnIssue'; import { localize } from '../../localize'; import { guid } from '@microsoft/logic-apps-shared'; -import { openLanguageServerConnectionView } from './workflows/languageServer/connectionView'; +import { openConnectionView } from './workflows/connectionView/openConnectionView'; import { enableDevContainer } from './enableDevContainer/enableDevContainer'; export function registerCommands(): void { @@ -160,18 +160,20 @@ export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping(extensionCommand.validateAndInstallBinaries, validateAndInstallBinaries); registerCommandWithTreeNodeUnwrapping(extensionCommand.resetValidateAndInstallBinaries, resetValidateAndInstallBinaries); registerCommandWithTreeNodeUnwrapping(extensionCommand.disableValidateAndInstallBinaries, disableValidateAndInstallBinaries); - // Data Mapper Commands + + // Data Mapper registerCommand(extensionCommand.createNewDataMap, (context: IActionContext) => createNewDataMapCmd(context)); registerCommand(extensionCommand.loadDataMapFile, (context: IActionContext, uri: vscode.Uri) => loadDataMapFileCmd(context, uri)); - // Custom code commands + + // Custom code registerCommandWithTreeNodeUnwrapping(extensionCommand.buildCustomCodeFunctionsProject, tryBuildCustomCodeFunctionsProject); registerCommand(extensionCommand.createCustomCodeFunction, createCustomCodeFunction); registerCommand(extensionCommand.debugLogicApp, debugLogicApp); registerCommand(extensionCommand.switchToDataMapperV2, switchToDataMapperV2); registerCommand(extensionCommand.enableDevContainer, enableDevContainer); - // Language server protocol - registerCommand(extensionCommand.openLanguageServerConnectionView, openLanguageServerConnectionView); + // Connections + registerCommand(extensionCommand.openLanguageServerConnectionView, openConnectionView); // Error handler registerErrorHandler((errorContext: IErrorHandlerContext): void => { diff --git a/apps/vs-code-designer/src/app/commands/workflows/connectionView/openConnectionView.ts b/apps/vs-code-designer/src/app/commands/workflows/connectionView/openConnectionView.ts new file mode 100644 index 00000000000..6c563ab853e --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/connectionView/openConnectionView.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import type { CodeSelection } from '@microsoft/vscode-extension-logic-apps'; +import ConnectionPanel from './panels/connectionPanel'; + +export async function openConnectionView( + context: IActionContext, + filePath: string, + methodName: string, + connectorName: string, + connectorType: string, + range: CodeSelection, + currentConnectionId: string +): Promise { + const connectionPanel = new ConnectionPanel( + context, + filePath, + methodName, + connectorName, + connectorType, + range, + currentConnectionId + ); + await connectionPanel.create(); +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionView.test.ts b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/__test__/connectionPanel.test.ts similarity index 99% rename from apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionView.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/__test__/connectionPanel.test.ts index b859998e79d..a83cca7b94e 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionView.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/__test__/connectionPanel.test.ts @@ -9,7 +9,7 @@ vi.mock('../../../../utils/codeless/connection', () => ({ })); // Import the module after mocks are set up -const mockModule = await import('../connectionView'); +const mockModule = await import('../connectionPanel'); describe('ConnectionView - getConnectionKeyFromConnectionsJson', () => { const testProjectPath = '/test/project'; diff --git a/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionViewRace.test.ts b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/__test__/connectionPanelRace.test.ts similarity index 74% rename from apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionViewRace.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/__test__/connectionPanelRace.test.ts index a53a645ebf3..bd5728221f8 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/languageServer/__test__/connectionViewRace.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/__test__/connectionPanelRace.test.ts @@ -9,7 +9,7 @@ const mockGetLogicAppProjectRoot = vi.fn(); const mockGetParametersFromFile = vi.fn(); const mockSaveWorkflowParameter = vi.fn(); -vi.mock('../../../../utils/codeless/connection', () => ({ +vi.mock('../../../../../utils/codeless/connection', () => ({ addConnectionData: mockAddConnectionData, getConnectionsAndSettingsToUpdate: mockGetConnectionsAndSettingsToUpdate, saveConnectionReferences: mockSaveConnectionReferences, @@ -18,46 +18,45 @@ vi.mock('../../../../utils/codeless/connection', () => ({ getParametersFromFile: mockGetParametersFromFile, })); -vi.mock('../../../../utils/codeless/parameter', () => ({ +vi.mock('../../../../../utils/codeless/parameter', () => ({ saveWorkflowParameter: mockSaveWorkflowParameter, })); -vi.mock('../../../../utils/codeless/common', () => ({ +vi.mock('../../../../../utils/codeless/common', () => ({ cacheWebviewPanel: vi.fn(), getAzureConnectorDetailsForLocalProject: vi.fn(), removeWebviewPanelFromCache: vi.fn(), })); -vi.mock('../../../../utils/codeless/startDesignTimeApi', () => ({ +vi.mock('../../../../../utils/codeless/startDesignTimeApi', () => ({ startDesignTimeApi: vi.fn(), })); -vi.mock('../../../../utils/codeless/artifacts', () => ({ +vi.mock('../../../../../utils/codeless/artifacts', () => ({ getArtifactsInLocalProject: vi.fn(), })); -vi.mock('../../../../utils/appSettings/localSettings', () => ({ +vi.mock('../../../../../utils/appSettings/localSettings', () => ({ getLocalSettingsJson: vi.fn().mockResolvedValue({ Values: {} }), })); -vi.mock('../../../../utils/bundleFeed', () => ({ +vi.mock('../../../../../utils/bundleFeed', () => ({ getBundleVersionNumber: vi.fn().mockResolvedValue('1.0.0'), })); -vi.mock('../../openDesigner/openDesignerBase', () => { +vi.mock('../../../designer/panels/designerPanel', () => { return { - OpenDesignerBase: class { + DesignerPanel: class { panelGroupKey = 'ls'; panelName = 'test'; context = { telemetry: { properties: {} } }; - panel = { dispose: vi.fn() }; - sendMsgToWebview = vi.fn(); + panel = { dispose: vi.fn(), webview: { postMessage: vi.fn() } }; }, }; }); // ── Import the module under test (after all mocks are configured) ────────── -const mod = await import('../connectionView'); +const mod = await import('../connectionPanel'); const OpenConnectionView = mod.default; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -79,16 +78,16 @@ function createInstance(): InstanceType { instance.connectorType = 'builtin'; instance.currentConnectionId = ''; instance.context = { telemetry: { properties: {} } }; - instance.panel = { dispose: vi.fn() }; + instance.panel = { dispose: vi.fn(), webview: { postMessage: vi.fn() } }; instance.panelMetadata = { azureDetails: {} }; // Override the private saveConnection so tests focus on handler orchestration instance.saveConnection = mockSaveConnection; return instance; } -/** Helper to invoke the private _handleWebviewMsg via the prototype */ +/** Helper to invoke the private handleWebviewMsg via the prototype */ function handleMsg(instance: any, message: any): Promise { - return (OpenConnectionView.prototype as any)._handleWebviewMsg.call(instance, message); + return (OpenConnectionView.prototype as any).handleWebviewMsg.call(instance, message); } // ── Tests ────────────────────────────────────────────────────────────────── @@ -209,3 +208,44 @@ describe('ConnectionView – insert_connection handles local and managed connect }); }); }); + +describe('ConnectionPanel – getConnectionPanelMetadata reads localSettings after azureConnectorDetails', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetLogicAppProjectRoot.mockResolvedValue('/test/project'); + mockGetConnectionsFromFile.mockResolvedValue('{}'); + mockGetParametersFromFile.mockResolvedValue({}); + }); + + it('reads localSettings after getAzureConnectorDetailsForLocalProject to avoid stale data from wizard writes', async () => { + const { getAzureConnectorDetailsForLocalProject } = await import('../../../../../utils/codeless/common'); + const { getLocalSettingsJson } = await import('../../../../../utils/appSettings/localSettings'); + + let azureDetailsResolved = false; + + vi.mocked(getAzureConnectorDetailsForLocalProject).mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + azureDetailsResolved = true; + resolve({ accessToken: 'token', enabled: true, subscriptionId: 'sub-1', tenantId: 'tenant-1' } as any); + }, 50); + }); + }); + vi.mocked(getLocalSettingsJson).mockImplementation(async () => { + expect(azureDetailsResolved).toBe(true); + return { Values: { WORKFLOWS_TENANT_ID: 'tenant-1', WORKFLOWS_SUBSCRIPTION_ID: 'sub-1' } } as any; + }); + + const instance = Object.create(OpenConnectionView.prototype) as any; + instance.workflowFilePath = '/test/workflow.cs'; + instance.panelName = 'test-panel'; + instance.workflowName = ''; + instance.context = { telemetry: { properties: {} } }; + instance.schemaArtifacts = []; + instance.mapArtifacts = {}; + + await instance.getConnectionPanelMetadata(); + + expect(getLocalSettingsJson).toHaveBeenCalled(); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/languageServer/connectionView.ts b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/connectionPanel.ts similarity index 87% rename from apps/vs-code-designer/src/app/commands/workflows/languageServer/connectionView.ts rename to apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/connectionPanel.ts index c41fd68728e..0000a8fe463 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/languageServer/connectionView.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/connectionPanel.ts @@ -2,14 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ext } from '../../../../extensionVariables'; -import { localize } from '../../../../localize'; -import { cacheWebviewPanel, getAzureConnectorDetailsForLocalProject, removeWebviewPanelFromCache } from '../../../utils/codeless/common'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; +import { cacheWebviewPanel, getAzureConnectorDetailsForLocalProject, removeWebviewPanelFromCache } from '../../../../utils/codeless/common'; import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IDesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; +import type { CodeSelection, ConnectionPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName, RouteName } from '@microsoft/vscode-extension-logic-apps'; -import { OpenDesignerBase } from '../openDesigner/openDesignerBase'; -import { startDesignTimeApi } from '../../../utils/codeless/startDesignTimeApi'; +import { DesignerPanel } from '../../designer/panels/designerPanel'; +import { startDesignTimeApi } from '../../../../utils/codeless/startDesignTimeApi'; import { addConnectionData, getConnectionsAndSettingsToUpdate, @@ -17,37 +17,25 @@ import { getLogicAppProjectRoot, getParametersFromFile, saveConnectionReferences, -} from '../../../utils/codeless/connection'; -import { getAuthorizationToken } from '../../../utils/codeless/getAuthorizationToken'; +} from '../../../../utils/codeless/connection'; +import { getAuthorizationToken } from '../../../../utils/codeless/getAuthorizationToken'; import path from 'path'; -import { localSettingsFileName, managementApiPrefix, workflowAppApiVersion } from '../../../../constants'; +import { localSettingsFileName, managementApiPrefix, workflowAppApiVersion } from '../../../../../constants'; import type { WebviewPanel } from 'vscode'; import { env, Uri, ViewColumn, window } from 'vscode'; -import { getLocalSettingsJson } from '../../../utils/appSettings/localSettings'; -import { getArtifactsInLocalProject } from '../../../utils/codeless/artifacts'; +import { getLocalSettingsJson } from '../../../../utils/appSettings/localSettings'; +import { getArtifactsInLocalProject } from '../../../../utils/codeless/artifacts'; import * as vscode from 'vscode'; import type { Connection } from '@microsoft/logic-apps-shared'; -import { getBundleVersionNumber } from '../../../utils/bundleFeed'; -import { saveWorkflowParameter } from '../../../utils/codeless/parameter'; - -type Range = { - Start: { - Line: number; - Character: number; - }; - End: { - Line: number; - Character: number; - }; -}; +import { getBundleVersionNumber } from '../../../../utils/bundleFeed'; +import { saveWorkflowParameter } from '../../../../utils/codeless/parameter'; -export default class OpenConnectionView extends OpenDesignerBase { +export default class ConnectionPanel extends DesignerPanel { private readonly workflowFilePath: string; private projectPath: string | undefined; - private panelMetadata: IDesignerPanelMetadata; - private readonly methodName: string; + private panelMetadata?: ConnectionPanelMetadata; private readonly connectorName: string; - private readonly range: Range; + private readonly range: CodeSelection; private readonly connectorType: string; private readonly currentConnectionId: string; @@ -57,21 +45,20 @@ export default class OpenConnectionView extends OpenDesignerBase { methodName: string, connectorName: string, connectorType: string, - range: Range, + range: CodeSelection, currentConnectionId: string ) { const panelName: string = `Connection view - ${connectorName} - ${methodName}`; const panelGroupKey = ext.webViewKey.languageServer; super(context, '', panelName, workflowAppApiVersion, panelGroupKey, false, true, false, ''); this.workflowFilePath = filePath; - this.methodName = methodName; this.range = range; this.connectorName = connectorName; this.connectorType = connectorType; this.currentConnectionId = currentConnectionId; } - public async createPanel(): Promise { + public async create(): Promise { const existingPanel: WebviewPanel | undefined = this.getExistingPanel(); if (existingPanel) { @@ -105,12 +92,13 @@ export default class OpenConnectionView extends OpenDesignerBase { this.panel.webview.html = this.getLoadingHtml(); // Start design time API and load metadata in parallel - const [_, panelMetadata] = await Promise.all([startDesignTimeApi(this.projectPath), this._getDesignerPanelMetadata()]); + const [_, panelMetadata] = await Promise.all([startDesignTimeApi(this.projectPath), this.getConnectionPanelMetadata()]); if (!ext.designTimeInstances.has(this.projectPath)) { throw new Error(localize('designTimeNotRunning', `Design time is not running for project ${this.projectPath}.`)); } - const designTimePort = ext.designTimeInstances.get(this.projectPath).port; + + const designTimePort = ext.designTimeInstances.get(this.projectPath)?.port; if (!designTimePort) { throw new Error(localize('designTimePortNotFound', 'Design time port not found.')); } @@ -139,7 +127,7 @@ export default class OpenConnectionView extends OpenDesignerBase { // The React app sends "initialize" immediately on boot — if the handler // isn't registered yet, the message is silently dropped and the UI stays // stuck on "Loading connection data..." forever. - this.panel.webview.onDidReceiveMessage(async (message) => await this._handleWebviewMsg(message), ext.context.subscriptions); + this.panel.webview.onDidReceiveMessage(async (message) => await this.handleWebviewMsg(message), ext.context.subscriptions); this.panel.onDidDispose( () => { @@ -204,12 +192,12 @@ export default class OpenConnectionView extends OpenDesignerBase { `; } - private async _handleWebviewMsg(message: any) { + private async handleWebviewMsg(message: any) { switch (message.command) { case ExtensionCommand.initialize: { const resolvedCurrentConnectionId = resolveCurrentConnectionId(this.panelMetadata?.connectionsData, this.currentConnectionId); - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.initialize_frame, data: { project: ProjectName.languageServer, @@ -232,7 +220,7 @@ export default class OpenConnectionView extends OpenDesignerBase { break; } case ExtensionCommand.close_panel: { - this.panel.dispose(); + this.panel?.dispose(); break; } case ExtensionCommand.insert_connection: { @@ -254,7 +242,7 @@ export default class OpenConnectionView extends OpenDesignerBase { this.panelMetadata.azureDetails?.tenantId, this.panelMetadata.azureDetails?.workflowManagementBaseUrl ); - this.panel.dispose(); + this.panel?.dispose(); }); break; } @@ -274,7 +262,7 @@ export default class OpenConnectionView extends OpenDesignerBase { private async saveConnection( connection: Connection, - insertionContext: { documentUri: string; range: Range }, + insertionContext: { documentUri: string; range: CodeSelection }, connectionReferences: any, azureTenantId?: string, workflowBaseManagementUri?: string @@ -329,34 +317,30 @@ export default class OpenConnectionView extends OpenDesignerBase { }); } - private async _getDesignerPanelMetadata(): Promise { + private async getConnectionPanelMetadata(): Promise { const projectPath: string | undefined = await getLogicAppProjectRoot(this.context, this.workflowFilePath); if (!projectPath) { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } - // Critical data needed immediately for UI rendering - const criticalDataPromises = [ + const [ + connectionsData, + parametersData, + artifacts, + bundleVersionNumber, + azureDetails + ] = await Promise.all([ getConnectionsFromFile(this.context, this.workflowFilePath), getParametersFromFile(this.context, this.workflowFilePath), - getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName)).then((result) => result.Values), - ]; - - // Less critical data that can load slightly later - const deferredDataPromises = [ getArtifactsInLocalProject(projectPath), getBundleVersionNumber(), getAzureConnectorDetailsForLocalProject(this.context, projectPath), - ]; - - // Load critical data first - const [connectionsData, parametersData, localSettings] = await Promise.all(criticalDataPromises); + ]); - // Continue loading deferred data in background - const [artifacts, bundleVersionNumber, azureDetails] = await Promise.all(deferredDataPromises); + const localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values!; - const metadata = { + return { panelId: this.panelName, appSettingNames: Object.keys(localSettings), connectionsData, @@ -371,8 +355,6 @@ export default class OpenConnectionView extends OpenDesignerBase { extensionBundleVersion: bundleVersionNumber, workflowDetails: {}, }; - - return metadata; } /** @@ -413,9 +395,9 @@ export default class OpenConnectionView extends OpenDesignerBase { /** * Updates the connection ID in the source file at the specified range with the new connection ID. * @param {string} connectionId - The new connection ID to insert into the source file. - * @param {{ documentUri: string; range: Range }} insertionContext - The context containing the document URI and the range where the connection ID should be inserted. + * @param {{ documentUri: string; range: CodeSelection }} insertionContext - The context containing the document URI and the range where the connection ID should be inserted. */ -function updateConnectionIdInSource(connectionId: string, insertionContext: { documentUri: string; range: Range }) { +function updateConnectionIdInSource(connectionId: string, insertionContext: { documentUri: string; range: CodeSelection }) { const targetDocument = vscode.workspace.textDocuments.find((doc) => doc.uri.fsPath.toString() === insertionContext.documentUri); if (!targetDocument) { @@ -451,7 +433,7 @@ function updateConnectionIdInSource(connectionId: string, insertionContext: { do const performTextReplacement = ( editor: vscode.TextEditor, connectionId: string, - insertionContext: { documentUri: string; range: Range } + insertionContext: { documentUri: string; range: CodeSelection } ) => { if (!insertionContext.range) { vscode.window.showErrorMessage('No range provided for connection ID insertion'); @@ -616,24 +598,3 @@ export function resolveCurrentConnectionId(connectionsData: string | undefined, return currentConnectionId; } - -export async function openLanguageServerConnectionView( - context: IActionContext, - filePath: string, - methodName: string, - connectorName: string, - connectorType: string, - range: Range, - currentConnectionId: string -): Promise { - const connectionViewObj: OpenConnectionView = new OpenConnectionView( - context, - filePath, - methodName, - connectorName, - connectorType, - range, - currentConnectionId - ); - await connectionViewObj.createPanel(); -} diff --git a/apps/vs-code-designer/src/app/commands/workflows/designer/openDesigner.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/openDesigner.ts new file mode 100644 index 00000000000..c6517283104 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/openDesigner.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; +import { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; +import { getWorkflowNode } from '../../../utils/workspace'; +import { Uri } from 'vscode'; +import { tryBuildCustomCodeFunctionsProject } from '../../buildCustomCodeFunctionsProject'; +import { customCodeArtifactsExist } from '../../../utils/customCodeUtils'; +import type { DesignerPanel } from './panels/designerPanel'; +import { RemoteDesignerPanel } from './panels/remoteDesignerPanel'; +import LocalDesignerPanel from './panels/localDesignerPanel'; +import { ext } from '../../../../extensionVariables'; +import { localize } from '../../../../localize'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; + +export async function openDesigner(context: IActionContext, node: Uri | RemoteWorkflowTreeItem | undefined): Promise { + const workflowNode = getWorkflowNode(node); + if (!workflowNode) { + ext.outputChannel.appendLog(localize('workflowNodeNotFound', 'Failed to open designer. Unable to find the workflow node.')); + return; + } + + const designerPanel = await getDesignerPanel(context, workflowNode); + await designerPanel.create(); +} + +async function getDesignerPanel(context: IActionContext, workflowNode: Uri | RemoteWorkflowTreeItem): Promise { + if (workflowNode instanceof RemoteWorkflowTreeItem) { + return new RemoteDesignerPanel(context, workflowNode); + } + + const logicAppNode = Uri.file(path.join(workflowNode.fsPath, '../../')); + if (!(await customCodeArtifactsExist(logicAppNode.fsPath))) { + await tryBuildCustomCodeFunctionsProject(context, logicAppNode); + } + + return new LocalDesignerPanel(context, workflowNode); +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerBase.test.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/designerPanel.test.ts similarity index 95% rename from apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerBase.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/designerPanel.test.ts index 558f33828b0..3afbabadf67 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerBase.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/designerPanel.test.ts @@ -1,18 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as vscode from 'vscode'; -import { designerVersionSetting, defaultDesignerVersion, suppressDesignerVersionNotification } from '../../../../../constants'; -import { ext } from '../../../../../extensionVariables'; +import { designerVersionSetting, defaultDesignerVersion, suppressDesignerVersionNotification } from '../../../../../../constants'; +import { ext } from '../../../../../../extensionVariables'; // Mock dependencies before importing the class -vi.mock('../../../../../localize', () => ({ +vi.mock('../../../../../../localize', () => ({ localize: (_key: string, defaultMsg: string) => defaultMsg, })); -vi.mock('../../../../utils/codeless/common', () => ({ +vi.mock('../../../../../utils/codeless/common', () => ({ tryGetWebviewPanel: vi.fn(), })); -vi.mock('../../../../utils/codeless/getWebViewHTML', () => ({ +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ getWebViewHTML: vi.fn().mockResolvedValue(''), })); @@ -23,10 +23,10 @@ vi.mock('@microsoft/logic-apps-shared', () => ({ })); // Import the actual class after mocks -import { OpenDesignerBase } from '../openDesignerBase'; +import { DesignerPanel } from '../designerPanel'; // Concrete subclass to test the abstract class -class TestDesigner extends OpenDesignerBase { +class TestDesigner extends DesignerPanel { constructor(context?: any) { super( context ?? { telemetry: { properties: {}, measurements: {} } }, @@ -41,7 +41,7 @@ class TestDesigner extends OpenDesignerBase { ); } - async createPanel(): Promise {} + async create(): Promise {} // Expose protected methods for testing public testGetDesignerVersion() { diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerForLocalProject.test.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/localDesignerPanel.test.ts similarity index 70% rename from apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerForLocalProject.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/localDesignerPanel.test.ts index 8ca7b379ce7..57f24e3f4fa 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerForLocalProject.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/localDesignerPanel.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ext } from '../../../../../extensionVariables'; +import { ext } from '../../../../../../extensionVariables'; import { openUrl } from '@microsoft/vscode-azext-utils'; import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; import { env, workspace } from 'vscode'; import axios from 'axios'; import { writeFileSync } from 'fs'; -// Mock dependencies before importing the class -vi.mock('../../../../../localize', () => ({ +vi.mock('../../../../../../localize', () => ({ localize: (_key: string, defaultMsg: string, ...args: string[]) => defaultMsg.replace(/{(\d+)}/g, (_match, index) => args[Number(index)] ?? ''), })); @@ -35,7 +34,7 @@ vi.mock('fs', () => ({ writeFileSync: vi.fn(), })); -vi.mock('../../../../utils/codeless/common', () => ({ +vi.mock('../../../../../utils/codeless/common', () => ({ tryGetWebviewPanel: vi.fn(), cacheWebviewPanel: vi.fn(), removeWebviewPanelFromCache: vi.fn(), @@ -45,7 +44,7 @@ vi.mock('../../../../utils/codeless/common', () => ({ getAzureConnectorDetailsForLocalProject: vi.fn().mockResolvedValue({ enabled: false }), })); -vi.mock('../../../../utils/codeless/getWebViewHTML', () => ({ +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ getWebViewHTML: vi.fn().mockResolvedValue(''), })); @@ -56,7 +55,7 @@ vi.mock('@microsoft/logic-apps-shared', () => ({ HTTP_METHODS: { POST: 'POST', GET: 'GET' }, })); -vi.mock('../../../../utils/codeless/connection', () => ({ +vi.mock('../../../../../utils/codeless/connection', () => ({ getConnectionsFromFile: vi.fn().mockResolvedValue('{}'), getCustomCodeFromFiles: vi.fn().mockResolvedValue({}), getLogicAppProjectRoot: vi.fn().mockResolvedValue('/test/project'), @@ -68,31 +67,31 @@ vi.mock('../../../../utils/codeless/connection', () => ({ saveCustomCodeStandard: vi.fn(), })); -vi.mock('../../../../utils/codeless/startDesignTimeApi', () => ({ +vi.mock('../../../../../utils/codeless/startDesignTimeApi', () => ({ startDesignTimeApi: vi.fn(), })); -vi.mock('../../../../utils/requestUtils', () => ({ +vi.mock('../../../../../utils/requestUtils', () => ({ sendRequest: vi.fn(), })); -vi.mock('../../../dataMapper/dataMapper', () => ({ +vi.mock('../../../../dataMapper/dataMapper', () => ({ createNewDataMapCmd: vi.fn(), })); -vi.mock('../../../../utils/codeless/parameter', () => ({ +vi.mock('../../../../../utils/codeless/parameter', () => ({ saveWorkflowParameter: vi.fn(), })); -vi.mock('../../../../utils/codeless/artifacts', () => ({ +vi.mock('../../../../../utils/codeless/artifacts', () => ({ getArtifactsInLocalProject: vi.fn().mockResolvedValue({ maps: {}, schemas: [] }), })); -vi.mock('../../../../utils/bundleFeed', () => ({ +vi.mock('../../../../../utils/bundleFeed', () => ({ getBundleVersionNumber: vi.fn().mockResolvedValue('1.0.0'), })); -vi.mock('../../../../utils/appSettings/localSettings', () => ({ +vi.mock('../../../../../utils/appSettings/localSettings', () => ({ getLocalSettingsJson: vi.fn().mockResolvedValue({ Values: {} }), })); @@ -100,22 +99,21 @@ vi.mock('@azure/core-rest-pipeline', () => ({ createHttpHeaders: vi.fn(), })); -vi.mock('../../unitTest/createUnitTest', () => ({ +vi.mock('../../../unitTest/createUnitTest', () => ({ createUnitTest: vi.fn(), })); -vi.mock('../../../../utils/codeless/getAuthorizationToken', () => ({ +vi.mock('../../../../../utils/codeless/getAuthorizationToken', () => ({ getAuthorizationTokenFromNode: vi.fn().mockResolvedValue('mock-token'), })); -// Import after mocks -import OpenDesignerForLocalProject from '../openDesignerForLocalProject'; -import { createNewDataMapCmd } from '../../../dataMapper/dataMapper'; -import { createUnitTest } from '../../unitTest/createUnitTest'; -import { getBundleVersionNumber } from '../../../../utils/bundleFeed'; -import { getLocalSettingsJson } from '../../../../utils/appSettings/localSettings'; -import { getArtifactsInLocalProject } from '../../../../utils/codeless/artifacts'; -import { getWebViewHTML } from '../../../../utils/codeless/getWebViewHTML'; +import LocalDesignerPanel from '../localDesignerPanel'; +import { createNewDataMapCmd } from '../../../../dataMapper/dataMapper'; +import { createUnitTest } from '../../../unitTest/createUnitTest'; +import { getBundleVersionNumber } from '../../../../../utils/bundleFeed'; +import { getLocalSettingsJson } from '../../../../../utils/appSettings/localSettings'; +import { getArtifactsInLocalProject } from '../../../../../utils/codeless/artifacts'; +import { getWebViewHTML } from '../../../../../utils/codeless/getWebViewHTML'; import { addConnectionData, getConnectionsAndSettingsToUpdate, @@ -126,12 +124,12 @@ import { getParametersFromFile, saveConnectionReferences, saveCustomCodeStandard, -} from '../../../../utils/codeless/connection'; -import { getAzureConnectorDetailsForLocalProject, getManualWorkflowsInLocalProject } from '../../../../utils/codeless/common'; -import { startDesignTimeApi } from '../../../../utils/codeless/startDesignTimeApi'; -import { saveWorkflowParameter } from '../../../../utils/codeless/parameter'; +} from '../../../../../utils/codeless/connection'; +import { getAzureConnectorDetailsForLocalProject, getManualWorkflowsInLocalProject } from '../../../../../utils/codeless/common'; +import { startDesignTimeApi } from '../../../../../utils/codeless/startDesignTimeApi'; +import { saveWorkflowParameter } from '../../../../../utils/codeless/parameter'; -describe('OpenDesignerForLocalProject', () => { +describe('LocalDesignerPanel', () => { const mockContext = { telemetry: { properties: {}, measurements: {} } } as any; const mockUri = { fsPath: '/test/project/myWorkflow/workflow.json' } as any; @@ -163,36 +161,36 @@ describe('OpenDesignerForLocalProject', () => { describe('constructor', () => { it('should construct with correct workflow name from file path', () => { - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); + const instance = new LocalDesignerPanel(mockContext, mockUri); expect(instance).toBeDefined(); }); it('should set isLocal to true', () => { - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); + const instance = new LocalDesignerPanel(mockContext, mockUri); expect(instance).toBeDefined(); }); it('should handle run ID parameter', () => { - const instance = new OpenDesignerForLocalProject(mockContext, mockUri, undefined); + const instance = new LocalDesignerPanel(mockContext, mockUri, undefined); expect(instance).toBeDefined(); }); }); - describe('createPanel', () => { + describe('create', () => { it('should return early if existing panel is found', async () => { - const { tryGetWebviewPanel } = await import('../../../../utils/codeless/common'); + const { tryGetWebviewPanel } = await import('../../../../../utils/codeless/common'); const mockPanel = { active: false, reveal: vi.fn() }; vi.mocked(tryGetWebviewPanel).mockReturnValue(mockPanel as any); - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); - await instance.createPanel(); + const instance = new LocalDesignerPanel(mockContext, mockUri); + await instance.create(); expect(mockPanel.reveal).toHaveBeenCalled(); }); it('should fail before creating a panel when design-time startup failed', async () => { - const { tryGetWebviewPanel } = await import('../../../../utils/codeless/common'); - const { getLogicAppProjectRoot } = await import('../../../../utils/codeless/connection'); + const { tryGetWebviewPanel } = await import('../../../../../utils/codeless/common'); + const { getLogicAppProjectRoot } = await import('../../../../../utils/codeless/connection'); vi.mocked(tryGetWebviewPanel).mockReturnValue(undefined); vi.mocked(getLogicAppProjectRoot).mockResolvedValue('/test/project'); @@ -202,30 +200,30 @@ describe('OpenDesignerForLocalProject', () => { startupError: 'func host failed to start', }); - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); + const instance = new LocalDesignerPanel(mockContext, mockUri); - await expect(instance.createPanel()).rejects.toThrow( + await expect(instance.create()).rejects.toThrow( 'Design time failed to start for project /test/project. func host failed to start' ); }); it('should fail when no design-time instance is available for the project', async () => { - const { tryGetWebviewPanel } = await import('../../../../utils/codeless/common'); - const { getLogicAppProjectRoot } = await import('../../../../utils/codeless/connection'); + const { tryGetWebviewPanel } = await import('../../../../../utils/codeless/common'); + const { getLogicAppProjectRoot } = await import('../../../../../utils/codeless/connection'); vi.mocked(tryGetWebviewPanel).mockReturnValue(undefined); vi.mocked(getLogicAppProjectRoot).mockResolvedValue('/test/project'); - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); + const instance = new LocalDesignerPanel(mockContext, mockUri); - await expect(instance.createPanel()).rejects.toThrow('Design time is not running for project /test/project.'); + await expect(instance.create()).rejects.toThrow('Design time is not running for project /test/project.'); }); it('creates a designer panel and caches it when design-time is available', async () => { ext.designTimeInstances.set('/test/project', { port: 7071, isStarting: false }); - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); + const instance = new LocalDesignerPanel(mockContext, mockUri); - await instance.createPanel(); + await instance.create(); expect(startDesignTimeApi).toHaveBeenCalledWith('/test/project'); expect(mockContext.telemetry.properties.extensionBundleVersion).toBe('1.0.0'); @@ -236,8 +234,8 @@ describe('OpenDesignerForLocalProject', () => { describe('metadata', () => { it('builds designer panel metadata using the project path for bundle resolution', async () => { - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); - const metadata = await (instance as any)._getDesignerPanelMetadata({ + const instance = new LocalDesignerPanel(mockContext, mockUri); + const metadata = await (instance as any).getDesignerPanelMetadata({ flatFileEncoding: { inputs: { properties: { schema: { properties: { source: true } } } } }, liquidJsonToJson: { inputs: { properties: { map: { properties: { source: true } } } } }, xmlValidation: { inputs: { properties: { schema: { properties: { source: true } } } } }, @@ -258,11 +256,33 @@ describe('OpenDesignerForLocalProject', () => { 'LogicApp' ); }); + + it('reads localSettings after getAzureConnectorDetailsForLocalProject to avoid stale data from wizard writes', async () => { + let azureDetailsResolved = false; + + vi.mocked(getAzureConnectorDetailsForLocalProject).mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + azureDetailsResolved = true; + resolve({ accessToken: 'token', enabled: true, subscriptionId: 'sub-1', tenantId: 'tenant-1' } as any); + }, 50); + }); + }); + vi.mocked(getLocalSettingsJson).mockImplementation(async () => { + expect(azureDetailsResolved).toBe(true); + return { Values: { WORKFLOWS_TENANT_ID: 'tenant-1', WORKFLOWS_SUBSCRIPTION_ID: 'sub-1' } } as any; + }); + + const instance = new LocalDesignerPanel(mockContext, mockUri); + await (instance as any).getDesignerPanelMetadata({}); + + expect(getLocalSettingsJson).toHaveBeenCalled(); + }); }); describe('webview messages', () => { function createMessageHarness() { - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); + const instance = new LocalDesignerPanel(mockContext, mockUri); (instance as any).panel = { webview: { postMessage: vi.fn() } }; (instance as any).panelMetadata = { workflowContent: { definition: {} }, @@ -277,7 +297,6 @@ describe('OpenDesignerForLocalProject', () => { (instance as any).oauthRedirectUrl = 'vscode://auth'; (instance as any).projectPath = '/test/project'; (instance as any).getWorkflowRuntimeBaseUrl = () => 'http://localhost:8080/admin'; - (instance as any).sendMsgToWebview = vi.fn(); (instance as any).saveWorkflow = vi.fn(); (instance as any).createFileSystemConnection = vi.fn().mockResolvedValue({ connection: { id: 'connection' } }); return instance; @@ -286,30 +305,30 @@ describe('OpenDesignerForLocalProject', () => { it('handles initialize, save, and designer utility messages', async () => { const instance = createMessageHarness(); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.initialize }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.save, definition: {} }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.createUnitTest, definition: {} }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.addConnection, connectionAndSetting: { name: 'conn' } }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.openOauthLoginPopup, url: 'https://login.example.com' }); - await (instance as any)._handleWebviewMsg({ + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.initialize }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.save, definition: {} }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.createUnitTest, definition: {} }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.addConnection, connectionAndSetting: { name: 'conn' } }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.openOauthLoginPopup, url: 'https://login.example.com' }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.createFileSystemConnection, connectionName: 'filesystem', connectionInfo: { connectionParameters: {} }, }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.openRelativeLink, content: '/dataMapper' }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.logTelemetry, data: { area: 'designerArea' } }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.fileABug }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.getDesignerVersion }); - await (instance as any)._handleWebviewMsg({ command: 'unknown' }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.openRelativeLink, content: '/dataMapper' }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.logTelemetry, data: { area: 'designerArea' } }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.fileABug }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.getDesignerVersion }); + await (instance as any).handleWebviewMsg({ command: 'unknown' }); - expect((instance as any).sendMsgToWebview).toHaveBeenCalledWith( + expect((instance as any).panel.webview.postMessage).toHaveBeenCalledWith( expect.objectContaining({ command: ExtensionCommand.initialize_frame }) ); expect((instance as any).saveWorkflow).toHaveBeenCalled(); expect(createUnitTest).toHaveBeenCalled(); expect(addConnectionData).toHaveBeenCalledWith(expect.anything(), mockUri.fsPath, { name: 'conn' }); expect(env.openExternal).toHaveBeenCalledWith('https://login.example.com'); - expect((instance as any).sendMsgToWebview).toHaveBeenCalledWith( + expect((instance as any).panel.webview.postMessage).toHaveBeenCalledWith( expect.objectContaining({ command: ExtensionCommand.completeFileSystemConnection, data: expect.objectContaining({ connectionName: 'filesystem' }), @@ -318,7 +337,7 @@ describe('OpenDesignerForLocalProject', () => { expect(createNewDataMapCmd).toHaveBeenCalledWith(mockContext); expect(ext.telemetryReporter.sendTelemetryEvent).toHaveBeenCalledWith('designerArea', { area: 'designerArea' }); expect(openUrl).toHaveBeenCalledWith('https://github.com/Azure/LogicAppsUX/issues/new?template=bug_report.yml'); - expect((instance as any).sendMsgToWebview).toHaveBeenCalledWith( + expect((instance as any).panel.webview.postMessage).toHaveBeenCalledWith( expect.objectContaining({ command: ExtensionCommand.getDesignerVersion }) ); @@ -331,8 +350,8 @@ describe('OpenDesignerForLocalProject', () => { vi.mocked(getConnectionsAndSettingsToUpdate).mockResolvedValue({ managedApiConnections: {} } as any); vi.mocked(getCustomCodeToUpdate).mockResolvedValue({ codeFile: 'content' } as any); vi.mocked(getParametersFromFile).mockResolvedValueOnce({ preservedParameter: { value: 'existing' } } as any); - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); - (instance as any).sendMsgToWebview = vi.fn(); + const instance = new LocalDesignerPanel(mockContext, mockUri); + (instance as any).panel = { webview: { postMessage: vi.fn() } }; const workflow = { definition: { actions: {} } }; const workflowToSave = { definition: { actions: { Response: { type: 'Response' } } }, @@ -367,12 +386,12 @@ describe('OpenDesignerForLocalProject', () => { }) ); expect(writeFileSync).toHaveBeenCalledWith(mockUri.fsPath, expect.stringContaining('Response')); - expect((instance as any).sendMsgToWebview).toHaveBeenCalledWith({ command: ExtensionCommand.resetDesignerDirtyState }); + expect((instance as any).panel.webview.postMessage).toHaveBeenCalledWith({ command: ExtensionCommand.resetDesignerDirtyState }); }); it('reports and rethrows save failures', async () => { vi.mocked(getLogicAppProjectRoot).mockRejectedValueOnce(new Error('project lookup failed')); - const instance = new OpenDesignerForLocalProject(mockContext, mockUri); + const instance = new LocalDesignerPanel(mockContext, mockUri); await expect((instance as any).saveWorkflow(mockContext, mockUri.fsPath, {}, { definition: {} }, {})).rejects.toThrow( 'project lookup failed' diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerForAzureResource.test.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/remoteDesignerPanel.test.ts similarity index 80% rename from apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerForAzureResource.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/remoteDesignerPanel.test.ts index 4533eca3b02..667792916c1 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/__test__/openDesignerForAzureResource.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/remoteDesignerPanel.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as vscode from 'vscode'; -import { ext } from '../../../../../extensionVariables'; +import { ext } from '../../../../../../extensionVariables'; // Mock dependencies before importing the class -vi.mock('../../../../../localize', () => ({ +vi.mock('../../../../../../localize', () => ({ localize: (_key: string, defaultMsg: string) => defaultMsg, })); -vi.mock('../../../../utils/codeless/common', () => ({ +vi.mock('../../../../../utils/codeless/common', () => ({ tryGetWebviewPanel: vi.fn(), cacheWebviewPanel: vi.fn(), removeWebviewPanelFromCache: vi.fn(), @@ -15,7 +15,7 @@ vi.mock('../../../../utils/codeless/common', () => ({ getWorkflowManagementBaseURI: vi.fn(() => 'https://management.azure.com/test'), })); -vi.mock('../../../../utils/codeless/getWebViewHTML', () => ({ +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ getWebViewHTML: vi.fn().mockResolvedValue(''), })); @@ -25,11 +25,11 @@ vi.mock('@microsoft/logic-apps-shared', () => ({ resolveConnectionsReferences: vi.fn(() => ({})), })); -vi.mock('../../../../utils/codeless/getAuthorizationToken', () => ({ +vi.mock('../../../../../utils/codeless/getAuthorizationToken', () => ({ getAuthorizationTokenFromNode: vi.fn().mockResolvedValue('mock-token'), })); -import { OpenDesignerForAzureResource } from '../openDesignerForAzureResource'; +import { RemoteDesignerPanel } from '../remoteDesignerPanel'; const createMockNode = (overrides: Record = {}) => ({ name: 'test-workflow', @@ -69,32 +69,32 @@ describe('OpenDesignerForAzureResource', () => { describe('constructor', () => { it('should construct with correct workflow name from node', () => { const mockNode = createMockNode(); - const instance = new OpenDesignerForAzureResource(mockContext, mockNode as any); + const instance = new RemoteDesignerPanel(mockContext, mockNode as any); expect(instance).toBeDefined(); }); it('should set base URL from node', () => { const mockNode = createMockNode(); - const instance = new OpenDesignerForAzureResource(mockContext, mockNode as any); + const instance = new RemoteDesignerPanel(mockContext, mockNode as any); expect(instance).toBeDefined(); }); }); - describe('createPanel', () => { + describe('create', () => { it('should reveal existing panel if one exists', async () => { - const { tryGetWebviewPanel } = await import('../../../../utils/codeless/common'); + const { tryGetWebviewPanel } = await import('../../../../../utils/codeless/common'); const mockReveal = vi.fn(); vi.mocked(tryGetWebviewPanel).mockReturnValue({ active: false, reveal: mockReveal } as any); const mockNode = createMockNode(); - const instance = new OpenDesignerForAzureResource(mockContext, mockNode as any); - await instance.createPanel(); + const instance = new RemoteDesignerPanel(mockContext, mockNode as any); + await instance.create(); expect(mockReveal).toHaveBeenCalled(); }); it('should create new panel and call showDesignerVersionNotification', async () => { - const { tryGetWebviewPanel, cacheWebviewPanel } = await import('../../../../utils/codeless/common'); + const { tryGetWebviewPanel, cacheWebviewPanel } = await import('../../../../../utils/codeless/common'); vi.mocked(tryGetWebviewPanel).mockReturnValue(undefined); const mockPostMessage = vi.fn(); @@ -116,8 +116,8 @@ describe('OpenDesignerForAzureResource', () => { mockShowInfo.mockResolvedValue(undefined); const mockNode = createMockNode(); - const instance = new OpenDesignerForAzureResource(mockContext, mockNode as any); - await instance.createPanel(); + const instance = new RemoteDesignerPanel(mockContext, mockNode as any); + await instance.create(); expect(cacheWebviewPanel).toHaveBeenCalled(); // showDesignerVersionNotification was called (shows v1 message) diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerBase.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/designerPanel.ts similarity index 81% rename from apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerBase.ts rename to apps/vs-code-designer/src/app/commands/workflows/designer/panels/designerPanel.ts index 1d983ac994a..addc339d0ac 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerBase.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/designerPanel.ts @@ -2,15 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { tryGetWebviewPanel } from '../../../utils/codeless/common'; -import { getWebViewHTML } from '../../../utils/codeless/getWebViewHTML'; -import type { IAzureConnectorsContext } from '../azureConnectorWizard'; +import { tryGetWebviewPanel } from '../../../../utils/codeless/common'; +import { getWebViewHTML } from '../../../../utils/codeless/getWebViewHTML'; import { getRecordEntry, isEmptyString, resolveConnectionsReferences } from '@microsoft/logic-apps-shared'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import type { Artifacts, AzureConnectorDetails, ConnectionsData, FileDetails, Parameter } from '@microsoft/vscode-extension-logic-apps'; -import { azurePublicBaseUrl, workflowManagementBaseURIKey, designerVersionSetting, defaultDesignerVersion, suppressDesignerVersionNotification } from '../../../../constants'; -import { ext } from '../../../../extensionVariables'; -import { localize } from '../../../../localize'; +import { azurePublicBaseUrl, workflowManagementBaseURIKey, designerVersionSetting, defaultDesignerVersion, suppressDesignerVersionNotification } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; import type { WebviewPanel, WebviewOptions, WebviewPanelOptions } from 'vscode'; import { workspace, window, ConfigurationTarget } from 'vscode'; @@ -24,29 +23,27 @@ export interface IDesignerOptions { workflowDetails?: Record; } -export abstract class OpenDesignerBase { +export abstract class DesignerPanel { + protected readonly context: IActionContext; protected workflowName: string; protected panelName: string; protected apiVersion: string; protected panelGroupKey: string; - protected baseUrl: string; - protected workflowRuntimeBaseUrl: string; - protected connectionData: ConnectionsData; - protected panel: WebviewPanel; - protected apiHubServiceDetails: Record; - protected readonly context: IActionContext | IAzureConnectorsContext; + protected baseUrl?: string; + protected workflowRuntimeBaseUrl?: string; + protected connectionData?: ConnectionsData; + protected panel?: WebviewPanel; + protected apiHubServiceDetails?: Record; protected readOnly: boolean; protected isLocal: boolean; protected isMonitoringView: boolean; - protected appSettings: Record; - protected workflowDetails: Record; protected oauthRedirectUrl?: string; - protected schemaArtifacts?: FileDetails[] | undefined; - protected mapArtifacts?: Record | undefined; + protected schemaArtifacts?: FileDetails[]; + protected mapArtifacts?: Record; protected runId?: string; protected constructor( - context: IActionContext | IAzureConnectorsContext, + context: IActionContext, workflowName: string, panelName: string, apiVersion: string, @@ -67,7 +64,7 @@ export abstract class OpenDesignerBase { this.runId = runId; } - protected abstract createPanel(): Promise; + public abstract create(): Promise; protected getExistingPanel(): WebviewPanel | undefined { return tryGetWebviewPanel(this.panelGroupKey, this.panelName); @@ -80,15 +77,15 @@ export abstract class OpenDesignerBase { }; } - protected sendMsgToWebview(msg: any) { - this.panel.webview.postMessage(msg); - } - protected async getWebviewContent(options: IDesignerOptions): Promise { + if (!this.panel) { + throw new Error('Webview panel is not initialized.'); + } + const { parametersData, localSettings, artifacts, azureDetails } = options; let { connectionsData } = options; - const mapArtifacts = {}; + const mapArtifacts: Record = {}; connectionsData = this.getInterpolateConnectionData(connectionsData); for (const extension of Object.keys(artifacts.maps)) { @@ -103,11 +100,36 @@ export abstract class OpenDesignerBase { this.connectionData = resolvedConnections; this.apiHubServiceDetails = this.getApiHubServiceDetails(azureDetails, localSettings); this.mapArtifacts = mapArtifacts; - this.schemaArtifacts = artifacts.schemas; + this.schemaArtifacts = artifacts.schemas ?? []; return await getWebViewHTML('vs-code-react', this.panel); } + protected getInterpolateConnectionData(connectionsJson: string): string { + if (!connectionsJson) { + return connectionsJson; + } + + const connectionsData: ConnectionsData = JSON.parse(connectionsJson); + const managedApiConnections = Object.keys(connectionsData?.managedApiConnections ?? {}); + + managedApiConnections.forEach((apiConnection: any) => { + const connectionValue = connectionsData?.managedApiConnections?.[apiConnection]; + if (!connectionValue) { + return; + } + + if (connectionValue.api && connectionValue.api.id) { + connectionValue.api.id = this.addCurlyBraces(connectionValue.api.id); + } + if (connectionValue.connection && connectionValue.connection.id) { + connectionValue.connection.id = this.addCurlyBraces(connectionValue.connection.id); + } + }); + + return JSON.stringify(connectionsData); + } + private addCurlyBraces(root: string) { let interpolationString = root; let stringLength = interpolationString.length; @@ -116,7 +138,7 @@ export abstract class OpenDesignerBase { for (let i = 0; i < stringLength; i++) { const canHavekeyWord = i + 12 <= stringLength; - if (interpolationString[i] === '@' && canHavekeyWord && this.haveKeyWord(interpolationString.substring(i, i + 12))) { + if (interpolationString[i] === '@' && canHavekeyWord && this.isTemplateKeyword(interpolationString.substring(i, i + 12))) { resolvedString += `${interpolationString[i]}{`; const closeTagIndex = interpolationString.indexOf(')', i); interpolationString = `${interpolationString.substring(0, closeTagIndex + 1)}}${interpolationString.substring( @@ -131,28 +153,8 @@ export abstract class OpenDesignerBase { return resolvedString; } - private haveKeyWord(keyword: string): boolean { - return keyword === '@parameters(' || keyword === '@appsetting('; - } - - protected getInterpolateConnectionData(connectionsData: string) { - if (!connectionsData) { - return connectionsData; - } - const parseConnectionsData: ConnectionsData = JSON.parse(connectionsData); - const managedApiConnections = Object.keys(parseConnectionsData?.managedApiConnections ?? {}); - - managedApiConnections?.forEach((apiConnection: any) => { - const connectionValue = parseConnectionsData?.managedApiConnections[apiConnection] as any; - if (connectionValue.api && connectionValue.api.id) { - connectionValue.api.id = this.addCurlyBraces(connectionValue.api.id); - } - if (connectionValue.connection && connectionValue.connection.id) { - connectionValue.connection.id = this.addCurlyBraces(connectionValue.connection.id); - } - }); - - return JSON.stringify(parseConnectionsData); + private isTemplateKeyword(str: string): boolean { + return str === '@parameters(' || str === '@appsetting('; } protected getApiHubServiceDetails(azureDetails: AzureConnectorDetails, localSettings: Record) { @@ -180,11 +182,6 @@ export abstract class OpenDesignerBase { return location.toLowerCase().replace(/ /g, ''); } - protected getDesignerVersion(): number { - const config = workspace.getConfiguration(ext.prefix); - return config.get(designerVersionSetting) ?? defaultDesignerVersion; - } - protected async showDesignerVersionNotification(): Promise { const isSuppressed = ext.context.globalState.get(suppressDesignerVersionNotification) === true; if (isSuppressed) { @@ -199,6 +196,11 @@ export abstract class OpenDesignerBase { } } + protected getDesignerVersion(): number { + const config = workspace.getConfiguration(ext.prefix); + return config.get(designerVersionSetting) ?? defaultDesignerVersion; + } + private async showUpgradeDesignerNotification(): Promise { const config = workspace.getConfiguration(ext.prefix); const enablePreview = localize('enablePreview', 'Enable preview'); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/localDesignerPanel.ts similarity index 82% rename from apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts rename to apps/vs-code-designer/src/app/commands/workflows/designer/panels/localDesignerPanel.ts index 5e97a1cebcf..a5d792b80e6 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/localDesignerPanel.ts @@ -4,18 +4,18 @@ import { logicAppsStandardExtensionId, managementApiPrefix, workflowAppApiVersion, -} from '../../../../constants'; -import { ext } from '../../../../extensionVariables'; -import { localize } from '../../../../localize'; -import { getLocalSettingsJson } from '../../../utils/appSettings/localSettings'; -import { getArtifactsInLocalProject } from '../../../utils/codeless/artifacts'; +} from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; +import { getLocalSettingsJson } from '../../../../utils/appSettings/localSettings'; +import { getArtifactsInLocalProject } from '../../../../utils/codeless/artifacts'; import { cacheWebviewPanel, getAzureConnectorDetailsForLocalProject, getManualWorkflowsInLocalProject, getStandardAppData, removeWebviewPanelFromCache, -} from '../../../utils/codeless/common'; +} from '../../../../utils/codeless/common'; import { addConnectionData, getConnectionsAndSettingsToUpdate, @@ -26,19 +26,19 @@ import { getParametersFromFile, saveConnectionReferences, saveCustomCodeStandard, -} from '../../../utils/codeless/connection'; -import { saveWorkflowParameter } from '../../../utils/codeless/parameter'; -import { startDesignTimeApi } from '../../../utils/codeless/startDesignTimeApi'; -import { sendRequest } from '../../../utils/requestUtils'; -import { createNewDataMapCmd } from '../../dataMapper/dataMapper'; -import { OpenDesignerBase } from './openDesignerBase'; +} from '../../../../utils/codeless/connection'; +import { saveWorkflowParameter } from '../../../../utils/codeless/parameter'; +import { startDesignTimeApi } from '../../../../utils/codeless/startDesignTimeApi'; +import { sendRequest } from '../../../../utils/requestUtils'; +import { createNewDataMapCmd } from '../../../dataMapper/dataMapper'; +import { DesignerPanel } from './designerPanel'; import { HTTP_METHODS } from '@microsoft/logic-apps-shared'; import { callWithTelemetryAndErrorHandling, openUrl, type IActionContext } from '@microsoft/vscode-azext-utils'; import type { - AzureConnectorDetails, CompleteFileSystemConnectionData, + FileDetails, FileSystemConnectionInfo, - IDesignerPanelMetadata, + DesignerPanelMetadata, Parameter, } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; @@ -48,17 +48,16 @@ import { writeFileSync, readFileSync } from 'fs'; import * as path from 'path'; import { env, ProgressLocation, Uri, ViewColumn, window, workspace } from 'vscode'; import type { WebviewPanel, ProgressOptions } from 'vscode'; -import { createUnitTest } from '../unitTest/createUnitTest'; +import { createUnitTest } from '../../unitTest/createUnitTest'; import { createHttpHeaders } from '@azure/core-rest-pipeline'; -import { getBundleVersionNumber } from '../../../utils/bundleFeed'; +import { getBundleVersionNumber } from '../../../../utils/bundleFeed'; -export default class OpenDesignerForLocalProject extends OpenDesignerBase { +export default class LocalDesignerPanel extends DesignerPanel { private readonly workflowFilePath: string; - private migrationOptions: Record; - private projectPath: string | undefined; - private panelMetadata: IDesignerPanelMetadata; - private workflowRuntimeBaseUrlInterval: NodeJS.Timeout; - private getWorkflowRuntimeBaseUrl: () => string | undefined; + private migrationOptions?: Record; + private projectPath?: string; + private panelMetadata?: DesignerPanelMetadata; + private workflowRuntimeBaseUrlInterval?: NodeJS.Timeout; constructor(context: IActionContext, node: Uri, runId?: string) { const workflowName = path.basename(path.dirname(node.fsPath)); @@ -93,21 +92,20 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { }); }; - public async createPanel(): Promise { + public async create(): Promise { const existingPanel: WebviewPanel | undefined = this.getExistingPanel(); if (existingPanel) { this.panel = existingPanel; if (!existingPanel.active) { existingPanel.reveal(ViewColumn.Active); - return; } return; } this.projectPath = await getLogicAppProjectRoot(this.context, this.workflowFilePath); if (!this.projectPath) { - throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); + throw new Error(localize('projectPathUndefined', 'Unable to determine project root folder.')); } await startDesignTimeApi(this.projectPath); @@ -133,8 +131,6 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } this.baseUrl = `http://localhost:${designTimePort}${managementApiPrefix}`; - this.getWorkflowRuntimeBaseUrl = () => - ext.workflowRuntimePort ? `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}` : undefined; this.workflowRuntimeBaseUrl = this.getWorkflowRuntimeBaseUrl(); this.panel = window.createWebviewPanel( @@ -148,8 +144,8 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), }; - this.migrationOptions = await this._getMigrationOptions(this.baseUrl); - this.panelMetadata = await this._getDesignerPanelMetadata(this.migrationOptions); + this.migrationOptions = await this.getMigrationOptions(this.baseUrl); + this.panelMetadata = await this.getDesignerPanelMetadata(this.migrationOptions); const callbackUri: Uri = await (env as any).asExternalUri(Uri.parse(`${env.uriScheme}://${logicAppsStandardExtensionId}/authcomplete`)); this.context.telemetry.properties.extensionBundleVersion = this.panelMetadata.extensionBundleVersion; this.oauthRedirectUrl = callbackUri.toString(true); @@ -162,10 +158,11 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { azureDetails: this.panelMetadata.azureDetails, workflowDetails: this.panelMetadata.workflowDetails, }); - this.panelMetadata.mapArtifacts = this.mapArtifacts; - this.panelMetadata.schemaArtifacts = this.schemaArtifacts; - this.panel.webview.onDidReceiveMessage(async (message) => await this._handleWebviewMsg(message), ext.context.subscriptions); + this.panelMetadata.mapArtifacts = this.mapArtifacts as Record; + this.panelMetadata.schemaArtifacts = this.schemaArtifacts as FileDetails[]; + + this.panel.webview.onDidReceiveMessage(async (message) => await this.handleWebviewMsg(message), ext.context.subscriptions); this.panel.onDidChangeViewState( async (event) => { @@ -193,7 +190,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { this.showDesignerVersionNotification(); } - private async _handleWebviewMsg(msg: any) { + private async handleWebviewMsg(msg: any) { switch (msg.command) { case ExtensionCommand.initialize: { this.workflowRuntimeBaseUrlInterval = setInterval(async () => { @@ -201,7 +198,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { if (updatedRuntimeBaseUrl !== this.workflowRuntimeBaseUrl) { this.workflowRuntimeBaseUrl = updatedRuntimeBaseUrl; - this.panel.webview.postMessage({ + this.panel?.webview.postMessage({ command: ExtensionCommand.update_runtime_base_url, data: { baseUrl: this.workflowRuntimeBaseUrl, @@ -210,7 +207,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } }, 3000); - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.initialize_frame, data: { project: ProjectName.designer, @@ -223,7 +220,6 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { readOnly: this.readOnly, isLocal: this.isLocal, isMonitoringView: this.isMonitoringView, - workflowDetails: this.workflowDetails, oauthRedirectUrl: this.oauthRedirectUrl, hostVersion: ext.extensionVersion, runId: this.runId, @@ -236,8 +232,14 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { // }); break; } + case ExtensionCommand.save: { await callWithTelemetryAndErrorHandling('SaveWorkflowFromDesigner', async (activateContext: IActionContext) => { + if (!this.panelMetadata) { + window.showErrorMessage('Failed to save workflow. Panel metadata is not available.'); + return; + } + // TODO(aeldridge): Temporarily removed validation due to 500 responses from validatePartial endpoint. Re-add once fixed. // const projectPath = await getLogicAppProjectRoot(activateContext, this.workflowFilePath); // const localSettingsPath: string = path.join(projectPath, localSettingsFileName); @@ -263,16 +265,19 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { }); break; } + case ExtensionCommand.createUnitTest: { await createUnitTest(Uri.file(this.workflowFilePath), msg.definition); break; } + case ExtensionCommand.addConnection: { await callWithTelemetryAndErrorHandling('AddConnectionFromDesigner', async (activateContext: IActionContext) => { await addConnectionData(activateContext, this.workflowFilePath, msg.connectionAndSetting); }); break; } + case ExtensionCommand.openOauthLoginPopup: { await env.openExternal(msg.url); break; @@ -287,7 +292,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { connection, error: errorMessage, }; - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.completeFileSystemConnection, data: completeData, }); @@ -307,13 +312,14 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { ext.telemetryReporter.sendTelemetryEvent(eventName, { ...msg.data }); break; } + case ExtensionCommand.fileABug: { await openUrl('https://github.com/Azure/LogicAppsUX/issues/new?template=bug_report.yml'); break; } case ExtensionCommand.getDesignerVersion: { - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.getDesignerVersion, data: this.getDesignerVersion(), }); @@ -325,6 +331,10 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } } + private getWorkflowRuntimeBaseUrl(): string | undefined { + return ext.workflowRuntimePort ? `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}` : undefined; + } + /** * Saves workflow locally in the workflow.json. * @param {string} filePath - File path of file to save the workflow. @@ -386,7 +396,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } writeFileSync(filePath, JSON.stringify(workflow, null, 4)); - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.resetDesignerDirtyState, }); } catch (error) { @@ -412,13 +422,19 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { * The validation includes the workflow definition, kind, and local app settings. */ private async validateWorkflow(context: IActionContext, workflow: any, localSettings: any): Promise { + if (!this.projectPath) { + throw new Error(localize('projectPathUndefined', 'Unable to determine project root folder.')); + } + if (!ext.designTimeInstances.has(this.projectPath)) { throw new Error(localize('designTimeNotRunning', `Design time is not running for project ${this.projectPath}.`)); } - const designTimePort = ext.designTimeInstances.get(this.projectPath).port; + + const designTimePort = ext.designTimeInstances.get(this.projectPath)?.port; if (!designTimePort) { throw new Error(localize('designTimePortNotFound', 'Design time port not found.')); } + const url = `http://localhost:${designTimePort}${managementApiPrefix}/workflows/${this.workflowName}/validatePartial?api-version=${this.apiVersion}`; try { const headers = createHttpHeaders({ @@ -443,19 +459,19 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } } - private _migrate(workflow: any, migrationOptions: Record): void { - this._traverseActions(workflow.definition?.actions, migrationOptions); + private migrate(workflow: any, migrationOptions: Record): void { + this.traverseActions(workflow.definition?.actions, migrationOptions); } - private _traverseActions(actions: any, migrationOptions: Record): void { + private traverseActions(actions: any, migrationOptions: Record): void { if (actions) { for (const actionName of Object.keys(actions)) { - this._traverseAction(actions[actionName], migrationOptions); + this.traverseAction(actions[actionName], migrationOptions); } } } - private _traverseAction(action: any, migrationOptions: Record): void { + private traverseAction(action: any, migrationOptions: Record): void { const type = action?.type; switch ((type || '').toLowerCase()) { case 'liquid': { @@ -496,28 +512,28 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { break; } case 'if': { - this._traverseActions(action.else?.actions, migrationOptions); + this.traverseActions(action.else?.actions, migrationOptions); break; } case 'scope': case 'foreach': case 'changeset': case 'until': { - this._traverseActions(action.actions, migrationOptions); + this.traverseActions(action.actions, migrationOptions); break; } case 'switch': { for (const caseKey of Object.keys(action.cases || {})) { - this._traverseActions(action.cases[caseKey]?.actions, migrationOptions); + this.traverseActions(action.cases[caseKey]?.actions, migrationOptions); } - this._traverseActions(action.default?.actions, migrationOptions); + this.traverseActions(action.default?.actions, migrationOptions); break; } } } - private _getMigrationOptions(baseUrl: string): Promise> { + private getMigrationOptions(baseUrl: string): Promise> { const flatFileEncodingPromise = axios.get( `${baseUrl}/operationGroups/flatFileOperations/operations/flatFileEncoding?api-version=2019-10-01-edge-preview&$expand=properties/manifest` ); @@ -543,27 +559,28 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { ); } - private async _getDesignerPanelMetadata(migrationOptions: Record = {}): Promise { - const workflowContent: any = JSON.parse(readFileSync(this.workflowFilePath, 'utf8')); - this._migrate(workflowContent, migrationOptions); - const connectionsData: string = await getConnectionsFromFile(this.context, this.workflowFilePath); + private async getDesignerPanelMetadata(migrationOptions: Record = {}): Promise { const projectPath: string | undefined = await getLogicAppProjectRoot(this.context, this.workflowFilePath); - const parametersData: Record = await getParametersFromFile(this.context, this.workflowFilePath); - const customCodeData: Record = await getCustomCodeFromFiles(this.workflowFilePath); - const workflowDetails = await getManualWorkflowsInLocalProject(projectPath, this.workflowName); - const artifacts = await getArtifactsInLocalProject(projectPath); - const bundleVersionNumber = await getBundleVersionNumber(projectPath); - - let localSettings: Record; - let azureDetails: AzureConnectorDetails; - - if (projectPath) { - azureDetails = await getAzureConnectorDetailsForLocalProject(this.context, projectPath); - localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values; - } else { + if (!projectPath) { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } + const workflowContent: any = JSON.parse(readFileSync(this.workflowFilePath, 'utf8')); + this.migrate(workflowContent, migrationOptions); + + const [connectionsData, parametersData, customCodeData, workflowDetails, artifacts, bundleVersionNumber, azureDetails] = + await Promise.all([ + getConnectionsFromFile(this.context, this.workflowFilePath), + getParametersFromFile(this.context, this.workflowFilePath), + getCustomCodeFromFiles(this.workflowFilePath), + getManualWorkflowsInLocalProject(projectPath, this.workflowName), + getArtifactsInLocalProject(projectPath), + getBundleVersionNumber(projectPath), + getAzureConnectorDetailsForLocalProject(this.context, projectPath), + ]); + + const localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values!; + return { panelId: this.panelName, appSettingNames: Object.keys(localSettings), @@ -578,8 +595,8 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { workflowDetails, workflowName: this.workflowName, artifacts, - schemaArtifacts: this.schemaArtifacts, - mapArtifacts: this.mapArtifacts, + schemaArtifacts: this.schemaArtifacts as FileDetails[], + mapArtifacts: this.mapArtifacts as Record, extensionBundleVersion: bundleVersionNumber, }; } @@ -610,7 +627,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { * @param webviewPanel The web view panel to update. */ private async reloadWebviewPanel(webviewPanel: WebviewPanel): Promise { - this.panelMetadata = await this._getDesignerPanelMetadata(this.migrationOptions); + this.panelMetadata = await this.getDesignerPanelMetadata(this.migrationOptions); webviewPanel.webview.html = await this.getWebviewContent({ connectionsData: this.panelMetadata.connectionsData, parametersData: this.panelMetadata.parametersData || {}, @@ -619,7 +636,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { azureDetails: this.panelMetadata.azureDetails, workflowDetails: this.panelMetadata.workflowDetails, }); - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.update_panel_metadata, data: { panelMetadata: this.panelMetadata, diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForAzureResource.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/remoteDesignerPanel.ts similarity index 76% rename from apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForAzureResource.ts rename to apps/vs-code-designer/src/app/commands/workflows/designer/panels/remoteDesignerPanel.ts index c979110b3cc..66cd222cf74 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForAzureResource.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/remoteDesignerPanel.ts @@ -1,28 +1,27 @@ -import { openUrl } from '@microsoft/vscode-azext-utils'; -import { assetsFolderName, workflowAppApiVersion } from '../../../../constants'; -import { ext } from '../../../../extensionVariables'; -import type { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; +import { type IActionContext, openUrl } from '@microsoft/vscode-azext-utils'; +import { assetsFolderName, workflowAppApiVersion } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import type { RemoteWorkflowTreeItem } from '../../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; import { removeWebviewPanelFromCache, cacheWebviewPanel, getStandardAppData, getWorkflowManagementBaseURI, -} from '../../../utils/codeless/common'; -import type { IAzureConnectorsContext } from '../azureConnectorWizard'; -import { OpenDesignerBase } from './openDesignerBase'; -import type { IWorkflowFileContent, IDesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; +} from '../../../../utils/codeless/common'; +import { DesignerPanel } from './designerPanel'; +import type { IWorkflowFileContent, DesignerPanelMetadata, FileDetails } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; import * as path from 'path'; import * as vscode from 'vscode'; import { Uri } from 'vscode'; -import { getAuthorizationTokenFromNode } from '../../../utils/codeless/getAuthorizationToken'; +import { getAuthorizationTokenFromNode } from '../../../../utils/codeless/getAuthorizationToken'; -export class OpenDesignerForAzureResource extends OpenDesignerBase { +export class RemoteDesignerPanel extends DesignerPanel { private readonly node: RemoteWorkflowTreeItem; private readonly workflow: IWorkflowFileContent; - private panelMetadata: IDesignerPanelMetadata; + private panelMetadata?: DesignerPanelMetadata; - constructor(context: IAzureConnectorsContext, node: RemoteWorkflowTreeItem) { + constructor(context: IActionContext, node: RemoteWorkflowTreeItem) { const workflowName: string = node.name; const panelName = `${vscode.workspace.name}-${workflowName}`; const panelGroupKey = ext.webViewKey.designerAzure; @@ -34,14 +33,15 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { this.baseUrl = getWorkflowManagementBaseURI(node); } - public async createPanel(): Promise { + public async create(): Promise { const existingPanel: vscode.WebviewPanel | undefined = this.getExistingPanel(); if (existingPanel) { + this.panel = existingPanel; if (!existingPanel.active) { existingPanel.reveal(vscode.ViewColumn.Active); - return; } + return; } this.panel = vscode.window.createWebviewPanel(this.panelGroupKey, this.workflowName, vscode.ViewColumn.Active, this.getPanelOptions()); @@ -59,10 +59,11 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { azureDetails: this.panelMetadata.azureDetails, workflowDetails: this.panelMetadata.workflowDetails, }); - this.panelMetadata.mapArtifacts = this.mapArtifacts; - this.panelMetadata.schemaArtifacts = this.schemaArtifacts; + + this.panelMetadata.mapArtifacts = this.mapArtifacts as Record; + this.panelMetadata.schemaArtifacts = this.schemaArtifacts as FileDetails[]; - this.panel.webview.onDidReceiveMessage(async (message) => await this._handleWebviewMsg(message), ext.context.subscriptions); + this.panel.webview.onDidReceiveMessage(async (message) => await this.handleWebviewMsg(message), ext.context.subscriptions); this.panel.onDidDispose( () => { @@ -78,10 +79,10 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { this.showDesignerVersionNotification(); } - private async _handleWebviewMsg(msg: any) { + private async handleWebviewMsg(msg: any) { switch (msg.command) { case ExtensionCommand.initialize: { - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.initialize_frame, data: { project: ProjectName.designer, @@ -93,7 +94,6 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { readOnly: this.readOnly, isLocal: this.isLocal, isMonitoringView: this.isMonitoringView, - workflowDetails: this.workflowDetails, hostVersion: ext.extensionVersion, }, }); @@ -109,7 +109,7 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { break; } case ExtensionCommand.getDesignerVersion: { - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.getDesignerVersion, data: this.getDesignerVersion(), }); @@ -120,7 +120,7 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { } } - private async getDesignerPanelMetadata(): Promise { + private async getDesignerPanelMetadata(): Promise { const accessToken = await getAuthorizationTokenFromNode(this.node); return { @@ -143,8 +143,8 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { }, workflowName: this.workflowName, standardApp: getStandardAppData(this.workflowName, this.workflow), - schemaArtifacts: this.schemaArtifacts, - mapArtifacts: this.mapArtifacts, + schemaArtifacts: this.schemaArtifacts as FileDetails[], + mapArtifacts: this.mapArtifacts as Record, }; } } diff --git a/apps/vs-code-designer/src/app/commands/workflows/monitoringView/openMonitoringView.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/openMonitoringView.ts new file mode 100644 index 00000000000..002214b3fa8 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/openMonitoringView.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; +import RemoteMonitoringPanel from './panels/remoteMonitoringPanel'; +import LocalMonitoringPanel from './panels/localMonitoringPanel'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import { Uri } from 'vscode'; +import { ext } from '../../../../extensionVariables'; +import { localize } from '../../../../localize'; + +export async function openMonitoringView( + context: IActionContext, + node: Uri | RemoteWorkflowTreeItem | undefined, + runId: string, + workflowFilePath: string +): Promise { + if (!node) { + ext.outputChannel.appendLog(localize('workflowNodeNotFound', 'Failed to open monitoring view. Unable to find the workflow node.')); + return; + } + + const monitoringPanel = node instanceof Uri + ? new LocalMonitoringPanel(context, runId, workflowFilePath) + : new RemoteMonitoringPanel(context, runId, workflowFilePath, node); + + await callWithTelemetryAndErrorHandling('azureLogicAppsStandard.openMonitoringView', async (actionContext: IActionContext) => { + actionContext.telemetry.properties.isLocal = node instanceof Uri ? 'true' : 'false'; + await monitoringPanel.create(); + }); +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/__test__/openMonitoringViewForLocal.test.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/__test__/localMonitoringPanel.test.ts similarity index 62% rename from apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/__test__/openMonitoringViewForLocal.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/__test__/localMonitoringPanel.test.ts index 07b4737d9d8..4f23da2670b 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/__test__/openMonitoringViewForLocal.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/__test__/localMonitoringPanel.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ext } from '../../../../../extensionVariables'; +import { ext } from '../../../../../../extensionVariables'; import { openUrl } from '@microsoft/vscode-azext-utils'; import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; import { workspace } from 'vscode'; -// Mock dependencies before importing the class -vi.mock('../../../../../localize', () => ({ +vi.mock('../../../../../../localize', () => ({ localize: (_key: string, defaultMsg: string) => defaultMsg, })); @@ -16,7 +15,7 @@ vi.mock('fs', () => ({ readFileSync: vi.fn(() => JSON.stringify({ definition: {} })), })); -vi.mock('../../../../utils/codeless/common', () => ({ +vi.mock('../../../../../utils/codeless/common', () => ({ tryGetWebviewPanel: vi.fn(), cacheWebviewPanel: vi.fn(), removeWebviewPanelFromCache: vi.fn(), @@ -25,7 +24,7 @@ vi.mock('../../../../utils/codeless/common', () => ({ getAzureConnectorDetailsForLocalProject: vi.fn().mockResolvedValue({ enabled: false }), })); -vi.mock('../../../../utils/codeless/getWebViewHTML', () => ({ +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ getWebViewHTML: vi.fn().mockResolvedValue(''), })); @@ -38,54 +37,54 @@ vi.mock('@microsoft/logic-apps-shared', () => ({ HTTP_METHODS: { POST: 'POST', GET: 'GET' }, })); -vi.mock('../../../../utils/codeless/connection', () => ({ +vi.mock('../../../../../utils/codeless/connection', () => ({ getConnectionsFromFile: vi.fn().mockResolvedValue('{}'), getCustomCodeFromFiles: vi.fn().mockResolvedValue({}), getLogicAppProjectRoot: vi.fn().mockResolvedValue('/test/project'), getParametersFromFile: vi.fn().mockResolvedValue({}), })); -vi.mock('../../../../utils/appSettings/localSettings', () => ({ +vi.mock('../../../../../utils/appSettings/localSettings', () => ({ getLocalSettingsJson: vi.fn().mockResolvedValue({ Values: {} }), })); -vi.mock('../../../../utils/requestUtils', () => ({ +vi.mock('../../../../../utils/requestUtils', () => ({ sendRequest: vi.fn(), })); -vi.mock('../../../../utils/codeless/artifacts', () => ({ +vi.mock('../../../../../utils/codeless/artifacts', () => ({ getArtifactsInLocalProject: vi.fn().mockResolvedValue({ maps: {}, schemas: [] }), })); -vi.mock('../../../../utils/bundleFeed', () => ({ +vi.mock('../../../../../utils/bundleFeed', () => ({ getBundleVersionNumber: vi.fn().mockResolvedValue('1.0.0'), })); -vi.mock('../../unitTest/createUnitTestFromRun', () => ({ +vi.mock('../../../unitTest/createUnitTestFromRun', () => ({ createUnitTestFromRun: vi.fn(), })); -vi.mock('../../../../utils/codeless/getAuthorizationToken', () => ({ +vi.mock('../../../../../utils/codeless/getAuthorizationToken', () => ({ getAuthorizationTokenFromNode: vi.fn().mockResolvedValue('mock-token'), })); import { promises } from 'fs'; -import OpenMonitoringViewForLocal from '../openMonitoringViewForLocal'; -import { getBundleVersionNumber } from '../../../../utils/bundleFeed'; -import { getLocalSettingsJson } from '../../../../utils/appSettings/localSettings'; -import { getArtifactsInLocalProject } from '../../../../utils/codeless/artifacts'; -import { getWebViewHTML } from '../../../../utils/codeless/getWebViewHTML'; -import { getAzureConnectorDetailsForLocalProject } from '../../../../utils/codeless/common'; +import LocalMonitoringPanel from '../localMonitoringPanel'; +import { getBundleVersionNumber } from '../../../../../utils/bundleFeed'; +import { getLocalSettingsJson } from '../../../../../utils/appSettings/localSettings'; +import { getArtifactsInLocalProject } from '../../../../../utils/codeless/artifacts'; +import { getWebViewHTML } from '../../../../../utils/codeless/getWebViewHTML'; +import { getAzureConnectorDetailsForLocalProject } from '../../../../../utils/codeless/common'; import { getConnectionsFromFile, getCustomCodeFromFiles, getLogicAppProjectRoot, getParametersFromFile, -} from '../../../../utils/codeless/connection'; -import { createUnitTestFromRun } from '../../unitTest/createUnitTestFromRun'; -import { sendRequest } from '../../../../utils/requestUtils'; +} from '../../../../../utils/codeless/connection'; +import { createUnitTestFromRun } from '../../../unitTest/createUnitTestFromRun'; +import { sendRequest } from '../../../../../utils/requestUtils'; -describe('OpenMonitoringViewForLocal', () => { +describe('LocalMonitoringPanel', () => { const mockContext = { telemetry: { properties: {}, measurements: {} } } as any; const mockRunId = 'workflows/test-workflow/runs/run-123'; const mockWorkflowFilePath = '/test/project/test-workflow/workflow.json'; @@ -110,32 +109,32 @@ describe('OpenMonitoringViewForLocal', () => { describe('constructor', () => { it('should construct with correct parameters', () => { - const instance = new OpenMonitoringViewForLocal(mockContext, mockRunId, mockWorkflowFilePath); + const instance = new LocalMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath); expect(instance).toBeDefined(); }); it('should set isLocal to true', () => { - const instance = new OpenMonitoringViewForLocal(mockContext, mockRunId, mockWorkflowFilePath); + const instance = new LocalMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath); expect(instance).toBeDefined(); }); }); - describe('createPanel', () => { + describe('create', () => { it('should reveal existing panel if one exists', async () => { - const { tryGetWebviewPanel } = await import('../../../../utils/codeless/common'); + const { tryGetWebviewPanel } = await import('../../../../../utils/codeless/common'); const mockReveal = vi.fn(); vi.mocked(tryGetWebviewPanel).mockReturnValue({ active: false, reveal: mockReveal } as any); - const instance = new OpenMonitoringViewForLocal(mockContext, mockRunId, mockWorkflowFilePath); - await instance.createPanel(); + const instance = new LocalMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath); + await instance.create(); expect(mockReveal).toHaveBeenCalled(); }); it('creates a monitoring panel and caches it', async () => { - const instance = new OpenMonitoringViewForLocal(mockContext, mockRunId, mockWorkflowFilePath); + const instance = new LocalMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath); - await instance.createPanel(); + await instance.create(); expect(mockContext.telemetry.properties.extensionBundleVersion).toBe('1.0.0'); expect((instance as any).panel.webview.html).toBe(''); @@ -144,27 +143,49 @@ describe('OpenMonitoringViewForLocal', () => { describe('metadata', () => { it('builds monitoring metadata using the project path for bundle resolution', async () => { - const instance = new OpenMonitoringViewForLocal(mockContext, mockRunId, mockWorkflowFilePath); + const instance = new LocalMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath); - const metadata = await (instance as any)._getDesignerPanelMetadata(); + const metadata = await (instance as any).getDesignerPanelMetadata(); expect(getBundleVersionNumber).toHaveBeenCalledWith('/test/project'); expect(metadata.workflowName).toBe('test-workflow'); expect(metadata.extensionBundleVersion).toBe('1.0.0'); expect(metadata.workflowDetails).toEqual({}); }); + + it('reads localSettings after getAzureConnectorDetailsForLocalProject to avoid stale data from wizard writes', async () => { + let azureDetailsResolved = false; + + vi.mocked(getAzureConnectorDetailsForLocalProject).mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + azureDetailsResolved = true; + resolve({ accessToken: 'token', enabled: true, subscriptionId: 'sub-1', tenantId: 'tenant-1' } as any); + }, 50); + }); + }); + vi.mocked(getLocalSettingsJson).mockImplementation(async () => { + expect(azureDetailsResolved).toBe(true); + return { Values: { WORKFLOWS_TENANT_ID: 'tenant-1', WORKFLOWS_SUBSCRIPTION_ID: 'sub-1' } } as any; + }); + + const instance = new LocalMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath); + await (instance as any).getDesignerPanelMetadata(); + + expect(getLocalSettingsJson).toHaveBeenCalled(); + }); }); describe('webview messages', () => { function createMessageHarness() { - const instance = new OpenMonitoringViewForLocal(mockContext, mockRunId, mockWorkflowFilePath); + const instance = new LocalMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath); + (instance as any).panel = { webview: { postMessage: vi.fn() } }; (instance as any).panelMetadata = {}; (instance as any).connectionData = {}; (instance as any).workflowDetails = {}; (instance as any).apiHubServiceDetails = {}; (instance as any).baseUrl = 'http://localhost:7071/admin'; (instance as any).oauthRedirectUrl = 'vscode://auth'; - (instance as any).sendMsgToWebview = vi.fn(); (instance as any).openContent = vi.fn(); return instance; } @@ -172,26 +193,26 @@ describe('OpenMonitoringViewForLocal', () => { it('handles monitoring webview commands', async () => { const instance = createMessageHarness(); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.initialize }); - await (instance as any)._handleWebviewMsg({ + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.initialize }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.showContent, header: 'Header', id: 'content-id', title: 'Title', content: 'Content', }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.resubmitRun }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.logTelemetry, data: { area: 'monitoringArea' } }); - await (instance as any)._handleWebviewMsg({ + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.resubmitRun }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.logTelemetry, data: { area: 'monitoringArea' } }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.createUnitTestFromRun, runId: 'run-123', definition: {}, }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.fileABug }); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.getDesignerVersion }); - await (instance as any)._handleWebviewMsg({ command: 'unknown' }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.fileABug }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.getDesignerVersion }); + await (instance as any).handleWebviewMsg({ command: 'unknown' }); - expect((instance as any).sendMsgToWebview).toHaveBeenCalledWith( + expect((instance as any).panel.webview.postMessage).toHaveBeenCalledWith( expect.objectContaining({ command: ExtensionCommand.initialize_frame }) ); expect((instance as any).openContent).toHaveBeenCalledWith('Header', 'content-id', 'Title', 'Content'); @@ -205,7 +226,7 @@ describe('OpenMonitoringViewForLocal', () => { expect(ext.telemetryReporter.sendTelemetryEvent).toHaveBeenCalledWith('monitoringArea', { area: 'monitoringArea' }); expect(createUnitTestFromRun).toHaveBeenCalledWith(expect.objectContaining({ fsPath: mockWorkflowFilePath }), 'run-123', {}); expect(openUrl).toHaveBeenCalledWith('https://github.com/Azure/LogicAppsUX/issues/new?template=bug_report.yml'); - expect((instance as any).sendMsgToWebview).toHaveBeenCalledWith( + expect((instance as any).panel.webview.postMessage).toHaveBeenCalledWith( expect.objectContaining({ command: ExtensionCommand.getDesignerVersion }) ); }); @@ -214,7 +235,7 @@ describe('OpenMonitoringViewForLocal', () => { const instance = createMessageHarness(); vi.mocked(promises.readFile).mockRejectedValueOnce(new Error('read failed')); - await (instance as any)._handleWebviewMsg({ command: ExtensionCommand.resubmitRun }); + await (instance as any).handleWebviewMsg({ command: ExtensionCommand.resubmitRun }); const vscode = await import('vscode'); expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Workflow run resubmit failed: read failed', 'OK'); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/__test__/openMonitoringViewForAzureResource.test.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/__test__/remoteMonitoringPanel.test.ts similarity index 76% rename from apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/__test__/openMonitoringViewForAzureResource.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/__test__/remoteMonitoringPanel.test.ts index 7e482081b7e..2b721c82a4e 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/__test__/openMonitoringViewForAzureResource.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/__test__/remoteMonitoringPanel.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as vscode from 'vscode'; -import { ext } from '../../../../../extensionVariables'; +import { ext } from '../../../../../../extensionVariables'; // Mock dependencies before importing the class -vi.mock('../../../../../localize', () => ({ +vi.mock('../../../../../../localize', () => ({ localize: (_key: string, defaultMsg: string) => defaultMsg, })); -vi.mock('../../../../utils/codeless/common', () => ({ +vi.mock('../../../../../utils/codeless/common', () => ({ tryGetWebviewPanel: vi.fn(), cacheWebviewPanel: vi.fn(), removeWebviewPanelFromCache: vi.fn(), @@ -15,7 +15,7 @@ vi.mock('../../../../utils/codeless/common', () => ({ getWorkflowManagementBaseURI: vi.fn(() => 'https://management.azure.com/test'), })); -vi.mock('../../../../utils/codeless/getWebViewHTML', () => ({ +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ getWebViewHTML: vi.fn().mockResolvedValue(''), })); @@ -27,16 +27,16 @@ vi.mock('@microsoft/logic-apps-shared', () => ({ HTTP_METHODS: { POST: 'POST', GET: 'GET' }, })); -vi.mock('../../../../utils/codeless/getAuthorizationToken', () => ({ +vi.mock('../../../../../utils/codeless/getAuthorizationToken', () => ({ getAuthorizationTokenFromNode: vi.fn().mockResolvedValue('mock-token'), })); -vi.mock('../../../../utils/requestUtils', () => ({ +vi.mock('../../../../../utils/requestUtils', () => ({ sendAzureRequest: vi.fn(), sendRequest: vi.fn(), })); -import openMonitoringViewForAzureResource from '../openMonitoringViewForAzureResource'; +import RemoteMonitoringPanel from '../remoteMonitoringPanel'; const createMockNode = () => ({ name: 'test-workflow', @@ -77,26 +77,26 @@ describe('openMonitoringViewForAzureResource', () => { describe('constructor', () => { it('should construct with correct parameters', () => { const mockNode = createMockNode(); - const instance = new openMonitoringViewForAzureResource(mockContext, mockRunId, mockWorkflowFilePath, mockNode as any); + const instance = new RemoteMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath, mockNode as any); expect(instance).toBeDefined(); }); }); - describe('createPanel', () => { + describe('create', () => { it('should reveal existing panel if one exists', async () => { - const { tryGetWebviewPanel } = await import('../../../../utils/codeless/common'); + const { tryGetWebviewPanel } = await import('../../../../../utils/codeless/common'); const mockReveal = vi.fn(); vi.mocked(tryGetWebviewPanel).mockReturnValue({ active: false, reveal: mockReveal } as any); const mockNode = createMockNode(); - const instance = new openMonitoringViewForAzureResource(mockContext, mockRunId, mockWorkflowFilePath, mockNode as any); - await instance.createPanel(); + const instance = new RemoteMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath, mockNode as any); + await instance.create(); expect(mockReveal).toHaveBeenCalled(); }); it('should create new panel with azureDetails including defaultHostName', async () => { - const { tryGetWebviewPanel, cacheWebviewPanel } = await import('../../../../utils/codeless/common'); + const { tryGetWebviewPanel, cacheWebviewPanel } = await import('../../../../../utils/codeless/common'); vi.mocked(tryGetWebviewPanel).mockReturnValue(undefined); const mockPostMessage = vi.fn(); @@ -109,8 +109,8 @@ describe('openMonitoringViewForAzureResource', () => { ext.context = { extensionPath: '/test', subscriptions: [] } as any; const mockNode = createMockNode(); - const instance = new openMonitoringViewForAzureResource(mockContext, mockRunId, mockWorkflowFilePath, mockNode as any); - await instance.createPanel(); + const instance = new RemoteMonitoringPanel(mockContext, mockRunId, mockWorkflowFilePath, mockNode as any); + await instance.create(); expect(cacheWebviewPanel).toHaveBeenCalled(); }); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/localMonitoringPanel.ts similarity index 81% rename from apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts rename to apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/localMonitoringPanel.ts index cd23a562252..2aa462df15f 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/localMonitoringPanel.ts @@ -2,40 +2,40 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { assetsFolderName, localSettingsFileName, managementApiPrefix, workflowCodefulEnabledKey } from '../../../../constants'; -import { ext } from '../../../../extensionVariables'; -import { localize } from '../../../../localize'; -import { getLocalSettingsJson } from '../../../utils/appSettings/localSettings'; +import { assetsFolderName, localSettingsFileName, managementApiPrefix } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; +import { getLocalSettingsJson } from '../../../../utils/appSettings/localSettings'; import { removeWebviewPanelFromCache, cacheWebviewPanel, getAzureConnectorDetailsForLocalProject, getStandardAppData, -} from '../../../utils/codeless/common'; +} from '../../../../utils/codeless/common'; import { getConnectionsFromFile, getCustomCodeFromFiles, getLogicAppProjectRoot, getParametersFromFile, -} from '../../../utils/codeless/connection'; -import { sendRequest } from '../../../utils/requestUtils'; -import { createUnitTestFromRun } from '../unitTest/createUnitTestFromRun'; -import { OpenMonitoringViewBase } from './openMonitoringViewBase'; +} from '../../../../utils/codeless/connection'; +import { sendRequest } from '../../../../utils/requestUtils'; +import { createUnitTestFromRun } from '../../unitTest/createUnitTestFromRun'; +import { MonitoringPanel } from './monitoringPanel'; import { getRunTriggerName, HTTP_METHODS } from '@microsoft/logic-apps-shared'; import { openUrl, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IDesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; +import type { FileDetails, DesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; import { promises, readFileSync } from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import type { WebviewPanel } from 'vscode'; import { Uri, ViewColumn } from 'vscode'; -import { getArtifactsInLocalProject } from '../../../utils/codeless/artifacts'; -import { getBundleVersionNumber } from '../../../utils/bundleFeed'; +import { getArtifactsInLocalProject } from '../../../../utils/codeless/artifacts'; +import { getBundleVersionNumber } from '../../../../utils/bundleFeed'; -export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { +export default class LocalMonitoringPanel extends MonitoringPanel { private projectPath: string | undefined; - private panelMetadata: IDesignerPanelMetadata; + private panelMetadata?: DesignerPanelMetadata; constructor(context: IActionContext, runId: string, workflowFilePath: string) { const apiVersion = '2019-10-01-edge-preview'; @@ -43,7 +43,7 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { super(context, runId, workflowFilePath, true, apiVersion); } - public async createPanel(): Promise { + public async create(): Promise { const existingPanel: WebviewPanel | undefined = this.getExistingPanel(); if (existingPanel) { @@ -51,7 +51,6 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { if (!existingPanel.active) { existingPanel.reveal(vscode.ViewColumn.Active); } - return; } @@ -75,10 +74,7 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { this.baseUrl = `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}`; // Fetch panel metadata which does all operations in parallel internally - this.panelMetadata = await this._getDesignerPanelMetadata(); - - // Reuse data from panelMetadata instead of fetching again - this.localSettings = this.panelMetadata.localSettings; + this.panelMetadata = await this.getDesignerPanelMetadata(); this.panel.webview.html = await this.getWebviewContent({ connectionsData: this.panelMetadata.connectionsData, @@ -87,11 +83,13 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { artifacts: this.panelMetadata.artifacts, azureDetails: this.panelMetadata.azureDetails, }); - this.panelMetadata.mapArtifacts = this.mapArtifacts; - this.panelMetadata.schemaArtifacts = this.schemaArtifacts; + + this.panelMetadata.mapArtifacts = this.mapArtifacts as Record; + this.panelMetadata.schemaArtifacts = this.schemaArtifacts as FileDetails[]; this.context.telemetry.properties.extensionBundleVersion = this.panelMetadata.extensionBundleVersion; + this.panel.webview.onDidReceiveMessage( - async (message) => await this._handleWebviewMsg(message), + async (message) => await this.handleWebviewMsg(message), /* thisArgs */ undefined, ext.context.subscriptions ); @@ -108,16 +106,15 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { ext.context.subscriptions.push(this.panel); } - private async _handleWebviewMsg(message: any) { + private async handleWebviewMsg(message: any) { switch (message.command) { case ExtensionCommand.initialize: { - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.initialize_frame, data: { project: ProjectName.designer, panelMetadata: this.panelMetadata, connectionData: this.connectionData, - workflowDetails: this.workflowDetails, oauthRedirectUrl: this.oauthRedirectUrl, baseUrl: this.baseUrl, apiVersion: this.apiVersion, @@ -153,7 +150,7 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { break; } case ExtensionCommand.getDesignerVersion: { - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.getDesignerVersion, data: this.getDesignerVersion(), }); @@ -188,21 +185,19 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { }); } - private async _getDesignerPanelMetadata(): Promise { + private async getDesignerPanelMetadata(): Promise { const projectPath: string | undefined = await getLogicAppProjectRoot(this.context, this.workflowFilePath); if (!projectPath) { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } - // Parallelize all file reads and API calls for better performance const [ connectionsData, parametersData, customCodeData, bundleVersionNumber, azureDetails, - localSettingsResult, artifacts, workflowContent, ] = await Promise.all([ @@ -211,12 +206,11 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { getCustomCodeFromFiles(this.workflowFilePath), getBundleVersionNumber(projectPath), getAzureConnectorDetailsForLocalProject(this.context, projectPath), - getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName)), getArtifactsInLocalProject(projectPath), - this._getWorkflowContent(), + this.getWorkflowContent(), ]); - const localSettings = localSettingsResult.Values; + const localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values!; return { panelId: this.panelName, @@ -231,13 +225,13 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { workflowDetails: {}, artifacts, standardApp: getStandardAppData(this.workflowName, { ...workflowContent, definition: {} }), - schemaArtifacts: this.schemaArtifacts, - mapArtifacts: this.mapArtifacts, + schemaArtifacts: this.schemaArtifacts as FileDetails[], + mapArtifacts: this.mapArtifacts as Record, extensionBundleVersion: bundleVersionNumber, }; } - private async _getWorkflowContent(): Promise { + private async getWorkflowContent(): Promise { if (!this.workflowFilePath.endsWith('.cs')) { return JSON.parse(readFileSync(this.workflowFilePath, 'utf8')); } diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewBase.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/monitoringPanel.ts similarity index 77% rename from apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewBase.ts rename to apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/monitoringPanel.ts index 74b1f888246..bb184941c0c 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewBase.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/monitoringPanel.ts @@ -2,22 +2,19 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ext } from '../../../../extensionVariables'; -import { tryGetWebviewPanel } from '../../../utils/codeless/common'; -import type { IAzureConnectorsContext } from '../azureConnectorWizard'; -import { OpenDesignerBase } from '../openDesigner/openDesignerBase'; +import { ext } from '../../../../../extensionVariables'; +import { tryGetWebviewPanel } from '../../../../utils/codeless/common'; +import { DesignerPanel } from '../../designer/panels/designerPanel'; import { openReadOnlyJson } from '@microsoft/vscode-azext-utils'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import type { WebviewPanel } from 'vscode'; import * as vscode from 'vscode'; -export abstract class OpenMonitoringViewBase extends OpenDesignerBase { - protected runName: string; +export abstract class MonitoringPanel extends DesignerPanel { protected workflowFilePath: string; - protected localSettings: Record; protected constructor( - context: IActionContext | IAzureConnectorsContext, + context: IActionContext, runId: string, workflowFilePath: string, isLocal: boolean, diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForAzureResource.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/remoteMonitoringPanel.ts similarity index 82% rename from apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForAzureResource.ts rename to apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/remoteMonitoringPanel.ts index 60783ed7aaf..3d9dceffa7f 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForAzureResource.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/remoteMonitoringPanel.ts @@ -3,41 +3,40 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import path from 'path'; -import { assetsFolderName, workflowAppApiVersion } from '../../../../constants'; -import { ext } from '../../../../extensionVariables'; -import { localize } from '../../../../localize'; -import type { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; +import { assetsFolderName, workflowAppApiVersion } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; +import type { RemoteWorkflowTreeItem } from '../../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; import { removeWebviewPanelFromCache, cacheWebviewPanel, getWorkflowManagementBaseURI, getStandardAppData, -} from '../../../utils/codeless/common'; -import { sendAzureRequest } from '../../../utils/requestUtils'; -import type { IAzureConnectorsContext } from '../azureConnectorWizard'; -import { OpenMonitoringViewBase } from './openMonitoringViewBase'; +} from '../../../../utils/codeless/common'; +import { sendAzureRequest } from '../../../../utils/requestUtils'; +import { MonitoringPanel } from './monitoringPanel'; import { getRunTriggerName, HTTP_METHODS } from '@microsoft/logic-apps-shared'; import { openUrl, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IDesignerPanelMetadata, IWorkflowFileContent } from '@microsoft/vscode-extension-logic-apps'; +import type { FileDetails, DesignerPanelMetadata, IWorkflowFileContent } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; import * as vscode from 'vscode'; import type { WebviewPanel } from 'vscode'; import { Uri, ViewColumn } from 'vscode'; -import { getAuthorizationTokenFromNode } from '../../../utils/codeless/getAuthorizationToken'; +import { getAuthorizationTokenFromNode } from '../../../../utils/codeless/getAuthorizationToken'; -export default class openMonitoringViewForAzureResource extends OpenMonitoringViewBase { +export default class RemoteMonitoringPanel extends MonitoringPanel { private node: RemoteWorkflowTreeItem; - private panelMetadata: IDesignerPanelMetadata; + private panelMetadata?: DesignerPanelMetadata; private readonly workflow: IWorkflowFileContent; - constructor(context: IAzureConnectorsContext | IActionContext, runId: string, workflowFilePath: string, node: RemoteWorkflowTreeItem) { + constructor(context: IActionContext, runId: string, workflowFilePath: string, node: RemoteWorkflowTreeItem) { super(context, runId, workflowFilePath, false, workflowAppApiVersion); this.node = node; this.workflow = node.workflowFileContent; } - public async createPanel(): Promise { + public async create(): Promise { const existingPanel: WebviewPanel | undefined = this.getExistingPanel(); if (existingPanel) { @@ -45,7 +44,6 @@ export default class openMonitoringViewForAzureResource extends OpenMonitoringVi if (!existingPanel.active) { existingPanel.reveal(vscode.ViewColumn.Active); } - return; } @@ -80,11 +78,11 @@ export default class openMonitoringViewForAzureResource extends OpenMonitoringVi artifacts: await this.node.getArtifacts(), }); - this.panelMetadata.mapArtifacts = this.mapArtifacts; - this.panelMetadata.schemaArtifacts = this.schemaArtifacts; + this.panelMetadata.mapArtifacts = this.mapArtifacts as Record; + this.panelMetadata.schemaArtifacts = this.schemaArtifacts as FileDetails[]; this.panel.webview.onDidReceiveMessage( - async (message) => await this._handleWebviewMsg(message), + async (message) => await this.handleWebviewMsg(message), /* thisArgs */ undefined, ext.context.subscriptions ); @@ -101,16 +99,15 @@ export default class openMonitoringViewForAzureResource extends OpenMonitoringVi ext.context.subscriptions.push(this.panel); } - private async _handleWebviewMsg(message: any) { + private async handleWebviewMsg(message: any) { switch (message.command) { case ExtensionCommand.initialize: { - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.initialize_frame, data: { project: ProjectName.designer, panelMetadata: this.panelMetadata, connectionData: this.connectionData, - workflowDetails: this.workflowDetails, oauthRedirectUrl: this.oauthRedirectUrl, baseUrl: this.baseUrl, apiVersion: this.apiVersion, @@ -142,7 +139,7 @@ export default class openMonitoringViewForAzureResource extends OpenMonitoringVi break; } case ExtensionCommand.getDesignerVersion: { - this.sendMsgToWebview({ + this.panel?.webview.postMessage({ command: ExtensionCommand.getDesignerVersion, data: this.getDesignerVersion(), }); @@ -175,7 +172,7 @@ export default class openMonitoringViewForAzureResource extends OpenMonitoringVi }); } - private async getDesignerPanelMetadata(): Promise { + private async getDesignerPanelMetadata(): Promise { const accessToken: string = await getAuthorizationTokenFromNode(this.node); return { @@ -197,8 +194,8 @@ export default class openMonitoringViewForAzureResource extends OpenMonitoringVi }, workflowName: this.workflowName, standardApp: getStandardAppData(this.workflowName, { ...this.workflow, definition: {} }), - schemaArtifacts: this.schemaArtifacts, - mapArtifacts: this.mapArtifacts, + schemaArtifacts: this.schemaArtifacts as FileDetails[], + mapArtifacts: this.mapArtifacts as Record, }; } } diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesigner.ts b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesigner.ts deleted file mode 100644 index b43f71752dc..00000000000 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesigner.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; -import { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; -import { getWorkflowNode } from '../../../utils/workspace'; -import type { IAzureConnectorsContext } from '../azureConnectorWizard'; -import { OpenDesignerForAzureResource } from './openDesignerForAzureResource'; -import OpenDesignerForLocalProject from './openDesignerForLocalProject'; -import { Uri } from 'vscode'; -import { tryBuildCustomCodeFunctionsProject } from '../../buildCustomCodeFunctionsProject'; -import { customCodeArtifactsExist } from '../../../utils/customCodeUtils'; - -export async function openDesigner(context: IAzureConnectorsContext, node: Uri | RemoteWorkflowTreeItem | undefined): Promise { - let openDesignerObj: OpenDesignerForLocalProject | OpenDesignerForAzureResource; - - const workflowNode = getWorkflowNode(node); - - if (workflowNode instanceof Uri) { - const logicAppNode = Uri.file(path.join(workflowNode.fsPath, '../../')); - // Only build custom code projects on open designer if custom code binaries don't already exist in the logic app folder - if (!(await customCodeArtifactsExist(logicAppNode.fsPath))) { - await tryBuildCustomCodeFunctionsProject(context, logicAppNode); - } - - openDesignerObj = new OpenDesignerForLocalProject(context, workflowNode); - } else if (workflowNode instanceof RemoteWorkflowTreeItem) { - openDesignerObj = new OpenDesignerForAzureResource(context, workflowNode); - } - - await openDesignerObj?.createPanel(); -} diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringView.ts b/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringView.ts deleted file mode 100644 index 7aec85c40f6..00000000000 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringView.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import type { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; -import type { IAzureConnectorsContext } from '../azureConnectorWizard'; -import openMonitoringViewForAzureResource from './openMonitoringViewForAzureResource'; -import OpenMonitoringViewForLocal from './openMonitoringViewForLocal'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; -import { Uri } from 'vscode'; - -export async function openMonitoringView( - context: IActionContext | IAzureConnectorsContext, - node: Uri | RemoteWorkflowTreeItem | undefined, - runId: string, - workflowFilePath: string -): Promise { - let monitoringView: OpenMonitoringViewForLocal | openMonitoringViewForAzureResource; - let monitoringViewTelemetry = ''; - - if (node instanceof Uri) { - monitoringView = new OpenMonitoringViewForLocal(context, runId, workflowFilePath); - monitoringViewTelemetry = 'Local'; - } else { - monitoringView = new openMonitoringViewForAzureResource(context, runId, workflowFilePath, node as RemoteWorkflowTreeItem); - monitoringViewTelemetry = 'ForAzureResource'; - } - - await callWithTelemetryAndErrorHandling(`logicAppsExtension.openMonitoringView${monitoringViewTelemetry}`, async () => { - await monitoringView?.createPanel(); - }); -} diff --git a/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts b/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts deleted file mode 100644 index 432b92ea370..00000000000 --- a/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts +++ /dev/null @@ -1,880 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import type { LogicAppsV2 } from '@microsoft/logic-apps-shared'; -import { getRequestTriggerName, getTriggerName, HTTP_METHODS, isNullOrUndefined } from '@microsoft/logic-apps-shared'; -import { - assetsFolderName, - localSettingsFileName, - managementApiPrefix, - workflowAppApiVersion, - workflowCodefulEnabledKey, - workflowTenantIdKey, -} from '../../../constants'; -import { ext } from '../../../extensionVariables'; -import { localize } from '../../../localize'; -import { RemoteWorkflowTreeItem } from '../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; -import { getLocalSettingsJson } from '../../utils/appSettings/localSettings'; -import { - cacheWebviewPanel, - getAzureConnectorDetailsForLocalProject, - getStandardAppData, - getWorkflowManagementBaseURI, - removeWebviewPanelFromCache, - tryGetWebviewPanel, -} from '../../utils/codeless/common'; -import { getConnectionsJson, getLogicAppProjectRoot } from '../../utils/codeless/connection'; -import { getAuthorizationToken, getAuthorizationTokenFromNode } from '../../utils/codeless/getAuthorizationToken'; -import { getWebViewHTML } from '../../utils/codeless/getWebViewHTML'; -import { sendRequest } from '../../utils/requestUtils'; -import { getWorkflowNode } from '../../utils/workspace'; -import type { IAzureConnectorsContext } from './azureConnectorWizard'; -import { openMonitoringView } from './openMonitoringView/openMonitoringView'; -import { shouldUpdateOverviewCallbackInfo } from './overviewCallbackInfo'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { AzureConnectorDetails, ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; -import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; -import { readFileSync, readdirSync } from 'fs'; -import { basename, dirname, join } from 'path'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import { launchProjectDebugger } from '../../utils/vsCodeConfig/launch'; -import { isRuntimeUp } from '../../utils/startRuntimeApi'; -import { delay } from '../../utils/delay'; -import { detectCodefulWorkflow, extractTriggerNameFromCodeful, extractHttpTriggerName, hasHttpRequestTrigger } from '../../utils/codeful'; -import { getCodefulWorkflowMetadata } from '../../languageServer/languageServer'; - -interface CodefulWorkflowData { - workflowName: string; - workflowKind: string; - triggerName?: string; - triggerType?: string; - triggerKind?: string; -} - -interface CodefulWorkflowDataResult { - workflows: CodefulWorkflowData[]; - fromRuntime: boolean; -} - -interface CodefulTriggerData { - triggerName?: string; - triggerType?: string; - triggerKind?: string; -} - -interface CallbackInfoUpdate { - workflowName: string; - callbackInfo?: ICallbackUrlResponse; -} - -interface OverviewWorkflowProperties { - name: string; - stateType: string; - operationOptions?: string; - statelessRunMode?: string; - callbackInfo?: ICallbackUrlResponse; - triggerName?: string; - definition: LogicAppsV2.WorkflowDefinition; - kind?: string; -} - -// TODO(aeldridge): We should split into remote and local open overview -export async function openOverview(context: IAzureConnectorsContext, node: vscode.Uri | RemoteWorkflowTreeItem | undefined): Promise { - let workflowFilePath: string; - let workflowName = ''; - let workflowContent: any; - let baseUrl: string | undefined; - let getBaseUrl: () => string | undefined; - let apiVersion: string; - let accessToken: string; - let getAccessToken: () => Promise; - let isLocal: boolean; - let callbackInfo: ICallbackUrlResponse | undefined; - let getCallbackInfo: ((baseUrl: string) => Promise) | undefined; - let getCodefulCallbackInfoUpdates: ((baseUrl: string) => Promise) | undefined; - let panelName = ''; - let panelTitle = ''; - let corsNotice: string | undefined; - let localSettings: Record = {}; - let connectionData: Record = {}; - let azureDetails: AzureConnectorDetails; - let triggerName: string | undefined; - let workflowProps: OverviewWorkflowProperties | undefined; - let workflowPropertiesList: OverviewWorkflowProperties[] | undefined; - let isCodefulOverview = false; - let codefulWorkflowFileContent = ''; - let isCodefulRuntimeMetadataConfirmed = false; - let workflowPropertiesListSignature = ''; - const workflowNode = getWorkflowNode(node); - const panelGroupKey = ext.webViewKey.overview; - - if (workflowNode instanceof vscode.Uri) { - workflowFilePath = workflowNode.fsPath; - - const projectPath = await getLogicAppProjectRoot(context, workflowFilePath); - if (!isNullOrUndefined(projectPath) && !(await isRuntimeUp(ext.workflowRuntimePort))) { - await launchProjectDebugger(context, projectPath); - } - getBaseUrl = () => (ext.workflowRuntimePort ? `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}` : undefined); - baseUrl = getBaseUrl?.(); - apiVersion = '2019-10-01-edge-preview'; - isLocal = true; - if (!baseUrl) { - ext.outputChannel.appendLog( - localize( - 'overviewCallbackUrlUnavailable', - 'Callback URL is not available because the workflow runtime is not running. Start debugging or run "func host start" to enable the Run Trigger button.' - ) - ); - } - - localSettings = projectPath ? (await getLocalSettingsJson(context, join(projectPath, localSettingsFileName))).Values || {} : {}; - - if (workflowFilePath.endsWith('.cs')) { - isCodefulOverview = true; - const fileContent = readFileSync(workflowFilePath, 'utf8'); - codefulWorkflowFileContent = fileContent; - const codefulWorkflowData = await getCodefulWorkflowDataList(context, workflowFilePath, fileContent, baseUrl, apiVersion); - const codefulWorkflows = codefulWorkflowData.workflows; - isCodefulRuntimeMetadataConfirmed = codefulWorkflowData.fromRuntime; - if (codefulWorkflows.length === 0) { - throw new Error(localize('noCodefulWorkflowsFound', 'No codeful workflows were found in this project.')); - } - - workflowPropertiesList = await createCodefulWorkflowPropertiesList( - context, - codefulWorkflows, - workflowFilePath, - fileContent, - baseUrl, - apiVersion, - localSettings - ); - workflowPropertiesListSignature = getWorkflowPropertiesListSignature(workflowPropertiesList); - - workflowProps = workflowPropertiesList[0]; - workflowName = workflowProps.name; - triggerName = workflowProps.triggerName; - callbackInfo = workflowProps.callbackInfo; - workflowContent = createCodefulWorkflowContent( - { - workflowName: workflowProps.name, - workflowKind: workflowProps.kind ?? 'Stateful', - triggerName: workflowProps.triggerName, - }, - workflowProps.triggerName, - getCodefulWorkflowHasHttpTrigger(workflowProps) - ); - getCodefulCallbackInfoUpdates = async (baseUrl: string) => - await Promise.all( - (workflowPropertiesList ?? []).map(async (workflow) => ({ - workflowName: workflow.name, - callbackInfo: workflow.triggerName - ? await getCodefulWorkflowCallbackInfo( - context, - baseUrl, - workflow.name, - workflow.triggerName, - apiVersion, - getCodefulWorkflowHasHttpTrigger(workflow) - ) - : undefined, - })) - ); - } else { - // Codeless workflow - workflowName = basename(dirname(workflowFilePath)); - workflowContent = JSON.parse(readFileSync(workflowFilePath, 'utf8')); - triggerName = getTriggerName(workflowContent.definition); - getCallbackInfo = async (baseUrl: string) => - await getLocalWorkflowCallbackInfo(context, workflowContent.definition, baseUrl, workflowName, triggerName, apiVersion); - callbackInfo = baseUrl ? await getCallbackInfo(baseUrl) : undefined; - } - - const projectName = projectPath ? basename(projectPath) : basename(dirname(workflowFilePath)); - panelName = isCodefulOverview - ? `${vscode.workspace.name}-${projectName}-codeful-overview` - : `${vscode.workspace.name}-${workflowName}-overview`; - panelTitle = isCodefulOverview ? `${projectName}-overview` : `${workflowName}-overview`; - getAccessToken = async () => await getAuthorizationToken(localSettings[workflowTenantIdKey]); - accessToken = await getAccessToken(); - if (projectPath) { - azureDetails = await getAzureConnectorDetailsForLocalProject(context, projectPath); - const connectionJson = await getConnectionsJson(projectPath); - connectionData = connectionJson ? JSON.parse(connectionJson) : {}; - } - } else if (workflowNode instanceof RemoteWorkflowTreeItem) { - workflowName = workflowNode.name; - panelName = `${workflowNode.id}-${workflowName}-overview`; - panelTitle = `${workflowName}-overview`; - workflowContent = workflowNode.workflowFileContent; - getAccessToken = async () => await getAuthorizationTokenFromNode(workflowNode); - getBaseUrl = () => getWorkflowManagementBaseURI(workflowNode); - baseUrl = getBaseUrl?.(); - apiVersion = workflowAppApiVersion; - triggerName = getTriggerName(workflowContent.definition); - getCallbackInfo = async (baseUrl: string) => await workflowNode.getCallbackUrl(workflowNode, baseUrl, triggerName, apiVersion); - callbackInfo = await getCallbackInfo(baseUrl); - corsNotice = localize('CorsNotice', 'To view runs, set "*" to allowed origins in the CORS setting.'); - isLocal = false; - accessToken = await getAccessToken(); - azureDetails = { - enabled: true, - accessToken, - subscriptionId: workflowNode?.subscription?.subscriptionId, - location: normalizeLocation(workflowNode?.parent?.parent?.site.location), - workflowManagementBaseUrl: workflowNode?.parent?.subscription?.environment?.resourceManagerEndpointUrl, - tenantId: workflowNode?.parent?.subscription?.tenantId, - resourceGroupName: workflowNode?.parent?.parent?.site.resourceGroup, - }; - connectionData = {}; - } else { - throw new Error(localize('noWorkflowNode', 'No workflow node provided.')); - } - - const existingPanel: vscode.WebviewPanel | undefined = tryGetWebviewPanel(panelGroupKey, panelName); - - if (existingPanel) { - if (!existingPanel.active) { - existingPanel.reveal(vscode.ViewColumn.Active); - } - return; - } - - const options: vscode.WebviewOptions & vscode.WebviewPanelOptions = { - enableScripts: true, - retainContextWhenHidden: true, - }; - const { name, kind, operationOptions, statelessRunMode } = getStandardAppData(workflowName, workflowContent); - workflowProps ??= { - name, - stateType: getWorkflowStateType(name, kind, localSettings), - operationOptions, - statelessRunMode, - callbackInfo, - triggerName, - definition: workflowContent.definition, - kind, - }; - - const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel( - 'workflowOverview', - panelTitle || `${workflowName}-overview`, - vscode.ViewColumn.Active, - options - ); - - panel.iconPath = { - light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'Codeless.svg')), - dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'Codeless.svg')), - }; - - panel.webview.html = await getWebViewHTML('vs-code-react', panel); - - let accessTokenInterval: NodeJS.Timeout; - let baseUrlInterval: NodeJS.Timeout; - panel.webview.onDidReceiveMessage(async (message) => { - switch (message.command) { - case ExtensionCommand.loadRun: { - openMonitoringView(context, workflowNode, message.item.id, workflowFilePath); - break; - } - case ExtensionCommand.initialize: { - panel.webview.postMessage({ - command: ExtensionCommand.initialize_frame, - data: { - apiVersion: apiVersion, - baseUrl: baseUrl, - corsNotice, - accessToken: accessToken, - workflowProperties: workflowProps, - workflowPropertiesList, - project: ProjectName.overview, - hostVersion: ext.extensionVersion, - isLocal: isLocal, - azureDetails: azureDetails, - kind: workflowProps.kind ?? kind, - isCodeful: isCodefulOverview, - connectionData: connectionData, - }, - }); - - // Just shipping the access Token every 5 seconds is easier and more - // performant that asking for it every time and waiting. - accessTokenInterval = setInterval(async () => { - const updatedAccessToken = await getAccessToken(); - - if (updatedAccessToken !== accessToken) { - accessToken = updatedAccessToken; - panel.webview.postMessage({ - command: ExtensionCommand.update_access_token, - data: { - accessToken, - }, - }); - } - }, 5000); - - let lastCheckedBaseUrl: string | undefined; - let consecutiveCallbackErrors = 0; - const MAX_CALLBACK_ERRORS = 3; - - baseUrlInterval = setInterval(async () => { - const updatedBaseUrl = getBaseUrl(); - - // Only process if baseUrl changed - if (updatedBaseUrl !== baseUrl) { - baseUrl = updatedBaseUrl; - lastCheckedBaseUrl = baseUrl; - panel.webview.postMessage({ - command: ExtensionCommand.update_runtime_base_url, - data: { - baseUrl, - }, - }); - // Reset error count when baseUrl changes - consecutiveCallbackErrors = 0; - } - - if (isCodefulOverview && baseUrl && !isCodefulRuntimeMetadataConfirmed) { - const runtimeWorkflows = await getRuntimeCodefulWorkflows(context, baseUrl, apiVersion); - if (runtimeWorkflows.length > 0) { - const refreshedWorkflowPropertiesList = await createCodefulWorkflowPropertiesList( - context, - runtimeWorkflows, - workflowFilePath, - codefulWorkflowFileContent, - baseUrl, - apiVersion, - localSettings - ); - const refreshedSignature = getWorkflowPropertiesListSignature(refreshedWorkflowPropertiesList); - isCodefulRuntimeMetadataConfirmed = true; - - if (refreshedSignature !== workflowPropertiesListSignature) { - workflowPropertiesList = refreshedWorkflowPropertiesList; - workflowProps = workflowPropertiesList[0]; - workflowName = workflowProps.name; - triggerName = workflowProps.triggerName; - callbackInfo = workflowProps.callbackInfo; - workflowContent = createCodefulWorkflowContent( - { - workflowName: workflowProps.name, - workflowKind: workflowProps.kind ?? 'Stateful', - triggerName: workflowProps.triggerName, - }, - workflowProps.triggerName, - getCodefulWorkflowHasHttpTrigger(workflowProps) - ); - workflowPropertiesListSignature = refreshedSignature; - panel.webview.postMessage({ - command: ExtensionCommand.update_workflow_properties, - data: { - workflowProperties: workflowProps, - workflowPropertiesList, - kind: workflowProps.kind, - }, - }); - } - } - } - - // Only fetch callback info when baseUrl changes or we haven't fetched yet - if ( - baseUrl && - (lastCheckedBaseUrl !== baseUrl || (callbackInfo === undefined && consecutiveCallbackErrors < MAX_CALLBACK_ERRORS)) - ) { - lastCheckedBaseUrl = baseUrl; - try { - if (isCodefulOverview && getCodefulCallbackInfoUpdates) { - const callbackInfoUpdates = await getCodefulCallbackInfoUpdates(baseUrl); - for (const update of callbackInfoUpdates) { - const workflowProperty = workflowPropertiesList?.find((workflow) => workflow.name === update.workflowName); - if ( - update.callbackInfo?.value !== workflowProperty?.callbackInfo?.value || - update.callbackInfo?.basePath !== workflowProperty?.callbackInfo?.basePath - ) { - if (workflowProperty) { - workflowProperty.callbackInfo = update.callbackInfo; - } - panel.webview.postMessage({ - command: ExtensionCommand.update_callback_info, - data: { - workflowName: update.workflowName, - callbackInfo: update.callbackInfo, - }, - }); - } - } - callbackInfo = workflowPropertiesList?.[0]?.callbackInfo; - } else if (getCallbackInfo) { - const updatedCallbackInfo = await getCallbackInfo(baseUrl); - if (shouldUpdateOverviewCallbackInfo(callbackInfo, updatedCallbackInfo)) { - callbackInfo = updatedCallbackInfo; - panel.webview.postMessage({ - command: ExtensionCommand.update_callback_info, - data: { - callbackInfo, - }, - }); - } - } - // Reset error count on success - consecutiveCallbackErrors = 0; - } catch { - consecutiveCallbackErrors++; - if (consecutiveCallbackErrors >= MAX_CALLBACK_ERRORS) { - ext.outputChannel.appendLog( - `Stopped fetching callback URL after ${MAX_CALLBACK_ERRORS} consecutive errors. Trigger may not exist for workflow '${workflowName}'.` - ); - } - } - } - }, 5000); - - break; - } - default: - break; - } - }, ext.context.subscriptions); - - panel.onDidDispose( - () => { - removeWebviewPanelFromCache(panelGroupKey, panelName); - clearInterval(accessTokenInterval); - clearInterval(baseUrlInterval); - }, - null, - ext.context.subscriptions - ); - cacheWebviewPanel(panelGroupKey, panelName, panel); -} - -async function getLocalWorkflowCallbackInfo( - context: IActionContext, - definition: LogicAppsV2.WorkflowDefinition, - baseUrl: string, - workflowName: string, - triggerName: string, - apiVersion: string -): Promise { - const requestTriggerName = getRequestTriggerName(definition); - if (requestTriggerName) { - if (baseUrl) { - try { - const url = `${baseUrl}/workflows/${workflowName}/triggers/${requestTriggerName}/listCallbackUrl?api-version=${apiVersion}`; - const response: string = await sendRequest(context, { - url, - method: HTTP_METHODS.POST, - }); - return JSON.parse(response); - } catch (error) { - // API call failed, log error and return undefined - ext.outputChannel.appendLog( - localize( - 'callbackUrlApiFailed', - 'Failed to get callback URL for workflow "{0}": {1}', - workflowName, - error instanceof Error ? error.message : String(error) - ) - ); - return undefined; - } - } - } else { - // For non-request triggers, provide the run endpoint - const fallbackBaseUrl = baseUrl || `http://localhost:7071${managementApiPrefix}`; - return { - value: `${fallbackBaseUrl}/workflows/${workflowName}/triggers/${triggerName}/run?api-version=${apiVersion}`, - method: HTTP_METHODS.POST, - }; - } -} - -async function getCodefulWorkflowCallbackInfo( - context: IActionContext, - baseUrl: string, - workflowName: string, - triggerName: string, - apiVersion: string, - hasRequestTrigger: boolean -): Promise { - // For HTTP request triggers, try to get the callback URL from the API - if (hasRequestTrigger) { - if (!baseUrl) { - ext.outputChannel.appendLog( - localize( - 'codefulCallbackUrlNoBaseUrl', - 'Cannot get callback URL for codeful workflow "{0}" with request trigger: baseUrl is not available. Make sure the workflow runtime is running.', - workflowName - ) - ); - return undefined; - } - - const url = `${baseUrl}/workflows/${workflowName}/triggers/${triggerName}/listCallbackUrl?api-version=${apiVersion}`; - try { - const response: string = await sendRequest(context, { - url, - method: HTTP_METHODS.POST, - }); - return JSON.parse(response); - } catch (error) { - ext.outputChannel.appendLog( - localize( - 'codefulCallbackUrlApiFailed', - 'Falling back to CodefulWorkflowHttpTrigger URL for codeful workflow "{0}" trigger "{1}" (listCallbackUrl failed: {2}).', - workflowName, - triggerName, - error instanceof Error ? error.message : String(error) - ) - ); - // Fall back to the CodefulWorkflowHttpTrigger endpoint pattern - return getCodefulHttpTriggerCallbackUrl(baseUrl, workflowName, triggerName); - } - } - - // For non-request triggers, provide the run endpoint - const fallbackBaseUrl = baseUrl || `http://localhost:7071${managementApiPrefix}`; - return { - value: `${fallbackBaseUrl}/workflows/${workflowName}/triggers/${triggerName}/run?api-version=${apiVersion}`, - method: HTTP_METHODS.POST, - }; -} - -/** - * Constructs a callback URL using the CodefulWorkflowHttpTrigger endpoint pattern. - * This is used as a fallback when the listCallbackUrl management API is not available. - */ -function getCodefulHttpTriggerCallbackUrl(baseUrl: string, workflowName: string, triggerName: string): ICallbackUrlResponse { - // Extract the origin (e.g., http://localhost:7071) from the management base URL - const origin = baseUrl.split(managementApiPrefix)[0] || baseUrl.replace(/\/runtime\/webhooks\/workflow\/api\/management$/, ''); - return { - value: `${origin}/api/CodefulWorkflowHttpTrigger/scaleUnits/prod-00/workflows/${workflowName}/triggers/${triggerName}/invoke`, - method: HTTP_METHODS.POST, - }; -} - -async function createCodefulWorkflowPropertiesList( - context: IActionContext, - codefulWorkflows: CodefulWorkflowData[], - workflowFilePath: string, - workflowContent: string, - baseUrl: string | undefined, - apiVersion: string, - localSettings: Record -): Promise { - return await Promise.all( - codefulWorkflows.map(async (workflowData) => { - const triggerData = - baseUrl && (!workflowData.triggerName || !workflowData.triggerType) - ? await getCodefulTriggerData(context, workflowData.workflowName, baseUrl, apiVersion).catch(() => undefined) - : undefined; - const resolvedWorkflowData: CodefulWorkflowData = { - ...workflowData, - triggerName: workflowData.triggerName ?? triggerData?.triggerName, - triggerType: workflowData.triggerType ?? triggerData?.triggerType, - triggerKind: workflowData.triggerKind ?? triggerData?.triggerKind, - }; - const hasHttpTrigger = isHttpRequestTrigger(resolvedWorkflowData, workflowContent); - // Priority: runtime trigger data → LSP server → source-code regex fallback - const workflowTriggerName = - resolvedWorkflowData.triggerName ?? - (await getCodefulWorkflowMetadata(workflowFilePath) - .then((metadata) => metadata?.triggerName) - .catch(() => undefined)) ?? - getFallbackCodefulTriggerName(workflowContent, hasHttpTrigger); - const codefulWorkflowContent = createCodefulWorkflowContent(resolvedWorkflowData, workflowTriggerName, hasHttpTrigger); - const codefulCallbackInfo = - baseUrl && workflowTriggerName - ? await getCodefulWorkflowCallbackInfo( - context, - baseUrl, - resolvedWorkflowData.workflowName, - workflowTriggerName, - apiVersion, - hasHttpTrigger - ) - : undefined; - - return createWorkflowProperties( - resolvedWorkflowData.workflowName, - codefulWorkflowContent, - localSettings, - codefulCallbackInfo, - workflowTriggerName - ); - }) - ); -} - -async function getCodefulWorkflowDataList( - context: IActionContext, - workflowFilePath: string, - workflowContent: string, - baseUrl: string | undefined, - apiVersion: string -): Promise { - if (baseUrl) { - const runtimeWorkflows = await getRuntimeCodefulWorkflows(context, baseUrl, apiVersion); - if (runtimeWorkflows.length > 0) { - return { - workflows: runtimeWorkflows, - fromRuntime: true, - }; - } - } - - const hasHttpTrigger = hasHttpRequestTrigger(workflowContent); - const fallbackTriggerName = getFallbackCodefulTriggerName(workflowContent, hasHttpTrigger); - const workflowNames = getCodefulWorkflowNames(workflowFilePath); - if (workflowNames.length > 0) { - return { - workflows: workflowNames.map((workflowName) => ({ - workflowName, - workflowKind: 'Stateful', - triggerName: fallbackTriggerName, - triggerType: hasHttpTrigger ? 'Request' : undefined, - triggerKind: hasHttpTrigger ? 'Http' : undefined, - })), - fromRuntime: false, - }; - } - - const workflowInfo = detectCodefulWorkflow(workflowContent); - return { - workflows: workflowInfo - ? [ - { - workflowName: workflowInfo.workflowName, - workflowKind: workflowInfo.workflowType === 'agent' ? 'Agent' : 'Stateful', - triggerName: fallbackTriggerName, - triggerType: hasHttpTrigger ? 'Request' : undefined, - triggerKind: hasHttpTrigger ? 'Http' : undefined, - }, - ] - : [], - fromRuntime: false, - }; -} - -async function getRuntimeCodefulWorkflows(context: IActionContext, baseUrl: string, apiVersion: string): Promise { - const workflowsUrl = `${baseUrl}/workflows?api-version=${apiVersion}`; - const maxRetries = 4; - const initialDelayMs = 1000; - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - const workflowsResponse = await sendRequest(context, { - url: workflowsUrl, - method: HTTP_METHODS.GET, - }); - const parsed = JSON.parse(workflowsResponse); - const workflows: { - name: string; - kind?: string; - triggers?: Record; - }[] = Array.isArray(parsed) ? parsed : (parsed?.value ?? []); - - if (workflows.length > 0) { - return workflows.map((workflow) => { - const [runtimeTriggerName, trigger] = Object.entries(workflow.triggers ?? {})[0] ?? []; - return { - workflowName: workflow.name, - workflowKind: workflow.kind ?? 'Stateful', - triggerName: runtimeTriggerName, - triggerType: trigger?.properties?.type ?? trigger?.type, - triggerKind: trigger?.properties?.kind ?? trigger?.kind, - }; - }); - } - } catch (error) { - if (attempt === maxRetries - 1) { - ext.outputChannel.appendLog( - localize( - 'codefulWorkflowListApiFailed', - 'Failed to get codeful workflows from the runtime at "{0}": {1}', - workflowsUrl, - error instanceof Error ? error.message : String(error) - ) - ); - } - } - - if (attempt < maxRetries - 1) { - await delay(initialDelayMs * 2 ** attempt); - } - } - - return []; -} - -function getCodefulWorkflowNames(filePath: string): string[] { - const workflowNames: string[] = []; - const visitedFiles = new Set(); - const projectDir = dirname(filePath); - - const extractWorkflowsFromFile = (currentFilePath: string): void => { - if (visitedFiles.has(currentFilePath)) { - return; - } - visitedFiles.add(currentFilePath); - - try { - const fileContent = readFileSync(currentFilePath, 'utf8'); - const workflowRegex = /(?:CreateConversationalAgent|CreateAgentWorkflow|CreateStatefulWorkflow)\s*\(\s*["']([^"']+)["']/g; - let match: RegExpExecArray | null; - while ((match = workflowRegex.exec(fileContent)) !== null) { - const workflowName = match[1]; - if (workflowName && !workflowNames.includes(workflowName)) { - workflowNames.push(workflowName); - } - } - - const files = readdirSync(projectDir); - for (const file of files) { - if (file.endsWith('.cs') && file !== basename(currentFilePath)) { - extractWorkflowsFromFile(join(projectDir, file)); - } - } - } catch (error) { - ext.outputChannel.appendLog( - localize( - 'codefulWorkflowNameParseFailed', - 'Failed to parse codeful workflow names from "{0}": {1}', - currentFilePath, - error instanceof Error ? error.message : String(error) - ) - ); - } - }; - - extractWorkflowsFromFile(filePath); - return workflowNames; -} - -function getFallbackCodefulTriggerName(workflowContent: string, hasHttpTrigger: boolean): string | undefined { - return hasHttpTrigger ? extractHttpTriggerName(workflowContent) : extractTriggerNameFromCodeful(workflowContent); -} - -function isHttpRequestTrigger(workflowData: CodefulWorkflowData, workflowContent?: string): boolean { - const triggerType = workflowData.triggerType?.toLowerCase(); - const triggerKind = workflowData.triggerKind?.toLowerCase(); - if (triggerType === 'request') { - return !triggerKind || triggerKind === 'http'; - } - - if (triggerKind === 'http') { - return true; - } - - return workflowContent ? hasHttpRequestTrigger(workflowContent) : false; -} - -function getCodefulWorkflowHasHttpTrigger(workflowProperties: OverviewWorkflowProperties): boolean { - const trigger = workflowProperties.triggerName ? workflowProperties.definition?.triggers?.[workflowProperties.triggerName] : undefined; - return trigger?.type?.toLowerCase() === 'request' && trigger?.kind?.toLowerCase() === 'http'; -} - -function createCodefulWorkflowContent(workflowData: CodefulWorkflowData, triggerName: string | undefined, hasHttpTrigger: boolean): any { - return { - definition: { - $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', - contentVersion: '1.0.0.0', - triggers: triggerName - ? { - [triggerName]: hasHttpTrigger - ? { - type: 'Request', - kind: 'Http', - inputs: { - schema: {}, - }, - } - : { - type: 'Unknown', - }, - } - : {}, - actions: {}, - outputs: {}, - }, - kind: workflowData.workflowKind ?? 'Stateful', - }; -} - -function createWorkflowProperties( - workflowName: string, - workflowContent: any, - localSettings: Record, - callbackInfo: ICallbackUrlResponse | undefined, - triggerName: string | undefined -): OverviewWorkflowProperties { - const { name, kind, operationOptions, statelessRunMode } = getStandardAppData(workflowName, workflowContent); - return { - name, - stateType: getWorkflowStateType(name, kind, localSettings), - operationOptions, - statelessRunMode, - callbackInfo, - triggerName, - definition: workflowContent.definition, - kind, - }; -} - -function getWorkflowPropertiesListSignature(workflowPropertiesList: OverviewWorkflowProperties[] | undefined): string { - return JSON.stringify( - (workflowPropertiesList ?? []).map((workflowProperties) => ({ - name: workflowProperties.name, - kind: workflowProperties.kind, - triggerName: workflowProperties.triggerName, - callbackUrl: workflowProperties.callbackInfo?.value, - })) - ); -} - -function normalizeLocation(location: string): string { - if (!location) { - return ''; - } - return location.toLowerCase().replace(/ /g, ''); -} - -function getWorkflowStateType(workflowName: string, kind: string, settings: Record): string { - const operationOptionsSetting = `Workflows.${workflowName}.OperationOptions`; - const flowKindLower = kind?.toLowerCase(); - return flowKindLower === 'stateful' - ? localize('logicapps.stateful', 'Stateful') - : flowKindLower === 'agent' - ? localize('logicapps.agent', 'Agent') - : settings[operationOptionsSetting]?.toLowerCase() === 'withstatelessrunhistory' - ? localize('logicapps.statelessDebug', 'Stateless (debug mode)') - : localize('logicapps.stateless', 'Stateless'); -} - -async function getCodefulTriggerData( - context: IActionContext, - workflowName: string, - baseUrl: string, - apiVersion: string -): Promise { - const triggersUrl = `${baseUrl}/workflows/${workflowName}/triggers?api-version=${apiVersion}`; - const response: string = await sendRequest(context, { - url: triggersUrl, - method: HTTP_METHODS.GET, - }); - const triggersData = JSON.parse(response); - - if (triggersData?.value?.length > 0) { - const trigger = triggersData.value[0]; - return { - triggerName: trigger.name, - triggerType: trigger.properties?.type ?? trigger.type, - triggerKind: trigger.properties?.kind ?? trigger.kind, - }; - } -} diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/__test__/openOverview.test.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/__test__/openOverview.test.ts new file mode 100644 index 00000000000..31a522e3645 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/__test__/openOverview.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as vscode from 'vscode'; + +const mocks = vi.hoisted(() => { + class MockUri { + public fsPath: string; + public constructor(fsPath: string) { + this.fsPath = fsPath; + } + public static file(fsPath: string): MockUri { + return new MockUri(fsPath); + } + public toString(): string { + return this.fsPath; + } + } + + class MockRemoteWorkflowTreeItem {} + + return { + MockUri, + MockRemoteWorkflowTreeItem, + getWorkflowNode: vi.fn((node: any) => node), + localCreate: vi.fn().mockResolvedValue(undefined), + codefulCreate: vi.fn().mockResolvedValue(undefined), + remoteCreate: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('vscode', () => ({ + Uri: mocks.MockUri, +})); + +vi.mock('../../../../../extensionVariables', () => ({ + ext: { + outputChannel: { appendLog: vi.fn() }, + }, +})); + +vi.mock('../../../../../localize', () => ({ + localize: (_key: string, defaultMsg: string) => defaultMsg, +})); + +vi.mock('../../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem', () => ({ + RemoteWorkflowTreeItem: mocks.MockRemoteWorkflowTreeItem, +})); + +vi.mock('../../../../utils/workspace', () => ({ + getWorkflowNode: mocks.getWorkflowNode, +})); + +vi.mock('../panels/localOverviewPanel', () => ({ + default: class { + create = mocks.localCreate; + }, +})); + +vi.mock('../panels/localCodefulOverviewPanel', () => ({ + default: class { + create = mocks.codefulCreate; + }, +})); + +vi.mock('../panels/remoteOverviewPanel', () => ({ + default: class { + create = mocks.remoteCreate; + }, +})); + +import { openOverview } from '../openOverview'; + +const context = { telemetry: { properties: {}, measurements: {} } } as any; + +describe('openOverview routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getWorkflowNode.mockImplementation((node: any) => node); + }); + + it('routes to LocalOverviewPanel for codeless workflow.json files', async () => { + const node = vscode.Uri.file('D:\\project\\my-workflow\\workflow.json'); + await openOverview(context, node as any); + + expect(mocks.localCreate).toHaveBeenCalled(); + expect(mocks.codefulCreate).not.toHaveBeenCalled(); + expect(mocks.remoteCreate).not.toHaveBeenCalled(); + }); + + it('routes to LocalCodefulOverviewPanel for .cs files', async () => { + const node = vscode.Uri.file('D:\\project\\Workflows.cs'); + await openOverview(context, node as any); + + expect(mocks.codefulCreate).toHaveBeenCalled(); + expect(mocks.localCreate).not.toHaveBeenCalled(); + expect(mocks.remoteCreate).not.toHaveBeenCalled(); + }); + + it('routes to RemoteOverviewPanel for RemoteWorkflowTreeItem', async () => { + const node = new mocks.MockRemoteWorkflowTreeItem(); + await openOverview(context, node as any); + + expect(mocks.remoteCreate).toHaveBeenCalled(); + expect(mocks.localCreate).not.toHaveBeenCalled(); + expect(mocks.codefulCreate).not.toHaveBeenCalled(); + }); + + it('logs and returns early when workflow node is not found', async () => { + mocks.getWorkflowNode.mockReturnValue(undefined); + await openOverview(context, undefined); + + expect(mocks.localCreate).not.toHaveBeenCalled(); + expect(mocks.codefulCreate).not.toHaveBeenCalled(); + expect(mocks.remoteCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/openOverview.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/openOverview.ts new file mode 100644 index 00000000000..677235eaca1 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/openOverview.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; +import { getWorkflowNode } from '../../../utils/workspace'; +import type { Uri } from 'vscode'; +import { ext } from '../../../../extensionVariables'; +import { localize } from '../../../../localize'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import type { OverviewPanel } from './panels/overviewPanel'; +import RemoteOverviewPanel from './panels/remoteOverviewPanel'; +import LocalOverviewPanel from './panels/localOverviewPanel'; +import LocalCodefulOverviewPanel from './panels/localCodefulOverviewPanel'; + +export async function openOverview(context: IActionContext, node: Uri | RemoteWorkflowTreeItem | undefined): Promise { + const workflowNode = getWorkflowNode(node); + if (!workflowNode) { + ext.outputChannel.appendLog(localize('workflowNodeNotFound', 'Failed to open overview. Unable to find the workflow node.')); + return; + } + + const overviewPanel = getOverviewPanel(context, workflowNode); + await overviewPanel.create(); +} + +function getOverviewPanel(context: IActionContext, workflowNode: Uri | RemoteWorkflowTreeItem): OverviewPanel { + if (workflowNode instanceof RemoteWorkflowTreeItem) { + return new RemoteOverviewPanel(context, workflowNode); + } + + if (workflowNode.fsPath.endsWith('.cs')) { + return new LocalCodefulOverviewPanel(context, workflowNode); + } + + return new LocalOverviewPanel(context, workflowNode); +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/__test__/openOverview.test.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/localCodefulOverviewPanel.test.ts similarity index 50% rename from apps/vs-code-designer/src/app/commands/workflows/__test__/openOverview.test.ts rename to apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/localCodefulOverviewPanel.test.ts index 6a55cdbc254..88a306224cd 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/__test__/openOverview.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/localCodefulOverviewPanel.test.ts @@ -1,87 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as vscode from 'vscode'; +import { ext } from '../../../../../../extensionVariables'; import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import path from 'path'; -import * as vscode from 'vscode'; -import { ext } from '../../../../extensionVariables'; -import { openOverview } from '../openOverview'; const mocks = vi.hoisted(() => { class MockUri { public fsPath: string; - public constructor(fsPath: string) { this.fsPath = fsPath; } - public static file(fsPath: string): MockUri { return new MockUri(fsPath); } - public toString(): string { return this.fsPath; } } - class MockRemoteWorkflowTreeItem {} - return { - MockRemoteWorkflowTreeItem, MockUri, cacheWebviewPanel: vi.fn(), createWebviewPanel: vi.fn(), delay: vi.fn().mockResolvedValue(undefined), getAuthorizationToken: vi.fn().mockResolvedValue('local-token'), - getAuthorizationTokenFromNode: vi.fn().mockResolvedValue('remote-token'), getAzureConnectorDetailsForLocalProject: vi.fn().mockResolvedValue({ enabled: true, accessToken: 'azure-token', - clientId: 'client-id', tenantId: 'tenant-id', subscriptionId: 'subscription-id', resourceGroupName: 'resource-group', }), - getCallbackUrl: vi.fn(), getCodefulWorkflowMetadata: vi.fn().mockResolvedValue({ workflowName: 'workflow-a', triggerName: 'lspManual' }), getConnectionsJson: vi.fn().mockResolvedValue('{}'), - getLocalSettingsJson: vi.fn().mockResolvedValue({ Values: { WORKFLOW_CODEFUL_ENABLED: 'true' } }), + getLocalSettingsJson: vi.fn().mockResolvedValue({ Values: {} }), getLogicAppProjectRoot: vi.fn().mockResolvedValue('D:\\project'), - getRequestTriggerName: vi.fn(), - getRunTriggerName: vi.fn().mockReturnValue('manual'), getStandardAppData: vi.fn((workflowName: string, workflowContent: any) => ({ name: workflowName, kind: workflowContent?.kind ?? 'Stateful', operationOptions: workflowContent?.operationOptions, statelessRunMode: workflowContent?.statelessRunMode, })), - getTriggerName: vi.fn().mockReturnValue('manual'), getWebViewHTML: vi.fn().mockResolvedValue(''), - getWorkflowManagementBaseURI: vi.fn().mockReturnValue('https://management.azure.com/runtime'), - getWorkflowNode: vi.fn((node: any) => node), isRuntimeUp: vi.fn().mockResolvedValue(true), launchProjectDebugger: vi.fn(), - localize: vi.fn((_key: string, defaultMessage: string, ...args: string[]) => - defaultMessage.replace(/{(\d+)}/g, (_match, index) => args[Number(index)] ?? '') - ), openMonitoringView: vi.fn(), readFileSync: vi.fn(), readdirSync: vi.fn().mockReturnValue([]), removeWebviewPanelFromCache: vi.fn(), sendRequest: vi.fn(), + shouldUpdateOverviewCallbackInfo: vi.fn(), tryGetWebviewPanel: vi.fn(), + extractHttpTriggerName: vi.fn().mockReturnValue(undefined), + hasHttpRequestTrigger: vi.fn((content: string) => content.includes('CreateHttpTrigger')), }; }); vi.mock('vscode', () => ({ Uri: mocks.MockUri, - ViewColumn: { - Active: -1, - }, - window: { - createWebviewPanel: mocks.createWebviewPanel, - }, - workspace: { - name: 'test-workspace', - }, + ViewColumn: { Active: -1 }, + window: { createWebviewPanel: mocks.createWebviewPanel }, + workspace: { name: 'test-workspace' }, })); vi.mock('fs', () => ({ @@ -90,97 +69,90 @@ vi.mock('fs', () => ({ })); vi.mock('@microsoft/logic-apps-shared', () => ({ - getRequestTriggerName: mocks.getRequestTriggerName, - getRunTriggerName: mocks.getRunTriggerName, - getTriggerName: mocks.getTriggerName, - HTTP_METHODS: { - GET: 'GET', - POST: 'POST', - }, + getRequestTriggerName: vi.fn(), + getTriggerName: vi.fn().mockReturnValue('manual'), + HTTP_METHODS: { GET: 'GET', POST: 'POST' }, isNullOrUndefined: (value: unknown) => value === null || value === undefined, })); -vi.mock('../../../../localize', () => ({ - localize: mocks.localize, +vi.mock('../../../../../../localize', () => ({ + localize: (_key: string, defaultMsg: string, ...args: string[]) => + defaultMsg.replace(/{(\d+)}/g, (_match, index) => args[Number(index)] ?? ''), })); -vi.mock('../../../../extensionVariables', () => ({ +vi.mock('../../../../../../extensionVariables', () => ({ ext: { - context: { - extensionPath: 'D:\\extension', - subscriptions: [], - }, + context: { extensionPath: 'D:\\extension', subscriptions: [] }, extensionVersion: '1.0.0', - outputChannel: { - appendLog: vi.fn(), - }, - webViewKey: { - overview: 'overview', - }, + outputChannel: { appendLog: vi.fn() }, + webViewKey: { overview: 'overview' }, + workflowRuntimePort: 7071, }, })); -vi.mock('../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem', () => ({ - RemoteWorkflowTreeItem: mocks.MockRemoteWorkflowTreeItem, -})); - -vi.mock('../../../utils/appSettings/localSettings', () => ({ +vi.mock('../../../../../utils/appSettings/localSettings', () => ({ getLocalSettingsJson: mocks.getLocalSettingsJson, })); -vi.mock('../../../utils/codeless/common', () => ({ +vi.mock('../../../../../utils/codeless/common', () => ({ cacheWebviewPanel: mocks.cacheWebviewPanel, getAzureConnectorDetailsForLocalProject: mocks.getAzureConnectorDetailsForLocalProject, getStandardAppData: mocks.getStandardAppData, - getWorkflowManagementBaseURI: mocks.getWorkflowManagementBaseURI, removeWebviewPanelFromCache: mocks.removeWebviewPanelFromCache, tryGetWebviewPanel: mocks.tryGetWebviewPanel, })); -vi.mock('../../../utils/codeless/connection', () => ({ +vi.mock('../../../../../utils/codeless/connection', () => ({ getConnectionsJson: mocks.getConnectionsJson, getLogicAppProjectRoot: mocks.getLogicAppProjectRoot, })); -vi.mock('../../../utils/codeless/getAuthorizationToken', () => ({ +vi.mock('../../../../../utils/codeless/getAuthorizationToken', () => ({ getAuthorizationToken: mocks.getAuthorizationToken, - getAuthorizationTokenFromNode: mocks.getAuthorizationTokenFromNode, })); -vi.mock('../../../utils/codeless/getWebViewHTML', () => ({ +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ getWebViewHTML: mocks.getWebViewHTML, })); -vi.mock('../../../utils/requestUtils', () => ({ +vi.mock('../../../../../utils/requestUtils', () => ({ sendRequest: mocks.sendRequest, })); -vi.mock('../../../utils/workspace', () => ({ - getWorkflowNode: mocks.getWorkflowNode, -})); - -vi.mock('../openMonitoringView/openMonitoringView', () => ({ - openMonitoringView: mocks.openMonitoringView, -})); - -vi.mock('../../../utils/vsCodeConfig/launch', () => ({ +vi.mock('../../../../../utils/vsCodeConfig/launch', () => ({ launchProjectDebugger: mocks.launchProjectDebugger, })); -vi.mock('../../../utils/startRuntimeApi', () => ({ +vi.mock('../../../../../utils/startRuntimeApi', () => ({ isRuntimeUp: mocks.isRuntimeUp, })); -vi.mock('../../../utils/delay', () => ({ +vi.mock('../../../../../utils/delay', () => ({ delay: mocks.delay, })); -vi.mock('../../../languageServer/languageServer', () => ({ +vi.mock('../../../../../utils/codeful', () => ({ + detectCodefulWorkflow: vi.fn(), + extractTriggerNameFromCodeful: vi.fn(), + extractHttpTriggerName: mocks.extractHttpTriggerName, + hasHttpRequestTrigger: mocks.hasHttpRequestTrigger, +})); + +vi.mock('../../../../../languageServer/languageServer', () => ({ getCodefulWorkflowMetadata: mocks.getCodefulWorkflowMetadata, })); +vi.mock('../../../monitoringView/openMonitoringView', () => ({ + openMonitoringView: mocks.openMonitoringView, +})); + +vi.mock('../../../overviewCallbackInfo', () => ({ + shouldUpdateOverviewCallbackInfo: mocks.shouldUpdateOverviewCallbackInfo, +})); + +import LocalCodefulOverviewPanel from '../localCodefulOverviewPanel'; + const context = { telemetry: { properties: {}, measurements: {} } } as any; -const workflowFilePath = path.join('D:\\project', 'workflow-a', 'workflow.json'); const codefulFilePath = path.join('D:\\project', 'Workflows.cs'); interface MockPanel { @@ -195,73 +167,53 @@ interface MockPanel { }; } -let panel: MockPanel; -let messageHandler: ((message: any) => Promise) | undefined; -let disposeHandler: (() => void) | undefined; - -function createPanel(): MockPanel { - messageHandler = undefined; - disposeHandler = undefined; +function createMockPanel(): MockPanel { return { active: true, reveal: vi.fn(), webview: { html: '', - onDidReceiveMessage: vi.fn((handler: (message: any) => Promise) => { - messageHandler = handler; - }), + onDidReceiveMessage: vi.fn(), postMessage: vi.fn().mockResolvedValue(true), }, - onDidDispose: vi.fn((handler: () => void) => { - disposeHandler = handler; - }), + onDidDispose: vi.fn(), }; } -async function sendInitializeMessage(): Promise { - expect(messageHandler).toBeDefined(); - await messageHandler?.({ command: ExtensionCommand.initialize }); - return panel.webview.postMessage.mock.calls.find(([message]) => message.command === ExtensionCommand.initialize_frame)?.[0].data; -} - -function disposePanel(): void { - disposeHandler?.(); -} - -function mockWorkflowJson(definition: any, kind = 'Stateful'): void { - mocks.readFileSync.mockReturnValue(JSON.stringify({ definition, kind })); -} +describe('LocalCodefulOverviewPanel', () => { + let panel: MockPanel; -describe('openOverview', () => { beforeEach(() => { - vi.useRealTimers(); vi.clearAllMocks(); - panel = createPanel(); + panel = createMockPanel(); mocks.createWebviewPanel.mockReturnValue(panel); - mocks.getAuthorizationToken.mockResolvedValue('local-token'); - mocks.getAuthorizationTokenFromNode.mockResolvedValue('remote-token'); + mocks.tryGetWebviewPanel.mockReturnValue(undefined); + mocks.isRuntimeUp.mockResolvedValue(true); + mocks.readdirSync.mockReturnValue([]); + mocks.getCodefulWorkflowMetadata.mockResolvedValue({ workflowName: 'workflow-a', triggerName: 'lspManual' }); + mocks.getLogicAppProjectRoot.mockResolvedValue('D:\\project'); + mocks.getLocalSettingsJson.mockResolvedValue({ Values: {} }); + mocks.getConnectionsJson.mockResolvedValue('{}'); mocks.getAzureConnectorDetailsForLocalProject.mockResolvedValue({ enabled: true, accessToken: 'azure-token', - clientId: 'client-id', tenantId: 'tenant-id', subscriptionId: 'subscription-id', resourceGroupName: 'resource-group', }); - mocks.getCodefulWorkflowMetadata.mockResolvedValue({ workflowName: 'workflow-a', triggerName: 'lspManual' }); - mocks.getConnectionsJson.mockResolvedValue('{}'); - mocks.tryGetWebviewPanel.mockReturnValue(undefined); - mocks.getLocalSettingsJson.mockResolvedValue({ Values: { WORKFLOW_CODEFUL_ENABLED: 'true' } }); - mocks.getLogicAppProjectRoot.mockResolvedValue('D:\\project'); - mocks.getRequestTriggerName.mockReturnValue('manual'); - mocks.getRunTriggerName.mockReturnValue('manual'); - mocks.getTriggerName.mockReturnValue('manual'); - mocks.isRuntimeUp.mockResolvedValue(true); - mocks.readdirSync.mockReturnValue([]); + mocks.getAuthorizationToken.mockResolvedValue('local-token'); + mocks.getStandardAppData.mockImplementation((workflowName: string, workflowContent: any) => ({ + name: workflowName, + kind: workflowContent?.kind ?? 'Stateful', + operationOptions: workflowContent?.operationOptions, + statelessRunMode: workflowContent?.statelessRunMode, + })); + mocks.extractHttpTriggerName.mockReturnValue(undefined); + mocks.hasHttpRequestTrigger.mockImplementation((content: string) => content.includes('CreateHttpTrigger')); (ext as any).workflowRuntimePort = 7071; }); - it('initializes a centralized codeful overview with discovered workflows and LSP fallback trigger metadata', async () => { + it('initializes a codeful overview with discovered workflows and LSP fallback trigger metadata', async () => { const codefulContent = ` WorkflowBuilderFactory.CreateStatefulWorkflow("workflow-a", builder => {}); WorkflowBuilderFactory.CreateStatefulWorkflow("workflow-b", builder => {}); @@ -272,94 +224,38 @@ describe('openOverview', () => { if (request.url.endsWith('/workflows?api-version=2019-10-01-edge-preview')) { return JSON.stringify({ value: [] }); } - if (request.url.includes('/triggers?api-version=2019-10-01-edge-preview')) { return JSON.stringify({ value: [] }); } - if (request.url.includes('/listCallbackUrl?api-version=2019-10-01-edge-preview')) { return JSON.stringify({ value: `callback:${request.url}`, method: 'POST' }); } - throw new Error(`Unexpected request ${request.url}`); }); - await openOverview(context, vscode.Uri.file(codefulFilePath)); + const codefulPanel = new LocalCodefulOverviewPanel(context, vscode.Uri.file(codefulFilePath) as any); + await codefulPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); - const initializePayload = await sendInitializeMessage(); + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + const initializePayload = initCall?.[0].data; expect(initializePayload.project).toBe('overview'); expect(initializePayload.isCodeful).toBe(true); expect(initializePayload.workflowPropertiesList).toHaveLength(2); - expect(initializePayload.workflowPropertiesList.map((workflow: any) => workflow.name)).toEqual(['workflow-a', 'workflow-b']); + expect(initializePayload.workflowPropertiesList.map((w: any) => w.name)).toEqual(['workflow-a', 'workflow-b']); expect(initializePayload.workflowPropertiesList[0].triggerName).toBe('lspManual'); expect(initializePayload.workflowPropertiesList[0].callbackInfo.value).toContain( '/workflows/workflow-a/triggers/lspManual/listCallbackUrl' ); - expect(mocks.getCodefulWorkflowMetadata).toHaveBeenCalledWith(codefulFilePath); - expect(mocks.sendRequest).toHaveBeenCalledWith( - context, - expect.objectContaining({ - method: 'POST', - url: expect.stringContaining('/listCallbackUrl?api-version=2019-10-01-edge-preview'), - }) - ); - - disposePanel(); - }); - - it('uses trigger details to resolve callback URLs when runtime workflow metadata omits trigger type', async () => { - const codefulContent = 'WorkflowFactory.CreateStatefulWorkflow("workflow-a", workflow);'; - mocks.readFileSync.mockReturnValue(codefulContent); - mocks.sendRequest.mockImplementation(async (_context: any, request: { url: string; method: string }) => { - if (request.url.endsWith('/workflows?api-version=2019-10-01-edge-preview')) { - return JSON.stringify({ - value: [ - { - name: 'workflow-a', - kind: 'Stateful', - }, - ], - }); - } - - if (request.url.endsWith('/workflows/workflow-a/triggers?api-version=2019-10-01-edge-preview')) { - return JSON.stringify({ - value: [ - { - name: 'manual', - properties: { - type: 'Request', - kind: 'Http', - }, - }, - ], - }); - } - - if (request.url.includes('/listCallbackUrl?api-version=2019-10-01-edge-preview')) { - return JSON.stringify({ value: `callback:${request.url}`, method: 'POST' }); - } - - throw new Error(`Unexpected request ${request.url}`); - }); - - await openOverview(context, vscode.Uri.file(codefulFilePath)); - const initializePayload = await sendInitializeMessage(); - - expect(initializePayload.workflowPropertiesList).toHaveLength(1); - expect(initializePayload.workflowPropertiesList[0].triggerName).toBe('manual'); - expect(initializePayload.workflowPropertiesList[0].callbackInfo.value).toContain( - '/workflows/workflow-a/triggers/manual/listCallbackUrl' - ); - expect(initializePayload.workflowPropertiesList[0].callbackInfo.value).not.toContain('/triggers/manual/run'); - - disposePanel(); }); - it('posts callback URL updates with workflow names for a centralized codeful overview when the runtime base URL appears', async () => { + it('posts per-workflow callback URL updates when the runtime base URL appears', async () => { vi.useFakeTimers(); (ext as any).workflowRuntimePort = undefined; + mocks.extractHttpTriggerName.mockReturnValue('manual'); const codefulContent = ` WorkflowBuilderFactory.CreateStatefulWorkflow("workflow-a", builder => {}); WorkflowBuilderFactory.CreateStatefulWorkflow("workflow-b", builder => {}); @@ -370,24 +266,24 @@ describe('openOverview', () => { if (request.url.includes('/listCallbackUrl?api-version=2019-10-01-edge-preview')) { return JSON.stringify({ value: `callback:${request.url}`, method: 'POST' }); } - throw new Error(`Unexpected request ${request.url}`); }); - await openOverview(context, vscode.Uri.file(codefulFilePath)); - const initializePayload = await sendInitializeMessage(); + const codefulPanel = new LocalCodefulOverviewPanel(context, vscode.Uri.file(codefulFilePath) as any); + await codefulPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); - expect(initializePayload.baseUrl).toBeUndefined(); - expect(initializePayload.workflowPropertiesList[0].callbackInfo).toBeUndefined(); + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + expect(initCall?.[0].data.baseUrl).toBeUndefined(); (ext as any).workflowRuntimePort = 7071; await vi.advanceTimersByTimeAsync(5000); expect(panel.webview.postMessage).toHaveBeenCalledWith({ command: ExtensionCommand.update_runtime_base_url, - data: { - baseUrl: 'http://localhost:7071/runtime/webhooks/workflow/api/management', - }, + data: { baseUrl: 'http://localhost:7071/runtime/webhooks/workflow/api/management' }, }); expect(panel.webview.postMessage).toHaveBeenCalledWith({ command: ExtensionCommand.update_callback_info, @@ -408,11 +304,10 @@ describe('openOverview', () => { }, }); - disposePanel(); vi.useRealTimers(); }); - it('refreshes source-fallback codeful workflows from runtime metadata when the runtime base URL appears', async () => { + it('refreshes source-fallback workflows from runtime metadata when runtime appears', async () => { vi.useFakeTimers(); (ext as any).workflowRuntimePort = undefined; const codefulContent = 'WorkflowFactory.CreateStatefulWorkflow("source-workflow", workflow);'; @@ -424,28 +319,22 @@ describe('openOverview', () => { { name: 'runtime-workflow', kind: 'Stateful', - triggers: { - manual: { - type: 'Request', - kind: 'Http', - }, - }, + triggers: { manual: { type: 'Request', kind: 'Http' } }, }, ], }); } - if (request.url.includes('/listCallbackUrl?api-version=2019-10-01-edge-preview')) { return JSON.stringify({ value: `callback:${request.url}`, method: 'POST' }); } - throw new Error(`Unexpected request ${request.url}`); }); - await openOverview(context, vscode.Uri.file(codefulFilePath)); - const initializePayload = await sendInitializeMessage(); + const codefulPanel = new LocalCodefulOverviewPanel(context, vscode.Uri.file(codefulFilePath) as any); + await codefulPanel.create(); - expect(initializePayload.workflowPropertiesList.map((workflow: any) => workflow.name)).toEqual(['source-workflow']); + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); (ext as any).workflowRuntimePort = 7071; await vi.advanceTimersByTimeAsync(5000); @@ -470,128 +359,43 @@ describe('openOverview', () => { }, }); - disposePanel(); vi.useRealTimers(); }); - it('uses callback URLs for request-triggered codeless local workflows', async () => { - mockWorkflowJson({ - triggers: { - manual: { - type: 'Request', - kind: 'Http', - }, - }, - actions: {}, - }); - mocks.sendRequest.mockResolvedValue(JSON.stringify({ value: 'https://callback.local/manual', method: 'POST' })); - - await openOverview(context, vscode.Uri.file(workflowFilePath)); - - const initializePayload = await sendInitializeMessage(); - - expect(initializePayload.workflowProperties.callbackInfo).toEqual({ - value: 'https://callback.local/manual', - method: 'POST', - }); - expect(mocks.sendRequest).toHaveBeenCalledWith(context, { - method: 'POST', - url: 'http://localhost:7071/runtime/webhooks/workflow/api/management/workflows/workflow-a/triggers/manual/listCallbackUrl?api-version=2019-10-01-edge-preview', - }); - - disposePanel(); - }); - - it('uses the management run endpoint for codeless local workflows without request triggers', async () => { - mocks.getRequestTriggerName.mockReturnValue(undefined); - mocks.getRunTriggerName.mockReturnValue('recurrence'); - mocks.getTriggerName.mockReturnValue('recurrence'); - mockWorkflowJson({ - triggers: { - recurrence: { - type: 'Recurrence', - }, - }, - actions: {}, - }); - - await openOverview(context, vscode.Uri.file(workflowFilePath)); - - const initializePayload = await sendInitializeMessage(); - - expect(initializePayload.workflowProperties.callbackInfo).toEqual({ - value: - 'http://localhost:7071/runtime/webhooks/workflow/api/management/workflows/workflow-a/triggers/recurrence/run?api-version=2019-10-01-edge-preview', - method: 'POST', + it('uses trigger details to resolve callback URLs when runtime workflow metadata omits trigger type', async () => { + const codefulContent = 'WorkflowFactory.CreateStatefulWorkflow("workflow-a", workflow);'; + mocks.readFileSync.mockReturnValue(codefulContent); + mocks.sendRequest.mockImplementation(async (_context: any, request: { url: string; method: string }) => { + if (request.url.endsWith('/workflows?api-version=2019-10-01-edge-preview')) { + return JSON.stringify({ + value: [{ name: 'workflow-a', kind: 'Stateful' }], + }); + } + if (request.url.endsWith('/workflows/workflow-a/triggers?api-version=2019-10-01-edge-preview')) { + return JSON.stringify({ + value: [{ name: 'manual', properties: { type: 'Request', kind: 'Http' } }], + }); + } + if (request.url.includes('/listCallbackUrl?api-version=2019-10-01-edge-preview')) { + return JSON.stringify({ value: `callback:${request.url}`, method: 'POST' }); + } + throw new Error(`Unexpected request ${request.url}`); }); - expect(mocks.sendRequest).not.toHaveBeenCalled(); - disposePanel(); - }); - - it('initializes remote overview payloads with remote callback and Azure metadata', async () => { - const remoteNode = Object.assign(new mocks.MockRemoteWorkflowTreeItem(), { - id: 'remote-app', - name: 'remote-workflow', - workflowFileContent: { - definition: { - triggers: { - manual: { - type: 'Request', - kind: 'Http', - }, - }, - actions: {}, - }, - kind: 'Stateful', - }, - getCallbackUrl: mocks.getCallbackUrl.mockResolvedValue({ value: 'https://callback.remote/manual', method: 'POST' }), - subscription: { - subscriptionId: 'remote-subscription', - }, - parent: { - parent: { - site: { - location: 'West US', - resourceGroup: 'remote-rg', - }, - }, - subscription: { - environment: { - resourceManagerEndpointUrl: 'https://management.azure.com', - }, - tenantId: 'remote-tenant', - }, - }, - }); + const codefulPanel = new LocalCodefulOverviewPanel(context, vscode.Uri.file(codefulFilePath) as any); + await codefulPanel.create(); - await openOverview(context, remoteNode as any); + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); - const initializePayload = await sendInitializeMessage(); + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + const initializePayload = initCall?.[0].data; - expect(initializePayload.isLocal).toBe(false); - expect(initializePayload.corsNotice).toBe('To view runs, set "*" to allowed origins in the CORS setting.'); - expect(initializePayload.workflowProperties.callbackInfo).toEqual({ - value: 'https://callback.remote/manual', - method: 'POST', - }); - expect(initializePayload.azureDetails).toEqual( - expect.objectContaining({ - enabled: true, - accessToken: 'remote-token', - subscriptionId: 'remote-subscription', - location: 'westus', - resourceGroupName: 'remote-rg', - tenantId: 'remote-tenant', - }) + expect(initializePayload.workflowPropertiesList).toHaveLength(1); + expect(initializePayload.workflowPropertiesList[0].triggerName).toBe('manual'); + expect(initializePayload.workflowPropertiesList[0].callbackInfo.value).toContain( + '/workflows/workflow-a/triggers/manual/listCallbackUrl' ); - - await messageHandler?.({ - command: ExtensionCommand.loadRun, - item: { id: 'workflows/remote-workflow/runs/run-1' }, - }); - expect(mocks.openMonitoringView).toHaveBeenCalledWith(context, remoteNode, 'workflows/remote-workflow/runs/run-1', undefined); - - disposePanel(); + expect(initializePayload.workflowPropertiesList[0].callbackInfo.value).not.toContain('/triggers/manual/run'); }); }); diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/localOverviewPanel.test.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/localOverviewPanel.test.ts new file mode 100644 index 00000000000..0e3cb4e4b0a --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/localOverviewPanel.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as vscode from 'vscode'; +import { ext } from '../../../../../../extensionVariables'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; +import path from 'path'; + +const mocks = vi.hoisted(() => { + class MockUri { + public fsPath: string; + public constructor(fsPath: string) { + this.fsPath = fsPath; + } + public static file(fsPath: string): MockUri { + return new MockUri(fsPath); + } + public toString(): string { + return this.fsPath; + } + } + + return { + MockUri, + cacheWebviewPanel: vi.fn(), + createWebviewPanel: vi.fn(), + getAuthorizationToken: vi.fn().mockResolvedValue('local-token'), + getAzureConnectorDetailsForLocalProject: vi.fn().mockResolvedValue({ + enabled: true, + accessToken: 'azure-token', + tenantId: 'tenant-id', + subscriptionId: 'subscription-id', + resourceGroupName: 'resource-group', + }), + getConnectionsJson: vi.fn().mockResolvedValue('{}'), + getLocalSettingsJson: vi.fn().mockResolvedValue({ Values: {} }), + getLogicAppProjectRoot: vi.fn().mockResolvedValue('D:\\project'), + getRequestTriggerName: vi.fn().mockReturnValue('manual'), + getTriggerName: vi.fn().mockReturnValue('manual'), + getStandardAppData: vi.fn((workflowName: string, workflowContent: any) => ({ + name: workflowName, + kind: workflowContent?.kind ?? 'Stateful', + operationOptions: workflowContent?.operationOptions, + statelessRunMode: workflowContent?.statelessRunMode, + })), + getWebViewHTML: vi.fn().mockResolvedValue(''), + isRuntimeUp: vi.fn().mockResolvedValue(true), + launchProjectDebugger: vi.fn(), + openMonitoringView: vi.fn(), + readFileSync: vi.fn(), + removeWebviewPanelFromCache: vi.fn(), + sendRequest: vi.fn(), + shouldUpdateOverviewCallbackInfo: vi.fn((current: any, updated: any) => { + if (!updated) { + return false; + } + return updated.value !== current?.value; + }), + tryGetWebviewPanel: vi.fn(), + }; +}); + +vi.mock('vscode', () => ({ + Uri: mocks.MockUri, + ViewColumn: { Active: -1 }, + window: { createWebviewPanel: mocks.createWebviewPanel }, + workspace: { name: 'test-workspace' }, +})); + +vi.mock('fs', () => ({ + readFileSync: mocks.readFileSync, +})); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + getRequestTriggerName: mocks.getRequestTriggerName, + getTriggerName: mocks.getTriggerName, + HTTP_METHODS: { GET: 'GET', POST: 'POST' }, + isNullOrUndefined: (value: unknown) => value === null || value === undefined, +})); + +vi.mock('../../../../../../localize', () => ({ + localize: (_key: string, defaultMsg: string) => defaultMsg, +})); + +vi.mock('../../../../../../extensionVariables', () => ({ + ext: { + context: { extensionPath: 'D:\\extension', subscriptions: [] }, + extensionVersion: '1.0.0', + outputChannel: { appendLog: vi.fn() }, + webViewKey: { overview: 'overview' }, + workflowRuntimePort: 7071, + }, +})); + +vi.mock('../../../../../utils/appSettings/localSettings', () => ({ + getLocalSettingsJson: mocks.getLocalSettingsJson, +})); + +vi.mock('../../../../../utils/codeless/common', () => ({ + cacheWebviewPanel: mocks.cacheWebviewPanel, + getAzureConnectorDetailsForLocalProject: mocks.getAzureConnectorDetailsForLocalProject, + getStandardAppData: mocks.getStandardAppData, + removeWebviewPanelFromCache: mocks.removeWebviewPanelFromCache, + tryGetWebviewPanel: mocks.tryGetWebviewPanel, +})); + +vi.mock('../../../../../utils/codeless/connection', () => ({ + getConnectionsJson: mocks.getConnectionsJson, + getLogicAppProjectRoot: mocks.getLogicAppProjectRoot, +})); + +vi.mock('../../../../../utils/codeless/getAuthorizationToken', () => ({ + getAuthorizationToken: mocks.getAuthorizationToken, +})); + +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ + getWebViewHTML: mocks.getWebViewHTML, +})); + +vi.mock('../../../../../utils/requestUtils', () => ({ + sendRequest: mocks.sendRequest, +})); + +vi.mock('../../../../../utils/vsCodeConfig/launch', () => ({ + launchProjectDebugger: mocks.launchProjectDebugger, +})); + +vi.mock('../../../../../utils/startRuntimeApi', () => ({ + isRuntimeUp: mocks.isRuntimeUp, +})); + +vi.mock('../../../monitoringView/openMonitoringView', () => ({ + openMonitoringView: mocks.openMonitoringView, +})); + +vi.mock('../../../overviewCallbackInfo', () => ({ + shouldUpdateOverviewCallbackInfo: mocks.shouldUpdateOverviewCallbackInfo, +})); + +import LocalOverviewPanel from '../localOverviewPanel'; + +const context = { telemetry: { properties: {}, measurements: {} } } as any; +const workflowFilePath = path.join('D:\\project', 'workflow-a', 'workflow.json'); + +interface MockPanel { + active: boolean; + iconPath?: unknown; + onDidDispose: ReturnType; + reveal: ReturnType; + webview: { + html: string; + onDidReceiveMessage: ReturnType; + postMessage: ReturnType; + }; +} + +function createMockPanel(): MockPanel { + return { + active: true, + reveal: vi.fn(), + webview: { + html: '', + onDidReceiveMessage: vi.fn(), + postMessage: vi.fn().mockResolvedValue(true), + }, + onDidDispose: vi.fn(), + }; +} + +function mockWorkflowJson(definition: any, kind = 'Stateful'): void { + mocks.readFileSync.mockReturnValue(JSON.stringify({ definition, kind })); +} + +describe('LocalOverviewPanel', () => { + let panel: MockPanel; + + beforeEach(() => { + vi.clearAllMocks(); + panel = createMockPanel(); + mocks.createWebviewPanel.mockReturnValue(panel); + mocks.tryGetWebviewPanel.mockReturnValue(undefined); + mocks.isRuntimeUp.mockResolvedValue(true); + mocks.getLogicAppProjectRoot.mockResolvedValue('D:\\project'); + mocks.getLocalSettingsJson.mockResolvedValue({ Values: {} }); + mocks.getConnectionsJson.mockResolvedValue('{}'); + mocks.getAzureConnectorDetailsForLocalProject.mockResolvedValue({ + enabled: true, + accessToken: 'azure-token', + tenantId: 'tenant-id', + subscriptionId: 'subscription-id', + resourceGroupName: 'resource-group', + }); + mocks.getAuthorizationToken.mockResolvedValue('local-token'); + mocks.getTriggerName.mockReturnValue('manual'); + (ext as any).workflowRuntimePort = 7071; + }); + + it('uses callback URLs for request-triggered codeless local workflows', async () => { + mocks.getRequestTriggerName.mockReturnValue('manual'); + mockWorkflowJson({ + triggers: { manual: { type: 'Request', kind: 'Http' } }, + actions: {}, + }); + mocks.sendRequest.mockResolvedValue(JSON.stringify({ value: 'https://callback.local/manual', method: 'POST' })); + + const localPanel = new LocalOverviewPanel(context, vscode.Uri.file(workflowFilePath) as any); + await localPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + const initializePayload = initCall?.[0].data; + + expect(initializePayload.workflowProperties.callbackInfo).toEqual({ + value: 'https://callback.local/manual', + method: 'POST', + }); + expect(mocks.sendRequest).toHaveBeenCalledWith(context, { + method: 'POST', + url: 'http://localhost:7071/runtime/webhooks/workflow/api/management/workflows/workflow-a/triggers/manual/listCallbackUrl?api-version=2019-10-01-edge-preview', + }); + }); + + it('uses the management run endpoint for codeless local workflows without request triggers', async () => { + mocks.getRequestTriggerName.mockReturnValue(undefined); + mocks.getTriggerName.mockReturnValue('recurrence'); + mockWorkflowJson({ + triggers: { recurrence: { type: 'Recurrence' } }, + actions: {}, + }); + + const localPanel = new LocalOverviewPanel(context, vscode.Uri.file(workflowFilePath) as any); + await localPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + const initializePayload = initCall?.[0].data; + + expect(initializePayload.workflowProperties.callbackInfo).toEqual({ + value: + 'http://localhost:7071/runtime/webhooks/workflow/api/management/workflows/workflow-a/triggers/recurrence/run?api-version=2019-10-01-edge-preview', + method: 'POST', + }); + expect(mocks.sendRequest).not.toHaveBeenCalled(); + }); + + it('launches debugger when runtime is not up', async () => { + mocks.isRuntimeUp.mockResolvedValue(false); + mockWorkflowJson({ triggers: {}, actions: {} }); + + const localPanel = new LocalOverviewPanel(context, vscode.Uri.file(workflowFilePath) as any); + await localPanel.create(); + + expect(mocks.launchProjectDebugger).toHaveBeenCalledWith(context, 'D:\\project'); + }); + + it('sets isLocal to true in initialize payload', async () => { + mockWorkflowJson({ triggers: {}, actions: {} }); + + const localPanel = new LocalOverviewPanel(context, vscode.Uri.file(workflowFilePath) as any); + await localPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + const initializePayload = initCall?.[0].data; + + expect(initializePayload.isLocal).toBe(true); + expect(initializePayload.isCodeful).toBe(false); + expect(initializePayload.project).toBe('overview'); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/overviewPanel.test.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/overviewPanel.test.ts new file mode 100644 index 00000000000..fea6549238b --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/overviewPanel.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as vscode from 'vscode'; +import { ext } from '../../../../../../extensionVariables'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; + +const mocks = vi.hoisted(() => { + class MockUri { + public fsPath: string; + + public constructor(fsPath: string) { + this.fsPath = fsPath; + } + + public static file(fsPath: string): MockUri { + return new MockUri(fsPath); + } + + public toString(): string { + return this.fsPath; + } + } + + return { + MockUri, + cacheWebviewPanel: vi.fn(), + createWebviewPanel: vi.fn(), + getWebViewHTML: vi.fn().mockResolvedValue(''), + openMonitoringView: vi.fn(), + removeWebviewPanelFromCache: vi.fn(), + tryGetWebviewPanel: vi.fn(), + shouldUpdateOverviewCallbackInfo: vi.fn((current: any, updated: any) => { + if (!updated) { + return false; + } + return updated.value !== current?.value; + }), + }; +}); + +vi.mock('vscode', () => ({ + Uri: mocks.MockUri, + ViewColumn: { Active: -1 }, + window: { createWebviewPanel: mocks.createWebviewPanel }, + workspace: { name: 'test-workspace' }, +})); + +vi.mock('../../../../../../extensionVariables', () => ({ + ext: { + context: { extensionPath: 'D:\\extension', subscriptions: [] }, + extensionVersion: '1.0.0', + outputChannel: { appendLog: vi.fn() }, + webViewKey: { overview: 'overview' }, + }, +})); + +vi.mock('../../../../../../localize', () => ({ + localize: (_key: string, defaultMsg: string) => defaultMsg, +})); + +vi.mock('../../../../../utils/codeless/common', () => ({ + cacheWebviewPanel: mocks.cacheWebviewPanel, + removeWebviewPanelFromCache: mocks.removeWebviewPanelFromCache, + tryGetWebviewPanel: mocks.tryGetWebviewPanel, +})); + +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ + getWebViewHTML: mocks.getWebViewHTML, +})); + +vi.mock('../../../monitoringView/openMonitoringView', () => ({ + openMonitoringView: mocks.openMonitoringView, +})); + +vi.mock('../../../overviewCallbackInfo', () => ({ + shouldUpdateOverviewCallbackInfo: mocks.shouldUpdateOverviewCallbackInfo, +})); + +import { OverviewPanel } from '../overviewPanel'; +import type { ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; + +class TestOverviewPanel extends OverviewPanel { + public mockAccessToken = 'test-token'; + public mockBaseUrl: string | undefined = 'http://localhost:7071/api'; + public mockCallbackInfo: ICallbackUrlResponse | undefined; + public mockEnvironmentReady = true; + + constructor() { + super( + { telemetry: { properties: {}, measurements: {} } } as any, + 'test-workflow', + 'test-panel', + 'test-panel-title', + '2019-10-01-edge-preview', + true + ); + } + + protected async initializeOverviewData(): Promise { + this.baseUrl = this.mockBaseUrl; + this.accessToken = this.mockAccessToken; + this.workflowProps = { + name: 'test-workflow', + stateType: 'Stateful', + triggerName: 'manual', + definition: { triggers: {}, actions: {} } as any, + kind: 'Stateful', + }; + } + + protected async isEnvironmentReady(): Promise { + return this.mockEnvironmentReady; + } + + protected getBaseUrl(): string | undefined { + return this.mockBaseUrl; + } + + protected async getCallbackInfo(_baseUrl: string): Promise { + return this.mockCallbackInfo; + } + + protected async getAccessToken(): Promise { + return this.mockAccessToken; + } + + protected getWorkflowNode(): vscode.Uri | undefined { + return this.workflowFilePath ? vscode.Uri.file(this.workflowFilePath) : undefined; + } + + public exposePanel() { + return this.panel; + } +} + +interface MockPanel { + active: boolean; + iconPath?: unknown; + onDidDispose: ReturnType; + reveal: ReturnType; + webview: { + html: string; + onDidReceiveMessage: ReturnType; + postMessage: ReturnType; + }; +} + +function createMockPanel(): MockPanel { + return { + active: true, + reveal: vi.fn(), + webview: { + html: '', + onDidReceiveMessage: vi.fn(), + postMessage: vi.fn().mockResolvedValue(true), + }, + onDidDispose: vi.fn(), + }; +} + +describe('OverviewPanel', () => { + let panel: MockPanel; + let overviewPanel: TestOverviewPanel; + + beforeEach(() => { + vi.clearAllMocks(); + panel = createMockPanel(); + mocks.createWebviewPanel.mockReturnValue(panel); + mocks.tryGetWebviewPanel.mockReturnValue(undefined); + overviewPanel = new TestOverviewPanel(); + }); + + describe('create()', () => { + it('should create a new panel when none exists', async () => { + await overviewPanel.create(); + + expect(mocks.createWebviewPanel).toHaveBeenCalledWith('workflowOverview', 'test-panel-title', -1, { + enableScripts: true, + retainContextWhenHidden: true, + }); + expect(mocks.cacheWebviewPanel).toHaveBeenCalledWith('overview', 'test-panel', panel); + }); + + it('should reveal existing panel instead of creating new one', async () => { + const existingPanel = createMockPanel(); + existingPanel.active = false; + mocks.tryGetWebviewPanel.mockReturnValue(existingPanel); + + await overviewPanel.create(); + + expect(existingPanel.reveal).toHaveBeenCalledWith(-1); + expect(mocks.createWebviewPanel).not.toHaveBeenCalled(); + }); + + it('should not reveal an already-active existing panel', async () => { + const existingPanel = createMockPanel(); + existingPanel.active = true; + mocks.tryGetWebviewPanel.mockReturnValue(existingPanel); + + await overviewPanel.create(); + + expect(existingPanel.reveal).not.toHaveBeenCalled(); + expect(mocks.createWebviewPanel).not.toHaveBeenCalled(); + }); + + it('should dispose cached panel and recreate when environment is not ready', async () => { + const existingPanel = createMockPanel(); + const disposeFn = vi.fn(); + (existingPanel as any).dispose = disposeFn; + mocks.tryGetWebviewPanel.mockReturnValue(existingPanel); + overviewPanel.mockEnvironmentReady = false; + + await overviewPanel.create(); + + expect(disposeFn).toHaveBeenCalled(); + expect(mocks.createWebviewPanel).toHaveBeenCalled(); + expect(mocks.cacheWebviewPanel).toHaveBeenCalledWith('overview', 'test-panel', panel); + }); + + it('should not dispose cached panel when environment is ready', async () => { + const existingPanel = createMockPanel(); + const disposeFn = vi.fn(); + (existingPanel as any).dispose = disposeFn; + existingPanel.active = false; + mocks.tryGetWebviewPanel.mockReturnValue(existingPanel); + overviewPanel.mockEnvironmentReady = true; + + await overviewPanel.create(); + + expect(disposeFn).not.toHaveBeenCalled(); + expect(existingPanel.reveal).toHaveBeenCalledWith(-1); + expect(mocks.createWebviewPanel).not.toHaveBeenCalled(); + }); + }); + + describe('handleWebviewMsg()', () => { + it('should send initialize_frame on initialize command', async () => { + await overviewPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + command: ExtensionCommand.initialize_frame, + data: expect.objectContaining({ + project: 'overview', + apiVersion: '2019-10-01-edge-preview', + baseUrl: 'http://localhost:7071/api', + accessToken: 'test-token', + isLocal: true, + workflowProperties: expect.objectContaining({ name: 'test-workflow' }), + }), + }); + }); + + it('should call openMonitoringView on loadRun command', async () => { + overviewPanel.workflowFilePath = 'D:\\project\\workflow\\workflow.json'; + await overviewPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.loadRun, item: { id: 'run-123' } }); + + expect(mocks.openMonitoringView).toHaveBeenCalled(); + }); + }); + + describe('intervals', () => { + it('should update access token when it changes', async () => { + vi.useFakeTimers(); + await overviewPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + overviewPanel.mockAccessToken = 'new-token'; + await vi.advanceTimersByTimeAsync(5000); + + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + command: ExtensionCommand.update_access_token, + data: { accessToken: 'new-token' }, + }); + + vi.useRealTimers(); + }); + + it('should update base URL when it changes', async () => { + vi.useFakeTimers(); + await overviewPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + overviewPanel.mockBaseUrl = 'http://localhost:8080/api'; + await vi.advanceTimersByTimeAsync(5000); + + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + command: ExtensionCommand.update_runtime_base_url, + data: { baseUrl: 'http://localhost:8080/api' }, + }); + + vi.useRealTimers(); + }); + + it('should clear intervals on dispose', async () => { + vi.useFakeTimers(); + await overviewPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + const disposeHandler = panel.onDidDispose.mock.calls[0][0]; + disposeHandler(); + + overviewPanel.mockAccessToken = 'should-not-be-sent'; + await vi.advanceTimersByTimeAsync(10000); + + expect(panel.webview.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + command: ExtensionCommand.update_access_token, + data: { accessToken: 'should-not-be-sent' }, + }) + ); + + vi.useRealTimers(); + }); + + it('should post callback info update when callback changes', async () => { + vi.useFakeTimers(); + await overviewPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + overviewPanel.mockCallbackInfo = { value: 'https://new-callback.url', method: 'POST' }; + await vi.advanceTimersByTimeAsync(5000); + + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + command: ExtensionCommand.update_callback_info, + data: { callbackInfo: { value: 'https://new-callback.url', method: 'POST' } }, + }); + + vi.useRealTimers(); + }); + + it('should not post callback info when baseUrl is undefined', async () => { + vi.useFakeTimers(); + overviewPanel.mockBaseUrl = undefined; + await overviewPanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + overviewPanel.mockCallbackInfo = { value: 'https://should-not-appear.url', method: 'POST' }; + await vi.advanceTimersByTimeAsync(5000); + + expect(panel.webview.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ command: ExtensionCommand.update_callback_info }) + ); + + vi.useRealTimers(); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/remoteOverviewPanel.test.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/remoteOverviewPanel.test.ts new file mode 100644 index 00000000000..ed2b1f81c2d --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/__test__/remoteOverviewPanel.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as vscode from 'vscode'; +import { ext } from '../../../../../../extensionVariables'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; + +const mocks = vi.hoisted(() => { + class MockUri { + public fsPath: string; + public constructor(fsPath: string) { + this.fsPath = fsPath; + } + public static file(fsPath: string): MockUri { + return new MockUri(fsPath); + } + public toString(): string { + return this.fsPath; + } + } + + class MockRemoteWorkflowTreeItem {} + + return { + MockUri, + MockRemoteWorkflowTreeItem, + cacheWebviewPanel: vi.fn(), + createWebviewPanel: vi.fn(), + getAuthorizationTokenFromNode: vi.fn().mockResolvedValue('remote-token'), + getCallbackUrl: vi.fn(), + getStandardAppData: vi.fn((workflowName: string, workflowContent: any) => ({ + name: workflowName, + kind: workflowContent?.kind ?? 'Stateful', + operationOptions: workflowContent?.operationOptions, + statelessRunMode: workflowContent?.statelessRunMode, + })), + getTriggerName: vi.fn().mockReturnValue('manual'), + getWebViewHTML: vi.fn().mockResolvedValue(''), + getWorkflowManagementBaseURI: vi.fn().mockReturnValue('https://management.azure.com/runtime'), + openMonitoringView: vi.fn(), + removeWebviewPanelFromCache: vi.fn(), + shouldUpdateOverviewCallbackInfo: vi.fn((current: any, updated: any) => { + if (!updated) { + return false; + } + return updated.value !== current?.value; + }), + tryGetWebviewPanel: vi.fn(), + }; +}); + +vi.mock('vscode', () => ({ + Uri: mocks.MockUri, + ViewColumn: { Active: -1 }, + window: { createWebviewPanel: mocks.createWebviewPanel }, + workspace: { name: 'test-workspace' }, +})); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + getTriggerName: mocks.getTriggerName, + HTTP_METHODS: { GET: 'GET', POST: 'POST' }, +})); + +vi.mock('../../../../../../localize', () => ({ + localize: (_key: string, defaultMsg: string) => defaultMsg, +})); + +vi.mock('../../../../../../extensionVariables', () => ({ + ext: { + context: { extensionPath: 'D:\\extension', subscriptions: [] }, + extensionVersion: '1.0.0', + outputChannel: { appendLog: vi.fn() }, + webViewKey: { overview: 'overview' }, + }, +})); + +vi.mock('../../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem', () => ({ + RemoteWorkflowTreeItem: mocks.MockRemoteWorkflowTreeItem, +})); + +vi.mock('../../../../../utils/codeless/common', () => ({ + cacheWebviewPanel: mocks.cacheWebviewPanel, + getStandardAppData: mocks.getStandardAppData, + getWorkflowManagementBaseURI: mocks.getWorkflowManagementBaseURI, + removeWebviewPanelFromCache: mocks.removeWebviewPanelFromCache, + tryGetWebviewPanel: mocks.tryGetWebviewPanel, +})); + +vi.mock('../../../../../utils/codeless/getAuthorizationToken', () => ({ + getAuthorizationTokenFromNode: mocks.getAuthorizationTokenFromNode, +})); + +vi.mock('../../../../../utils/codeless/getWebViewHTML', () => ({ + getWebViewHTML: mocks.getWebViewHTML, +})); + +vi.mock('../../../monitoringView/openMonitoringView', () => ({ + openMonitoringView: mocks.openMonitoringView, +})); + +vi.mock('../../../overviewCallbackInfo', () => ({ + shouldUpdateOverviewCallbackInfo: mocks.shouldUpdateOverviewCallbackInfo, +})); + +import RemoteOverviewPanel from '../remoteOverviewPanel'; + +const context = { telemetry: { properties: {}, measurements: {} } } as any; + +interface MockPanel { + active: boolean; + iconPath?: unknown; + onDidDispose: ReturnType; + reveal: ReturnType; + webview: { + html: string; + onDidReceiveMessage: ReturnType; + postMessage: ReturnType; + }; +} + +function createMockPanel(): MockPanel { + return { + active: true, + reveal: vi.fn(), + webview: { + html: '', + onDidReceiveMessage: vi.fn(), + postMessage: vi.fn().mockResolvedValue(true), + }, + onDidDispose: vi.fn(), + }; +} + +function createRemoteNode() { + return Object.assign(new mocks.MockRemoteWorkflowTreeItem(), { + id: 'remote-app', + name: 'remote-workflow', + workflowFileContent: { + definition: { + triggers: { manual: { type: 'Request', kind: 'Http' } }, + actions: {}, + }, + kind: 'Stateful', + }, + getCallbackUrl: mocks.getCallbackUrl.mockResolvedValue({ value: 'https://callback.remote/manual', method: 'POST' }), + subscription: { subscriptionId: 'remote-subscription' }, + parent: { + parent: { + site: { location: 'West US', resourceGroup: 'remote-rg' }, + }, + subscription: { + environment: { resourceManagerEndpointUrl: 'https://management.azure.com' }, + tenantId: 'remote-tenant', + }, + }, + }); +} + +describe('RemoteOverviewPanel', () => { + let panel: MockPanel; + + beforeEach(() => { + vi.clearAllMocks(); + panel = createMockPanel(); + mocks.createWebviewPanel.mockReturnValue(panel); + mocks.tryGetWebviewPanel.mockReturnValue(undefined); + mocks.getAuthorizationTokenFromNode.mockResolvedValue('remote-token'); + mocks.getWorkflowManagementBaseURI.mockReturnValue('https://management.azure.com/runtime'); + mocks.getTriggerName.mockReturnValue('manual'); + mocks.getStandardAppData.mockImplementation((workflowName: string, workflowContent: any) => ({ + name: workflowName, + kind: workflowContent?.kind ?? 'Stateful', + operationOptions: workflowContent?.operationOptions, + statelessRunMode: workflowContent?.statelessRunMode, + })); + }); + + it('initializes remote overview with Azure metadata and CORS notice', async () => { + const remoteNode = createRemoteNode(); + const remotePanel = new RemoteOverviewPanel(context, remoteNode as any); + await remotePanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + const initializePayload = initCall?.[0].data; + + expect(initializePayload.isLocal).toBe(false); + expect(initializePayload.corsNotice).toBe('To view runs, set "*" to allowed origins in the CORS setting.'); + expect(initializePayload.workflowProperties.callbackInfo).toEqual({ + value: 'https://callback.remote/manual', + method: 'POST', + }); + expect(initializePayload.azureDetails).toEqual( + expect.objectContaining({ + enabled: true, + accessToken: 'remote-token', + subscriptionId: 'remote-subscription', + location: 'westus', + resourceGroupName: 'remote-rg', + tenantId: 'remote-tenant', + }) + ); + }); + + it('passes remote node to openMonitoringView on loadRun', async () => { + const remoteNode = createRemoteNode(); + const remotePanel = new RemoteOverviewPanel(context, remoteNode as any); + await remotePanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ + command: ExtensionCommand.loadRun, + item: { id: 'workflows/remote-workflow/runs/run-1' }, + }); + + expect(mocks.openMonitoringView).toHaveBeenCalledWith(context, remoteNode, 'workflows/remote-workflow/runs/run-1', undefined); + }); + + it('sets isCodeful to false for remote overview', async () => { + const remoteNode = createRemoteNode(); + const remotePanel = new RemoteOverviewPanel(context, remoteNode as any); + await remotePanel.create(); + + const messageHandler = panel.webview.onDidReceiveMessage.mock.calls[0][0]; + await messageHandler({ command: ExtensionCommand.initialize }); + + const initCall = panel.webview.postMessage.mock.calls.find(([msg]: any) => msg.command === ExtensionCommand.initialize_frame); + expect(initCall?.[0].data.isCodeful).toBe(false); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/panels/localCodefulOverviewPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/localCodefulOverviewPanel.ts new file mode 100644 index 00000000000..267b0bd8aa6 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/localCodefulOverviewPanel.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { isNullOrUndefined } from '@microsoft/logic-apps-shared'; +import { localSettingsFileName } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; +import { getLocalSettingsJson } from '../../../../utils/appSettings/localSettings'; +import { getAzureConnectorDetailsForLocalProject } from '../../../../utils/codeless/common'; +import { getConnectionsJson, getLogicAppProjectRoot } from '../../../../utils/codeless/connection'; +import { launchProjectDebugger } from '../../../../utils/vsCodeConfig/launch'; +import { isRuntimeUp } from '../../../../utils/startRuntimeApi'; +import { + getCodefulWorkflowContent, + getCodefulWorkflowPropertiesList, + getCodefulWorkflowCallbackInfo, + getCodefulWorkflowDataList, + getCodefulWorkflowHasHttpTrigger, + getRuntimeCodefulWorkflows, +} from '../utils/codefulHelpers'; +import { getWorkflowPropertiesListSignature } from '../utils/overviewHelpers'; +import LocalOverviewPanel from './localOverviewPanel'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; +import { readFileSync } from 'fs'; +import { basename, dirname, join } from 'path'; +import * as vscode from 'vscode'; + +export default class LocalCodefulOverviewPanel extends LocalOverviewPanel { + private codefulWorkflowFileContent = ''; + private isCodefulRuntimeMetadataConfirmed = false; + private workflowPropertiesListSignature = ''; + + constructor(context: IActionContext, node: vscode.Uri) { + super(context, node); + this.isCodefulOverview = true; + + const projectName = basename(dirname(this.workflowFilePath)); + this.panelName = `${vscode.workspace.name}-${projectName}-codeful-overview`; + this.panelTitle = `${projectName}-overview`; + } + + protected async initializeOverviewData(): Promise { + this.projectPath = await getLogicAppProjectRoot(this.context, this.workflowFilePath); + + if (!isNullOrUndefined(this.projectPath) && !(await isRuntimeUp(ext.workflowRuntimePort))) { + await launchProjectDebugger(this.context, this.projectPath); + } + + this.baseUrl = this.getBaseUrl(); + if (!this.baseUrl) { + ext.outputChannel.appendLog( + localize( + 'overviewCallbackUrlUnavailable', + 'Callback URL is not available because the workflow runtime is not running. Start debugging or run "func host start" to enable the Run Trigger button.' + ) + ); + } + + this.localSettings = this.projectPath + ? (await getLocalSettingsJson(this.context, join(this.projectPath, localSettingsFileName))).Values || {} + : {}; + + const fileContent = readFileSync(this.workflowFilePath, 'utf8'); + this.codefulWorkflowFileContent = fileContent; + + const codefulWorkflowData = await getCodefulWorkflowDataList( + this.context, + this.workflowFilePath, + fileContent, + this.baseUrl, + this.apiVersion + ); + const codefulWorkflows = codefulWorkflowData.workflows; + this.isCodefulRuntimeMetadataConfirmed = codefulWorkflowData.fromRuntime; + + if (codefulWorkflows.length === 0) { + throw new Error(localize('noCodefulWorkflowsFound', 'No codeful workflows were found in this project.')); + } + + this.workflowPropertiesList = await getCodefulWorkflowPropertiesList( + this.context, + codefulWorkflows, + this.workflowFilePath, + fileContent, + this.baseUrl, + this.apiVersion, + this.localSettings + ); + this.workflowPropertiesListSignature = getWorkflowPropertiesListSignature(this.workflowPropertiesList); + + this.workflowProps = this.workflowPropertiesList[0]; + this.workflowName = this.workflowProps.name; + this.triggerName = this.workflowProps.triggerName; + this.callbackInfo = this.workflowProps.callbackInfo; + this.workflowContent = getCodefulWorkflowContent( + { + workflowName: this.workflowProps.name, + workflowKind: this.workflowProps.kind ?? 'Stateful', + triggerName: this.workflowProps.triggerName, + }, + this.workflowProps.triggerName, + getCodefulWorkflowHasHttpTrigger(this.workflowProps) + ); + + if (this.projectPath) { + this.azureDetails = await getAzureConnectorDetailsForLocalProject(this.context, this.projectPath); + const connectionJson = await getConnectionsJson(this.projectPath); + this.connectionData = connectionJson ? JSON.parse(connectionJson) : {}; + } + + this.accessToken = await this.getAccessToken(); + } + + protected async onIntervalTick(): Promise { + if (this.baseUrl && !this.isCodefulRuntimeMetadataConfirmed) { + await this.refreshCodefulRuntimeMetadata(this.baseUrl); + } + } + + protected async handleCallbackInfoUpdate(baseUrl: string): Promise { + const callbackInfoUpdates = await Promise.all( + (this.workflowPropertiesList ?? []).map(async (workflow) => ({ + workflowName: workflow.name, + callbackInfo: workflow.triggerName + ? await getCodefulWorkflowCallbackInfo( + this.context, + baseUrl, + workflow.name, + workflow.triggerName, + this.apiVersion, + getCodefulWorkflowHasHttpTrigger(workflow) + ) + : undefined, + })) + ); + + for (const update of callbackInfoUpdates) { + const workflowProperty = this.workflowPropertiesList?.find((workflow) => workflow.name === update.workflowName); + if ( + update.callbackInfo?.value !== workflowProperty?.callbackInfo?.value || + update.callbackInfo?.basePath !== workflowProperty?.callbackInfo?.basePath + ) { + if (workflowProperty) { + workflowProperty.callbackInfo = update.callbackInfo; + } + this.panel?.webview.postMessage({ + command: ExtensionCommand.update_callback_info, + data: { + workflowName: update.workflowName, + callbackInfo: update.callbackInfo, + }, + }); + } + } + this.callbackInfo = this.workflowPropertiesList?.[0]?.callbackInfo; + } + + private async refreshCodefulRuntimeMetadata(baseUrl: string): Promise { + const runtimeWorkflows = await getRuntimeCodefulWorkflows(this.context, baseUrl, this.apiVersion); + if (runtimeWorkflows.length > 0) { + const refreshedWorkflowPropertiesList = await getCodefulWorkflowPropertiesList( + this.context, + runtimeWorkflows, + this.workflowFilePath, + this.codefulWorkflowFileContent, + baseUrl, + this.apiVersion, + this.localSettings + ); + const refreshedSignature = getWorkflowPropertiesListSignature(refreshedWorkflowPropertiesList); + this.isCodefulRuntimeMetadataConfirmed = true; + + if (refreshedSignature !== this.workflowPropertiesListSignature) { + this.workflowPropertiesList = refreshedWorkflowPropertiesList; + this.workflowProps = this.workflowPropertiesList[0]; + this.workflowName = this.workflowProps.name; + this.triggerName = this.workflowProps.triggerName; + this.callbackInfo = this.workflowProps.callbackInfo; + this.workflowContent = getCodefulWorkflowContent( + { + workflowName: this.workflowProps.name, + workflowKind: this.workflowProps.kind ?? 'Stateful', + triggerName: this.workflowProps.triggerName, + }, + this.workflowProps.triggerName, + getCodefulWorkflowHasHttpTrigger(this.workflowProps) + ); + this.workflowPropertiesListSignature = refreshedSignature; + this.panel?.webview.postMessage({ + command: ExtensionCommand.update_workflow_properties, + data: { + workflowProperties: this.workflowProps, + workflowPropertiesList: this.workflowPropertiesList, + kind: this.workflowProps.kind, + }, + }); + } + } + } +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/panels/localOverviewPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/localOverviewPanel.ts new file mode 100644 index 00000000000..4fdcdd3fd27 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/localOverviewPanel.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { getRequestTriggerName, getTriggerName, HTTP_METHODS, isNullOrUndefined } from '@microsoft/logic-apps-shared'; +import { localSettingsFileName, managementApiPrefix, workflowTenantIdKey } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; +import { getLocalSettingsJson } from '../../../../utils/appSettings/localSettings'; +import { getAzureConnectorDetailsForLocalProject } from '../../../../utils/codeless/common'; +import { getConnectionsJson, getLogicAppProjectRoot } from '../../../../utils/codeless/connection'; +import { getAuthorizationToken } from '../../../../utils/codeless/getAuthorizationToken'; +import { launchProjectDebugger } from '../../../../utils/vsCodeConfig/launch'; +import { isRuntimeUp } from '../../../../utils/startRuntimeApi'; +import { getWorkflowProperties } from '../utils/overviewHelpers'; +import { OverviewPanel } from './overviewPanel'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import type { ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; +import { readFileSync } from 'fs'; +import { basename, dirname, join } from 'path'; +import * as vscode from 'vscode'; +import { sendRequest } from '../../../../utils/requestUtils'; + +export default class LocalOverviewPanel extends OverviewPanel { + protected readonly workflowFilePath: string; + protected projectPath?: string; + protected localSettings: Record = {}; + + constructor(context: IActionContext, node: vscode.Uri) { + const workflowFilePath = node.fsPath; + const workflowName = basename(dirname(workflowFilePath)); + const panelName = `${vscode.workspace.name}-${workflowName}-overview`; + const panelTitle = `${workflowName}-overview`; + + super(context, workflowName, panelName, panelTitle, '2019-10-01-edge-preview', true); + + this.workflowFilePath = workflowFilePath; + } + + protected async isEnvironmentReady(): Promise { + return await isRuntimeUp(ext.workflowRuntimePort); + } + + protected async initializeOverviewData(): Promise { + this.projectPath = await getLogicAppProjectRoot(this.context, this.workflowFilePath); + + if (!isNullOrUndefined(this.projectPath) && !(await isRuntimeUp(ext.workflowRuntimePort))) { + await launchProjectDebugger(this.context, this.projectPath); + } + + this.baseUrl = this.getBaseUrl(); + if (!this.baseUrl) { + ext.outputChannel.appendLog( + localize( + 'overviewCallbackUrlUnavailable', + 'Callback URL is not available because the workflow runtime is not running. Start debugging or run "func host start" to enable the Run Trigger button.' + ) + ); + } + + this.localSettings = this.projectPath + ? (await getLocalSettingsJson(this.context, join(this.projectPath, localSettingsFileName))).Values || {} + : {}; + + this.workflowContent = JSON.parse(readFileSync(this.workflowFilePath, 'utf8')); + this.triggerName = getTriggerName(this.workflowContent.definition); + this.callbackInfo = this.baseUrl ? await this.getCallbackInfo(this.baseUrl) : undefined; + + if (this.projectPath) { + this.azureDetails = await getAzureConnectorDetailsForLocalProject(this.context, this.projectPath); + const connectionJson = await getConnectionsJson(this.projectPath); + this.connectionData = connectionJson ? JSON.parse(connectionJson) : {}; + } + + this.accessToken = await this.getAccessToken(); + this.workflowProps = getWorkflowProperties( + this.workflowName, + this.workflowContent, + this.localSettings, + this.callbackInfo, + this.triggerName + ); + } + + protected getBaseUrl(): string | undefined { + return ext.workflowRuntimePort ? `http://localhost:${ext.workflowRuntimePort}${managementApiPrefix}` : undefined; + } + + protected async getCallbackInfo(baseUrl: string): Promise { + const requestTriggerName = getRequestTriggerName(this.workflowContent.definition); + if (requestTriggerName) { + if (baseUrl) { + try { + const url = `${baseUrl}/workflows/${this.workflowName}/triggers/${requestTriggerName}/listCallbackUrl?api-version=${this.apiVersion}`; + const response: string = await sendRequest(this.context, { + url, + method: HTTP_METHODS.POST, + }); + return JSON.parse(response); + } catch (error) { + ext.outputChannel.appendLog( + localize( + 'callbackUrlApiFailed', + 'Failed to get callback URL for workflow "{0}": {1}', + this.workflowName, + error instanceof Error ? error.message : String(error) + ) + ); + return undefined; + } + } + } else { + const fallbackBaseUrl = baseUrl || `http://localhost:7071${managementApiPrefix}`; + return { + value: `${fallbackBaseUrl}/workflows/${this.workflowName}/triggers/${this.triggerName}/run?api-version=${this.apiVersion}`, + method: HTTP_METHODS.POST, + }; + } + } + + protected async getAccessToken(): Promise { + return await getAuthorizationToken(this.localSettings[workflowTenantIdKey]); + } + + protected getWorkflowNode(): vscode.Uri { + return vscode.Uri.file(this.workflowFilePath); + } +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/panels/overviewPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/overviewPanel.ts new file mode 100644 index 00000000000..30c23798fcf --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/overviewPanel.ts @@ -0,0 +1,226 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { assetsFolderName } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import type { RemoteWorkflowTreeItem } from '../../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; +import { cacheWebviewPanel, removeWebviewPanelFromCache, tryGetWebviewPanel } from '../../../../utils/codeless/common'; +import { getWebViewHTML } from '../../../../utils/codeless/getWebViewHTML'; +import { openMonitoringView } from '../../monitoringView/openMonitoringView'; +import { shouldUpdateOverviewCallbackInfo } from '../../overviewCallbackInfo'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import type { AzureConnectorDetails, ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; +import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { OverviewWorkflowProperties } from '../utils/types'; + +export abstract class OverviewPanel { + protected readonly context: IActionContext; + protected workflowName: string; + protected panelName: string; + protected panelTitle: string; + protected apiVersion: string; + protected isLocal: boolean; + protected panelGroupKey: string; + protected baseUrl?: string; + protected accessToken?: string; + protected isCodefulOverview = false; + protected callbackInfo?: ICallbackUrlResponse; + protected panel?: vscode.WebviewPanel; + protected workflowContent: any; + protected workflowProps?: OverviewWorkflowProperties; + protected workflowPropertiesList?: OverviewWorkflowProperties[]; + protected triggerName?: string; + protected azureDetails?: AzureConnectorDetails; + protected corsNotice?: string; + protected connectionData: Record = {}; + protected workflowFilePath?: string; + + private pollingInterval?: NodeJS.Timeout; + + protected constructor( + context: IActionContext, + workflowName: string, + panelName: string, + panelTitle: string, + apiVersion: string, + isLocal: boolean + ) { + this.context = context; + this.workflowName = workflowName; + this.panelName = panelName; + this.panelTitle = panelTitle; + this.apiVersion = apiVersion; + this.isLocal = isLocal; + this.panelGroupKey = ext.webViewKey.overview; + } + + protected abstract isEnvironmentReady(): Promise; + protected abstract initializeOverviewData(): Promise; + protected abstract getBaseUrl(): string | undefined; + protected abstract getCallbackInfo(baseUrl: string): Promise; + protected abstract getAccessToken(): Promise; + protected abstract getWorkflowNode(): vscode.Uri | RemoteWorkflowTreeItem | undefined; + + public async create(): Promise { + const existingPanel = tryGetWebviewPanel(this.panelGroupKey, this.panelName); + if (existingPanel) { + if (await this.isEnvironmentReady()) { + if (!existingPanel.active) { + existingPanel.reveal(vscode.ViewColumn.Active); + } + return; + } + existingPanel.dispose(); + } + + await this.initializeOverviewData(); + + this.panel = vscode.window.createWebviewPanel('workflowOverview', this.panelTitle, vscode.ViewColumn.Active, this.getPanelOptions()); + + this.panel.iconPath = { + light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'Codeless.svg')), + dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'Codeless.svg')), + }; + + this.panel.webview.html = await getWebViewHTML('vs-code-react', this.panel); + + this.panel.webview.onDidReceiveMessage(async (message) => await this.handleWebviewMsg(message), ext.context.subscriptions); + + this.panel.onDidDispose(() => this.dispose(), null, ext.context.subscriptions); + + cacheWebviewPanel(this.panelGroupKey, this.panelName, this.panel); + } + + protected getPanelOptions(): vscode.WebviewOptions & vscode.WebviewPanelOptions { + return { + enableScripts: true, + retainContextWhenHidden: true, + }; + } + + protected async handleWebviewMsg(message: any): Promise { + switch (message.command) { + case ExtensionCommand.loadRun: { + openMonitoringView(this.context, this.getWorkflowNode(), message.item.id, this.workflowFilePath); + break; + } + case ExtensionCommand.initialize: { + this.sendInitializeFrame(); + this.startPollingInterval(); + break; + } + default: + break; + } + } + + protected sendInitializeFrame(): void { + const { kind } = this.workflowProps ?? {}; + this.panel?.webview.postMessage({ + command: ExtensionCommand.initialize_frame, + data: { + apiVersion: this.apiVersion, + baseUrl: this.baseUrl, + corsNotice: this.corsNotice, + accessToken: this.accessToken, + workflowProperties: this.workflowProps, + workflowPropertiesList: this.workflowPropertiesList, + project: ProjectName.overview, + hostVersion: ext.extensionVersion, + isLocal: this.isLocal, + azureDetails: this.azureDetails, + kind: this.workflowProps?.kind ?? kind, + isCodeful: this.isCodefulOverview, + connectionData: this.connectionData, + }, + }); + } + + private startPollingInterval(): void { + let consecutiveCallbackErrors = 0; + const MAX_CALLBACK_ERRORS = 3; + + this.pollingInterval = setInterval(async () => { + await this.pollAccessToken(); + + const hasBaseUrlChanged = await this.pollBaseUrl(); + if (hasBaseUrlChanged) { + consecutiveCallbackErrors = 0; + } + + await this.onIntervalTick(); + + const shouldFetchCallback = + this.baseUrl && (hasBaseUrlChanged || (this.callbackInfo === undefined && consecutiveCallbackErrors < MAX_CALLBACK_ERRORS)); + + if (shouldFetchCallback) { + try { + await this.handleCallbackInfoUpdate(this.baseUrl!); + consecutiveCallbackErrors = 0; + } catch { + consecutiveCallbackErrors++; + if (consecutiveCallbackErrors >= MAX_CALLBACK_ERRORS) { + ext.outputChannel.appendLog( + `Stopped fetching callback URL after ${MAX_CALLBACK_ERRORS} consecutive errors. Trigger may not exist for workflow '${this.workflowName}'.` + ); + } + } + } + }, 5000); + } + + private async pollAccessToken(): Promise { + const updatedAccessToken = await this.getAccessToken(); + if (updatedAccessToken !== this.accessToken) { + this.accessToken = updatedAccessToken; + this.panel?.webview.postMessage({ + command: ExtensionCommand.update_access_token, + data: { + accessToken: this.accessToken, + }, + }); + } + } + + /** + * Polls the baseUrl and updates the webview if it has changed. + * @returns {Promise} - Returns true if the baseUrl has changed, false otherwise. + */ + private async pollBaseUrl(): Promise { + const updatedBaseUrl = this.getBaseUrl(); + if (updatedBaseUrl !== this.baseUrl) { + this.baseUrl = updatedBaseUrl; + this.panel?.webview.postMessage({ + command: ExtensionCommand.update_runtime_base_url, + data: { + baseUrl: this.baseUrl, + }, + }); + return true; + } + return false; + } + + protected async onIntervalTick(): Promise {} + + protected async handleCallbackInfoUpdate(baseUrl: string): Promise { + const updatedCallbackInfo = await this.getCallbackInfo(baseUrl); + if (shouldUpdateOverviewCallbackInfo(this.callbackInfo, updatedCallbackInfo)) { + this.callbackInfo = updatedCallbackInfo; + this.panel?.webview.postMessage({ + command: ExtensionCommand.update_callback_info, + data: { + callbackInfo: this.callbackInfo, + }, + }); + } + } + + private dispose(): void { + clearInterval(this.pollingInterval); + removeWebviewPanelFromCache(this.panelGroupKey, this.panelName); + } +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/panels/remoteOverviewPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/remoteOverviewPanel.ts new file mode 100644 index 00000000000..fbc1bf88f78 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/panels/remoteOverviewPanel.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { getTriggerName } from '@microsoft/logic-apps-shared'; +import { workflowAppApiVersion } from '../../../../../constants'; +import { localize } from '../../../../../localize'; +import type { RemoteWorkflowTreeItem } from '../../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; +import { getWorkflowManagementBaseURI } from '../../../../utils/codeless/common'; +import { getAuthorizationTokenFromNode } from '../../../../utils/codeless/getAuthorizationToken'; +import { getWorkflowProperties, normalizeLocation } from '../utils/overviewHelpers'; +import { OverviewPanel } from './overviewPanel'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import type { ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; +import type * as vscode from 'vscode'; + +export default class RemoteOverviewPanel extends OverviewPanel { + private readonly node: RemoteWorkflowTreeItem; + + constructor(context: IActionContext, node: RemoteWorkflowTreeItem) { + const workflowName = node.name; + const panelName = `${node.id}-${workflowName}-overview`; + const panelTitle = `${workflowName}-overview`; + + super(context, workflowName, panelName, panelTitle, workflowAppApiVersion, false); + + this.node = node; + this.corsNotice = localize('CorsNotice', 'To view runs, set "*" to allowed origins in the CORS setting.'); + } + + protected async isEnvironmentReady(): Promise { + return true; + } + + protected async initializeOverviewData(): Promise { + this.workflowContent = this.node.workflowFileContent; + this.triggerName = getTriggerName(this.workflowContent.definition); + this.baseUrl = this.getBaseUrl(); + this.accessToken = await this.getAccessToken(); + this.callbackInfo = await this.getCallbackInfo(this.baseUrl!); + this.azureDetails = { + enabled: true, + accessToken: this.accessToken, + subscriptionId: this.node?.subscription?.subscriptionId, + location: normalizeLocation(this.node?.parent?.parent?.site.location), + workflowManagementBaseUrl: this.node?.parent?.subscription?.environment?.resourceManagerEndpointUrl, + tenantId: this.node?.parent?.subscription?.tenantId, + resourceGroupName: this.node?.parent?.parent?.site.resourceGroup, + }; + this.workflowProps = getWorkflowProperties(this.workflowName, this.workflowContent, {}, this.callbackInfo, this.triggerName); + } + + protected getBaseUrl(): string | undefined { + return getWorkflowManagementBaseURI(this.node); + } + + protected async getCallbackInfo(baseUrl: string): Promise { + return await this.node.getCallbackUrl(this.node, baseUrl, this.triggerName, this.apiVersion); + } + + protected async getAccessToken(): Promise { + return await getAuthorizationTokenFromNode(this.node); + } + + protected getWorkflowNode(): vscode.Uri | RemoteWorkflowTreeItem { + return this.node; + } +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/utils/codefulHelpers.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/utils/codefulHelpers.ts new file mode 100644 index 00000000000..037f3e9d1a6 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/utils/codefulHelpers.ts @@ -0,0 +1,357 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { HTTP_METHODS } from '@microsoft/logic-apps-shared'; +import { managementApiPrefix } from '../../../../../constants'; +import { ext } from '../../../../../extensionVariables'; +import { localize } from '../../../../../localize'; +import { sendRequest } from '../../../../utils/requestUtils'; +import { delay } from '../../../../utils/delay'; +import { + detectCodefulWorkflow, + extractTriggerNameFromCodeful, + extractHttpTriggerName, + hasHttpRequestTrigger, +} from '../../../../utils/codeful'; +import { getCodefulWorkflowMetadata } from '../../../../languageServer/languageServer'; +import { getWorkflowProperties } from './overviewHelpers'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import type { ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; +import type { CodefulTriggerData, CodefulWorkflowData, CodefulWorkflowDataResult, OverviewWorkflowProperties } from './types'; +import { readFileSync, readdirSync } from 'fs'; +import { basename, dirname, join } from 'path'; + +export async function getCodefulWorkflowCallbackInfo( + context: IActionContext, + baseUrl: string, + workflowName: string, + triggerName: string, + apiVersion: string, + hasRequestTrigger: boolean +): Promise { + if (hasRequestTrigger) { + if (!baseUrl) { + ext.outputChannel.appendLog( + localize( + 'codefulCallbackUrlNoBaseUrl', + 'Cannot get callback URL for codeful workflow "{0}" with request trigger: baseUrl is not available. Make sure the workflow runtime is running.', + workflowName + ) + ); + return undefined; + } + + const url = `${baseUrl}/workflows/${workflowName}/triggers/${triggerName}/listCallbackUrl?api-version=${apiVersion}`; + try { + const response: string = await sendRequest(context, { + url, + method: HTTP_METHODS.POST, + }); + return JSON.parse(response); + } catch (error) { + ext.outputChannel.appendLog( + localize( + 'codefulCallbackUrlApiFailed', + 'Falling back to CodefulWorkflowHttpTrigger URL for codeful workflow "{0}" trigger "{1}" (listCallbackUrl failed: {2}).', + workflowName, + triggerName, + error instanceof Error ? error.message : String(error) + ) + ); + return getCodefulHttpTriggerCallbackUrl(baseUrl, workflowName, triggerName); + } + } + + const fallbackBaseUrl = baseUrl || `http://localhost:7071${managementApiPrefix}`; + return { + value: `${fallbackBaseUrl}/workflows/${workflowName}/triggers/${triggerName}/run?api-version=${apiVersion}`, + method: HTTP_METHODS.POST, + }; +} + +export function getCodefulHttpTriggerCallbackUrl(baseUrl: string, workflowName: string, triggerName: string): ICallbackUrlResponse { + const origin = baseUrl.split(managementApiPrefix)[0] || baseUrl.replace(/\/runtime\/webhooks\/workflow\/api\/management$/, ''); + return { + value: `${origin}/api/CodefulWorkflowHttpTrigger/scaleUnits/prod-00/workflows/${workflowName}/triggers/${triggerName}/invoke`, + method: HTTP_METHODS.POST, + }; +} + +export async function getCodefulWorkflowPropertiesList( + context: IActionContext, + codefulWorkflows: CodefulWorkflowData[], + workflowFilePath: string, + workflowContent: string, + baseUrl: string | undefined, + apiVersion: string, + localSettings: Record +): Promise { + return await Promise.all( + codefulWorkflows.map(async (workflowData) => { + const triggerData = + baseUrl && (!workflowData.triggerName || !workflowData.triggerType) + ? await getCodefulTriggerData(context, workflowData.workflowName, baseUrl, apiVersion).catch(() => undefined) + : undefined; + const resolvedWorkflowData: CodefulWorkflowData = { + ...workflowData, + triggerName: workflowData.triggerName ?? triggerData?.triggerName, + triggerType: workflowData.triggerType ?? triggerData?.triggerType, + triggerKind: workflowData.triggerKind ?? triggerData?.triggerKind, + }; + const hasHttpTrigger = isHttpRequestTrigger(resolvedWorkflowData, workflowContent); + const workflowTriggerName = + resolvedWorkflowData.triggerName ?? + (await getCodefulWorkflowMetadata(workflowFilePath) + .then((metadata) => metadata?.triggerName) + .catch(() => undefined)) ?? + getFallbackCodefulTriggerName(workflowContent, hasHttpTrigger); + const codefulWorkflowContent = getCodefulWorkflowContent(resolvedWorkflowData, workflowTriggerName, hasHttpTrigger); + const codefulCallbackInfo = + baseUrl && workflowTriggerName + ? await getCodefulWorkflowCallbackInfo( + context, + baseUrl, + resolvedWorkflowData.workflowName, + workflowTriggerName, + apiVersion, + hasHttpTrigger + ) + : undefined; + + return getWorkflowProperties( + resolvedWorkflowData.workflowName, + codefulWorkflowContent, + localSettings, + codefulCallbackInfo, + workflowTriggerName + ); + }) + ); +} + +export async function getCodefulWorkflowDataList( + context: IActionContext, + workflowFilePath: string, + workflowContent: string, + baseUrl: string | undefined, + apiVersion: string +): Promise { + if (baseUrl) { + const runtimeWorkflows = await getRuntimeCodefulWorkflows(context, baseUrl, apiVersion); + if (runtimeWorkflows.length > 0) { + return { + workflows: runtimeWorkflows, + fromRuntime: true, + }; + } + } + + const hasHttpTrigger = hasHttpRequestTrigger(workflowContent); + const fallbackTriggerName = getFallbackCodefulTriggerName(workflowContent, hasHttpTrigger); + const workflowNames = getCodefulWorkflowNames(workflowFilePath); + if (workflowNames.length > 0) { + return { + workflows: workflowNames.map((workflowName) => ({ + workflowName, + workflowKind: 'Stateful', + triggerName: fallbackTriggerName, + triggerType: hasHttpTrigger ? 'Request' : undefined, + triggerKind: hasHttpTrigger ? 'Http' : undefined, + })), + fromRuntime: false, + }; + } + + const workflowInfo = detectCodefulWorkflow(workflowContent); + return { + workflows: workflowInfo + ? [ + { + workflowName: workflowInfo.workflowName, + workflowKind: workflowInfo.workflowType === 'agent' ? 'Agent' : 'Stateful', + triggerName: fallbackTriggerName, + triggerType: hasHttpTrigger ? 'Request' : undefined, + triggerKind: hasHttpTrigger ? 'Http' : undefined, + }, + ] + : [], + fromRuntime: false, + }; +} + +export async function getRuntimeCodefulWorkflows( + context: IActionContext, + baseUrl: string, + apiVersion: string +): Promise { + const workflowsUrl = `${baseUrl}/workflows?api-version=${apiVersion}`; + const maxRetries = 4; + const initialDelayMs = 1000; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const workflowsResponse = await sendRequest(context, { + url: workflowsUrl, + method: HTTP_METHODS.GET, + }); + const parsed = JSON.parse(workflowsResponse); + const workflows: { + name: string; + kind?: string; + triggers?: Record; + }[] = Array.isArray(parsed) ? parsed : (parsed?.value ?? []); + + if (workflows.length > 0) { + return workflows.map((workflow) => { + const [runtimeTriggerName, trigger] = Object.entries(workflow.triggers ?? {})[0] ?? []; + return { + workflowName: workflow.name, + workflowKind: workflow.kind ?? 'Stateful', + triggerName: runtimeTriggerName, + triggerType: trigger?.properties?.type ?? trigger?.type, + triggerKind: trigger?.properties?.kind ?? trigger?.kind, + }; + }); + } + } catch (error) { + if (attempt === maxRetries - 1) { + ext.outputChannel.appendLog( + localize( + 'codefulWorkflowListApiFailed', + 'Failed to get codeful workflows from the runtime at "{0}": {1}', + workflowsUrl, + error instanceof Error ? error.message : String(error) + ) + ); + } + } + + if (attempt < maxRetries - 1) { + await delay(initialDelayMs * 2 ** attempt); + } + } + + return []; +} + +export function getCodefulWorkflowNames(filePath: string): string[] { + const workflowNames: string[] = []; + const visitedFiles = new Set(); + const projectDir = dirname(filePath); + + const extractWorkflowsFromFile = (currentFilePath: string): void => { + if (visitedFiles.has(currentFilePath)) { + return; + } + visitedFiles.add(currentFilePath); + + try { + const fileContent = readFileSync(currentFilePath, 'utf8'); + const workflowRegex = /(?:CreateConversationalAgent|CreateAgentWorkflow|CreateStatefulWorkflow)\s*\(\s*["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = workflowRegex.exec(fileContent)) !== null) { + const workflowName = match[1]; + if (workflowName && !workflowNames.includes(workflowName)) { + workflowNames.push(workflowName); + } + } + + const files = readdirSync(projectDir); + for (const file of files) { + if (file.endsWith('.cs') && file !== basename(currentFilePath)) { + extractWorkflowsFromFile(join(projectDir, file)); + } + } + } catch (error) { + ext.outputChannel.appendLog( + localize( + 'codefulWorkflowNameParseFailed', + 'Failed to parse codeful workflow names from "{0}": {1}', + currentFilePath, + error instanceof Error ? error.message : String(error) + ) + ); + } + }; + + extractWorkflowsFromFile(filePath); + return workflowNames; +} + +export function getFallbackCodefulTriggerName(workflowContent: string, hasHttpTrigger: boolean): string | undefined { + return hasHttpTrigger ? extractHttpTriggerName(workflowContent) : extractTriggerNameFromCodeful(workflowContent); +} + +export function isHttpRequestTrigger(workflowData: CodefulWorkflowData, workflowContent?: string): boolean { + const triggerType = workflowData.triggerType?.toLowerCase(); + const triggerKind = workflowData.triggerKind?.toLowerCase(); + if (triggerType === 'request') { + return !triggerKind || triggerKind === 'http'; + } + + if (triggerKind === 'http') { + return true; + } + + return workflowContent ? hasHttpRequestTrigger(workflowContent) : false; +} + +export function getCodefulWorkflowHasHttpTrigger(workflowProperties: OverviewWorkflowProperties): boolean { + const trigger = workflowProperties.triggerName ? workflowProperties.definition?.triggers?.[workflowProperties.triggerName] : undefined; + return trigger?.type?.toLowerCase() === 'request' && trigger?.kind?.toLowerCase() === 'http'; +} + +export function getCodefulWorkflowContent( + workflowData: CodefulWorkflowData, + triggerName: string | undefined, + hasHttpTrigger: boolean +): any { + return { + definition: { + $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', + contentVersion: '1.0.0.0', + triggers: triggerName + ? { + [triggerName]: hasHttpTrigger + ? { + type: 'Request', + kind: 'Http', + inputs: { + schema: {}, + }, + } + : { + type: 'Unknown', + }, + } + : {}, + actions: {}, + outputs: {}, + }, + kind: workflowData.workflowKind ?? 'Stateful', + }; +} + +export async function getCodefulTriggerData( + context: IActionContext, + workflowName: string, + baseUrl: string, + apiVersion: string +): Promise { + const triggersUrl = `${baseUrl}/workflows/${workflowName}/triggers?api-version=${apiVersion}`; + const response: string = await sendRequest(context, { + url: triggersUrl, + method: HTTP_METHODS.GET, + }); + const triggersData = JSON.parse(response); + + if (triggersData?.value?.length > 0) { + const trigger = triggersData.value[0]; + return { + triggerName: trigger.name, + triggerType: trigger.properties?.type ?? trigger.type, + triggerKind: trigger.properties?.kind ?? trigger.kind, + }; + } +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/utils/overviewHelpers.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/utils/overviewHelpers.ts new file mode 100644 index 00000000000..0f7c9ceaff8 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/utils/overviewHelpers.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../../localize'; +import { getStandardAppData } from '../../../../utils/codeless/common'; +import type { ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; +import type { OverviewWorkflowProperties } from './types'; + +export function getWorkflowProperties( + workflowName: string, + workflowContent: any, + localSettings: Record, + callbackInfo: ICallbackUrlResponse | undefined, + triggerName: string | undefined +): OverviewWorkflowProperties { + const { name, kind, operationOptions, statelessRunMode } = getStandardAppData(workflowName, workflowContent); + return { + name, + stateType: getWorkflowStateType(name, kind, localSettings), + operationOptions, + statelessRunMode, + callbackInfo, + triggerName, + definition: workflowContent.definition, + kind, + }; +} + +export function getWorkflowStateType(workflowName: string, kind: string, settings: Record): string { + const operationOptionsSetting = `Workflows.${workflowName}.OperationOptions`; + const flowKindLower = kind?.toLowerCase(); + return flowKindLower === 'stateful' + ? localize('logicapps.stateful', 'Stateful') + : flowKindLower === 'agent' + ? localize('logicapps.agent', 'Agent') + : settings[operationOptionsSetting]?.toLowerCase() === 'withstatelessrunhistory' + ? localize('logicapps.statelessDebug', 'Stateless (debug mode)') + : localize('logicapps.stateless', 'Stateless'); +} + +export function getWorkflowPropertiesListSignature(workflowPropertiesList: OverviewWorkflowProperties[] | undefined): string { + return JSON.stringify( + (workflowPropertiesList ?? []).map((workflowProperties) => ({ + name: workflowProperties.name, + kind: workflowProperties.kind, + triggerName: workflowProperties.triggerName, + callbackUrl: workflowProperties.callbackInfo?.value, + })) + ); +} + +export function normalizeLocation(location: string): string { + if (!location) { + return ''; + } + return location.toLowerCase().replace(/ /g, ''); +} diff --git a/apps/vs-code-designer/src/app/commands/workflows/overview/utils/types.ts b/apps/vs-code-designer/src/app/commands/workflows/overview/utils/types.ts new file mode 100644 index 00000000000..a288c8f4a1c --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/overview/utils/types.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { LogicAppsV2 } from '@microsoft/logic-apps-shared'; +import type { ICallbackUrlResponse } from '@microsoft/vscode-extension-logic-apps'; + +export interface OverviewWorkflowProperties { + name: string; + stateType: string; + operationOptions?: string; + statelessRunMode?: string; + callbackInfo?: ICallbackUrlResponse; + triggerName?: string; + definition: LogicAppsV2.WorkflowDefinition; + kind?: string; +} + +export interface CallbackInfoUpdate { + workflowName: string; + callbackInfo?: ICallbackUrlResponse; +} + +export interface CodefulWorkflowData { + workflowName: string; + workflowKind: string; + triggerName?: string; + triggerType?: string; + triggerKind?: string; +} + +export interface CodefulWorkflowDataResult { + workflows: CodefulWorkflowData[]; + fromRuntime: boolean; +} + +export interface CodefulTriggerData { + triggerName?: string; + triggerType?: string; + triggerKind?: string; +} diff --git a/apps/vs-code-react/src/app/designer/servicesHelper.ts b/apps/vs-code-react/src/app/designer/servicesHelper.ts index fa4e7a15072..14c4afbfb57 100644 --- a/apps/vs-code-react/src/app/designer/servicesHelper.ts +++ b/apps/vs-code-react/src/app/designer/servicesHelper.ts @@ -31,7 +31,7 @@ import type { LocalConnectionModel, OperationManifest, } from '@microsoft/logic-apps-shared'; -import type { IDesignerPanelMetadata, MessageToVsix } from '@microsoft/vscode-extension-logic-apps'; +import type { DesignerPanelMetadata, MessageToVsix } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, HttpClient } from '@microsoft/vscode-extension-logic-apps'; import type { QueryClient } from '@tanstack/react-query'; import type { WebviewApi } from 'vscode-webview'; @@ -70,7 +70,7 @@ export const getDesignerServices = ( apiHubDetails: ApiHubServiceDetails, isLocal: boolean, connectionData: ConnectionsData, - panelMetadata: IDesignerPanelMetadata | null, + panelMetadata: DesignerPanelMetadata | null, createFileSystemConnection: (connectionInfo: ConnectionCreationInfo, connectionName: string) => Promise, vscode: WebviewApi, oauthRedirectUrl: string, diff --git a/apps/vs-code-react/src/run-service/types.ts b/apps/vs-code-react/src/run-service/types.ts index 0b4ebcaf368..0675d6e47e9 100644 --- a/apps/vs-code-react/src/run-service/types.ts +++ b/apps/vs-code-react/src/run-service/types.ts @@ -5,7 +5,7 @@ import type { MapDefinitionData, ExtensionCommand, ConnectionsData, - IDesignerPanelMetadata, + DesignerPanelMetadata, CompleteFileSystemConnectionData, ICallbackUrlResponse, } from '@microsoft/vscode-extension-logic-apps'; @@ -271,7 +271,7 @@ export interface CompleteFileSystemConnectionMessage { export interface UpdatePanelMetadataMessage { command: typeof ExtensionCommand.update_panel_metadata; data: { - panelMetadata: IDesignerPanelMetadata; + panelMetadata: DesignerPanelMetadata; connectionData: ConnectionsData; apiHubServiceDetails: ApiHubServiceDetails; }; diff --git a/apps/vs-code-react/src/state/DesignerSlice.ts b/apps/vs-code-react/src/state/DesignerSlice.ts index 4796c7c9eee..f730e16013c 100644 --- a/apps/vs-code-react/src/state/DesignerSlice.ts +++ b/apps/vs-code-react/src/state/DesignerSlice.ts @@ -2,13 +2,13 @@ import type { ApiHubServiceDetails, ConnectionsData, ListDynamicValue } from '@m import type { CompleteFileSystemConnectionData, ICallbackUrlResponse, - IDesignerPanelMetadata, + DesignerPanelMetadata, } from '@microsoft/vscode-extension-logic-apps'; import type { PayloadAction, Slice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; export interface DesignerState { - panelMetaData: IDesignerPanelMetadata | null; + panelMetaData: DesignerPanelMetadata | null; connectionData: ConnectionsData; baseUrl: string; workflowRuntimeBaseUrl: string; @@ -97,7 +97,7 @@ export const designerSlice: Slice = createSlice({ updatePanelMetadata: ( state, action: PayloadAction<{ - panelMetadata: IDesignerPanelMetadata; + panelMetadata: DesignerPanelMetadata; connectionData: ConnectionsData; apiHubServiceDetails: ApiHubServiceDetails; }> diff --git a/apps/vs-code-react/src/state/LanguageServerSlice.ts b/apps/vs-code-react/src/state/LanguageServerSlice.ts index 81a390afb15..9cb3c1edd9c 100644 --- a/apps/vs-code-react/src/state/LanguageServerSlice.ts +++ b/apps/vs-code-react/src/state/LanguageServerSlice.ts @@ -1,10 +1,10 @@ import type { ApiHubServiceDetails, ConnectionsData } from '@microsoft/logic-apps-shared'; -import type { CompleteFileSystemConnectionData, IDesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; +import type { CompleteFileSystemConnectionData, DesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; import type { PayloadAction, Slice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; export interface LanguageServerState { - panelMetaData: IDesignerPanelMetadata | null; + panelMetaData: DesignerPanelMetadata | null; connectionData: ConnectionsData; baseUrl: string; workflowRuntimeBaseUrl: string; diff --git a/libs/vscode-extension/src/lib/models/codeful.ts b/libs/vscode-extension/src/lib/models/codeful.ts new file mode 100644 index 00000000000..62cd3dc5167 --- /dev/null +++ b/libs/vscode-extension/src/lib/models/codeful.ts @@ -0,0 +1,10 @@ +export interface CodeSelection { + Start: { + Line: number; + Character: number; + }; + End: { + Line: number; + Character: number; + }; +} diff --git a/libs/vscode-extension/src/lib/models/connection.ts b/libs/vscode-extension/src/lib/models/connection.ts index 28c0f356447..102cbd60340 100644 --- a/libs/vscode-extension/src/lib/models/connection.ts +++ b/libs/vscode-extension/src/lib/models/connection.ts @@ -1,3 +1,7 @@ +import type { Artifacts, FileDetails } from './artifact'; +import type { Parameter } from './parameter'; +import type { AzureConnectorDetails } from './workflow'; + export interface ConnectionReferenceModel { connection: { id: string; @@ -144,3 +148,19 @@ export interface FileSystemConnectionInfo { displayName?: string; parameterName?: string; } + +export interface ConnectionPanelMetadata { + panelId: string; + appSettingNames?: string[]; + connectionsData: string; + parametersData: Record; + localSettings: Record; + azureDetails: AzureConnectorDetails; + workflowDetails: Record; + workflowName: string; + artifacts: Artifacts; + accessToken?: string; + schemaArtifacts: FileDetails[]; + mapArtifacts: Record; + extensionBundleVersion?: string; +} diff --git a/libs/vscode-extension/src/lib/models/index.ts b/libs/vscode-extension/src/lib/models/index.ts index 2a92425a795..3ef01d3f697 100644 --- a/libs/vscode-extension/src/lib/models/index.ts +++ b/libs/vscode-extension/src/lib/models/index.ts @@ -9,6 +9,7 @@ export * from './connection'; export * from './project'; export * from './host'; export * from './cliFeed'; +export * from './codeful'; export * from './templates'; export * from './bundleFeed'; export * from './tasks'; diff --git a/libs/vscode-extension/src/lib/models/workflow.ts b/libs/vscode-extension/src/lib/models/workflow.ts index 43494325d28..21e041f0a11 100644 --- a/libs/vscode-extension/src/lib/models/workflow.ts +++ b/libs/vscode-extension/src/lib/models/workflow.ts @@ -9,7 +9,7 @@ export interface ILocalSettingsJson { ConnectionStrings?: { [key: string]: string }; } -export interface IDesignerPanelMetadata { +export interface DesignerPanelMetadata { panelId: string; appSettingNames?: string[]; standardApp: StandardApp;