Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 0 additions & 52 deletions Localize/lang/strings.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { LogEntryLevel } from '@microsoft/logic-apps-shared';
import type { SchemaType, MapMetadata, IFileSysTreeItem } from '@microsoft/logic-apps-shared';
import type { IActionContext } from '@microsoft/vscode-azext-utils';
import { callWithTelemetryAndErrorHandlingSync } from '@microsoft/vscode-azext-utils';
import type { MapDefinitionData, MessageToVsix, MessageToWebview } from '@microsoft/vscode-extension-logic-apps';
import type { MapDefinitionData, MessageToVsix } from '@microsoft/vscode-extension-logic-apps';
import { ExtensionCommand, Platform, ProjectName } from '@microsoft/vscode-extension-logic-apps';
import {
copyFileSync,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,6 @@ export async function openConnectionView(
range: CodeSelection,
currentConnectionId: string
): Promise<void> {
const connectionPanel = new ConnectionPanel(
context,
filePath,
methodName,
connectorName,
connectorType,
range,
currentConnectionId
);
const connectionPanel = new ConnectionPanel(context, filePath, methodName, connectorName, connectorType, range, currentConnectionId);
await connectionPanel.create();
}
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,7 @@ export default class ConnectionPanel extends DesignerPanel {
throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.'));
}

const [
connectionsData,
parametersData,
artifacts,
bundleVersionNumber,
azureDetails
] = await Promise.all([
const [connectionsData, parametersData, artifacts, bundleVersionNumber, azureDetails] = await Promise.all([
getConnectionsFromFile(this.context, this.workflowFilePath),
getParametersFromFile(this.context, this.workflowFilePath),
getArtifactsInLocalProject(projectPath),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,7 @@ describe('LocalDesignerPanel', () => {

const instance = new LocalDesignerPanel(mockContext, mockUri);

await expect(instance.create()).rejects.toThrow(
'Design time failed to start for project /test/project. func host failed to start'
);
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ 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 {
azurePublicBaseUrl,
workflowManagementBaseURIKey,
designerVersionSetting,
defaultDesignerVersion,
suppressDesignerVersionNotification,
} from '../../../../../constants';
import { ext } from '../../../../../extensionVariables';
import { localize } from '../../../../../localize';
import type { WebviewPanel, WebviewOptions, WebviewPanelOptions } from 'vscode';
Expand Down Expand Up @@ -213,10 +219,7 @@ export abstract class DesignerPanel {
} else if (selection === enablePreview) {
await config.update(designerVersionSetting, 2, ConfigurationTarget.Global);
const closeButton = localize('close', 'Close');
const reopenMessage = localize(
'closeToApply',
'Setting updated. Please close and reopen the workflow to apply the new experience.'
);
const reopenMessage = localize('closeToApply', 'Setting updated. Please close and reopen the workflow to apply the new experience.');
const reopenSelection = await window.showInformationMessage(reopenMessage, closeButton);
if (reopenSelection === closeButton) {
this.panel?.dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
saveConnectionReferences,
saveCustomCodeStandard,
} from '../../../../utils/codeless/connection';
import { getAuthorizationToken } from '../../../../utils/codeless/getAuthorizationToken';
import { saveWorkflowParameter } from '../../../../utils/codeless/parameter';
import { startDesignTimeApi } from '../../../../utils/codeless/startDesignTimeApi';
import { sendRequest } from '../../../../utils/requestUtils';
Expand Down Expand Up @@ -58,6 +59,7 @@ export default class LocalDesignerPanel extends DesignerPanel {
private projectPath?: string;
private panelMetadata?: DesignerPanelMetadata;
private workflowRuntimeBaseUrlInterval?: NodeJS.Timeout;
private accessTokenInterval?: NodeJS.Timeout;

constructor(context: IActionContext, node: Uri, runId?: string) {
const workflowName = path.basename(path.dirname(node.fsPath));
Expand Down Expand Up @@ -178,6 +180,7 @@ export default class LocalDesignerPanel extends DesignerPanel {
this.panel.onDidDispose(
() => {
clearInterval(this.workflowRuntimeBaseUrlInterval);
clearInterval(this.accessTokenInterval);
removeWebviewPanelFromCache(this.panelGroupKey, this.panelName);
},
null,
Expand Down Expand Up @@ -207,6 +210,28 @@ export default class LocalDesignerPanel extends DesignerPanel {
}
}, 3000);

// Refresh access token periodically to prevent stale-token failures on save
this.accessTokenInterval = setInterval(async () => {
try {
const tenantId = this.panelMetadata?.azureDetails?.tenantId;
const updatedAccessToken = await getAuthorizationToken(tenantId);
// Guard against "Bearer undefined" — only update if we got a real token
if (updatedAccessToken && !updatedAccessToken.endsWith('undefined') && updatedAccessToken !== this.panelMetadata?.accessToken) {
if (this.panelMetadata) {
this.panelMetadata.accessToken = updatedAccessToken;
}
this.panel?.webview.postMessage({
command: ExtensionCommand.update_access_token,
data: {
accessToken: updatedAccessToken,
},
});
}
} catch {
// Silently ignore token refresh failures — the existing token may still be valid
}
}, 30000);

this.panel?.webview.postMessage({
command: ExtensionCommand.initialize_frame,
data: {
Expand Down Expand Up @@ -603,6 +628,10 @@ export default class LocalDesignerPanel extends DesignerPanel {

/**
* Merges parameters from JSON.
* For parameters that exist only in the file (not in the designer output or panel),
* they are preserved as-is. For parameters that exist in both the file and designer,
* file-only properties (e.g., metadata, description) are preserved while designer
* properties take precedence.
* @param filePath The file path of the parameters JSON file.
* @param definitionParameters The parameters from the designer.
* @param panelParameterRecord The parameters from the panel
Expand All @@ -617,11 +646,37 @@ export default class LocalDesignerPanel extends DesignerPanel {

Object.entries(jsonParameters).forEach(([key, parameter]) => {
if (!definitionParameters[key] && !panelParameterRecord[key]) {
// Parameter exists only in the file — preserve it entirely
definitionParameters[key] = parameter;
} else if (definitionParameters[key]) {
// Parameter exists in both — deep-merge file properties that the designer doesn't emit
this.deepMergePreserveExisting(definitionParameters[key], parameter as Record<string, any>);
}
});
}

/**
* Recursively merges properties from source into target, only adding
* properties that don't already exist in target. For nested objects,
* merges recursively so nested file-only fields are preserved.
*/
private deepMergePreserveExisting(target: Record<string, any>, source: Record<string, any>): void {
for (const prop of Object.keys(source)) {
if (!(prop in target)) {
target[prop] = source[prop];
} else if (
typeof target[prop] === 'object' &&
target[prop] !== null &&
!Array.isArray(target[prop]) &&
typeof source[prop] === 'object' &&
source[prop] !== null &&
!Array.isArray(source[prop])
) {
this.deepMergePreserveExisting(target[prop], source[prop]);
}
}
}

/**
* Reloads the webview panel and updates the view state.
* @param webviewPanel The web view panel to update.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class RemoteDesignerPanel extends DesignerPanel {
azureDetails: this.panelMetadata.azureDetails,
workflowDetails: this.panelMetadata.workflowDetails,
});

this.panelMetadata.mapArtifacts = this.mapArtifacts as Record<string, FileDetails[]>;
this.panelMetadata.schemaArtifacts = this.schemaArtifacts as FileDetails[];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ export async function openMonitoringView(
return;
}

const monitoringPanel = node instanceof Uri
? new LocalMonitoringPanel(context, runId, workflowFilePath)
: new RemoteMonitoringPanel(context, runId, workflowFilePath, node);
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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,23 +192,16 @@ export default class LocalMonitoringPanel extends MonitoringPanel {
throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.'));
}

const [
connectionsData,
parametersData,
customCodeData,
bundleVersionNumber,
azureDetails,
artifacts,
workflowContent,
] = await Promise.all([
getConnectionsFromFile(this.context, this.workflowFilePath),
getParametersFromFile(this.context, this.workflowFilePath),
getCustomCodeFromFiles(this.workflowFilePath),
getBundleVersionNumber(projectPath),
getAzureConnectorDetailsForLocalProject(this.context, projectPath),
getArtifactsInLocalProject(projectPath),
this.getWorkflowContent(),
]);
const [connectionsData, parametersData, customCodeData, bundleVersionNumber, azureDetails, artifacts, workflowContent] =
await Promise.all([
getConnectionsFromFile(this.context, this.workflowFilePath),
getParametersFromFile(this.context, this.workflowFilePath),
getCustomCodeFromFiles(this.workflowFilePath),
getBundleVersionNumber(projectPath),
getAzureConnectorDetailsForLocalProject(this.context, projectPath),
getArtifactsInLocalProject(projectPath),
this.getWorkflowContent(),
]);

const localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values!;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,7 @@ import * as vscode from 'vscode';
export abstract class MonitoringPanel extends DesignerPanel {
protected workflowFilePath: string;

protected constructor(
context: IActionContext,
runId: string,
workflowFilePath: string,
isLocal: boolean,
apiVersion: string
) {
protected constructor(context: IActionContext, runId: string, workflowFilePath: string, isLocal: boolean, apiVersion: string) {
const runName = runId ? runId.split('/').slice(-1)[0] : '';
const workflowName = runId.split('/').slice(-3)[0];
const panelNamePrefix = isLocal ? `${vscode.workspace.name}-` : '';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getAuthorizationToken, getAuthorizationTokenFromNode, getCloudHost } from '../getAuthorizationToken';

// The module-level mock for '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode'
// is aliased via vitest.config.ts to '__mocks__/vscode-azext-azureauth.ts'.
// We import and spy on it to control return values per test.
import * as azureAuth from '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode';
import * as vscode from 'vscode';

vi.mock('@microsoft/vscode-azext-azureauth', () => ({
getConfiguredAzureEnv: vi.fn(() => ({
managementEndpointUrl: 'https://management.azure.com',
})),
}));

describe('getAuthorizationToken', () => {
beforeEach(() => {
vi.restoreAllMocks();
// Mock vscode.workspace.getConfiguration to return a config with get()
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn(() => false),
} as any);
});

it('should return a Bearer token when session has an accessToken', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'test-token-123',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const token = await getAuthorizationToken('test-tenant');
expect(token).toBe('Bearer test-token-123');
});

it('should return "Bearer undefined" when session returns no accessToken', async () => {
// Note: getAuthorizationToken does not guard against undefined accessToken.
// The token refresh interval in openDesignerForLocalProject guards against this
// by checking for "undefined" in the returned string before propagating it.
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const token = await getAuthorizationToken();
expect(token).toBe('Bearer undefined');
});
Comment thread
bjbennet marked this conversation as resolved.

it('should propagate errors when session acquisition fails', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockRejectedValue(new Error('Auth session expired'));

await expect(getAuthorizationToken()).rejects.toThrow('Auth session expired');
});

it('should pass tenantId to getSessionFromVSCode', async () => {
const spy = vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'tenant-token',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

await getAuthorizationToken('specific-tenant-id');
expect(spy).toHaveBeenCalledWith(undefined, 'specific-tenant-id', expect.any(Object));
});
});

describe('getAuthorizationTokenFromNode', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn(() => false),
} as any);
});

it('should throw when node is null/undefined', async () => {
await expect(getAuthorizationTokenFromNode(null as any)).rejects.toThrow();
});

it('should throw when node has no subscription', async () => {
const node = {} as any;
await expect(getAuthorizationTokenFromNode(node)).rejects.toThrow();
});

it('should return Bearer token from node subscription credentials', async () => {
const node = {
subscription: {
tenantId: 'tenant-1',
credentials: {
getToken: vi.fn().mockResolvedValue({ token: 'node-token-abc' }),
},
},
} as any;

const token = await getAuthorizationTokenFromNode(node);
expect(token).toBe('Bearer node-token-abc');
});

it('should fall back to getAuthorizationToken when credentials.getToken returns null', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'fallback-token',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const node = {
subscription: {
tenantId: 'tenant-1',
credentials: {
getToken: vi.fn().mockResolvedValue(null),
},
},
} as any;

const token = await getAuthorizationTokenFromNode(node);
expect(token).toBe('Bearer fallback-token');
});

it('should fall back to getAuthorizationToken when no credentials exist', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'fallback-token-2',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const node = {
subscription: {
tenantId: 'tenant-2',
credentials: undefined,
},
} as any;

const token = await getAuthorizationTokenFromNode(node);
expect(token).toBe('Bearer fallback-token-2');
});
});

describe('getCloudHost', () => {
it('should return the managementEndpointUrl from configured environment', async () => {
const host = await getCloudHost();
expect(host).toBe('https://management.azure.com');
});
});
Loading
Loading