diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 37a205a4e18..c04be0f3dc4 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -154,8 +154,13 @@ jobs: apps/vs-code-designer/src/app/commands/deploy/deploy.ts apps/vs-code-designer/src/app/commands/workflows/exportLogicApp.ts apps/vs-code-designer/src/app/utils/startRuntimeApi.ts + apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts + apps/vs-code-designer/src/app/utils/binaries.ts apps/vs-code-designer/src/extensionVariables.ts apps/vs-code-designer/src/main.ts + # VS Code connector/runtime orchestration glue; focused behavior is + # covered by command/helper unit tests and VS Code E2E scenarios. + apps/vs-code-designer/src/app/utils/codeless/common.ts # VS Code project-scaffolding files (depend on VS Code APIs; tested via E2E). # The pure-function PATH helper `funcHostTaskEnv.ts` has its own unit # tests in `src/app/utils/codeless/__test__/funcHostTaskEnv.test.ts`; diff --git a/.github/workflows/vscode-e2e.yml b/.github/workflows/vscode-e2e.yml index 1a1575a28f4..98edfd28b59 100644 --- a/.github/workflows/vscode-e2e.yml +++ b/.github/workflows/vscode-e2e.yml @@ -416,11 +416,117 @@ jobs: if-no-files-found: ignore retention-days: 30 + vscode-e2e-compat: + name: vscode-e2e compatibility (${{ matrix.label }}) + needs: setup-extension-build + timeout-minutes: 20 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # The runner Node version drives pnpm/tsup/run-e2e.js and any child + # processes that resolve node from PATH. The extension-host Node + # version is owned by the VS Code/Electron build below. + # + # Keep an explicit legacy smoke so the latest extension continues to + # activate on the stable runner/runtime combination used by the full + # VS Code E2E suite. + - label: node20-vscode108 + node-version: 20.x + vscode-version: 1.108.0 + - label: node22-vscode108 + node-version: 22.x + vscode-version: 1.108.0 + # VS Code 1.123 carries the Electron/Node 24 runtime that exposed the + # removed node:util helper regression fixed by this branch. + - label: node24-vscode123 + node-version: 24.x + vscode-version: 1.123.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Symlink node to system path + run: | + NODE_BIN="$(which node)" + NPM_BIN="$(which npm)" + NPX_BIN="$(which npx)" + for dir in /usr/local/bin /usr/bin; do + sudo ln -sf "$NODE_BIN" "$dir/node" + sudo ln -sf "$NPM_BIN" "$dir/npm" + sudo ln -sf "$NPX_BIN" "$dir/npx" + done + echo "Symlinked node $(node --version) into /usr/local/bin and /usr/bin" + env -i /usr/bin/node --version + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9.1.3 + run_install: | + - recursive: true + args: [--frozen-lockfile, --strict-peer-dependencies] + + - name: Download extension build artifact + uses: actions/download-artifact@v4 + with: + name: extension-build-${{ github.sha }} + path: . + + - name: Extract build artifacts + run: tar -xzf extension-build.tar.gz + + - name: Install system dependencies for virtual display + run: | + sudo apt-get update + sudo apt-get install -y xvfb libgbm-dev libgtk-3-0 libnss3 libasound2t64 libxss1 libatk-bridge2.0-0 libatk1.0-0 + + - name: Run compatibility activation smoke + run: | + export PATH="$(dirname $(which node)):/usr/local/bin:/usr/bin:/bin:$PATH" + echo "PATH=$PATH" + xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \ + node apps/vs-code-designer/src/test/ui/run-e2e.js + env: + LA_E2E_SCENARIO: p40-nonlogicapp + E2E_VSCODE_VERSION: ${{ matrix.vscode-version }} + NODE_OPTIONS: --max-old-space-size=4096 + TEMP: ${{ runner.temp }} + TMP: ${{ runner.temp }} + TMPDIR: ${{ runner.temp }} + + - name: Upload test screenshots (always) + uses: actions/upload-artifact@v4 + if: always() + with: + name: vscode-e2e-screenshots-compat-${{ matrix.label }} + path: | + ${{ runner.temp }}/test-resources/screenshots/ + test-resources/screenshots/ + if-no-files-found: ignore + retention-days: 30 + # Rollup status check so branch protection can require a single check name # ("vscode-e2e-summary") regardless of how many scenarios we add later. vscode-e2e-summary: name: vscode-e2e-summary - needs: [setup-extension-build, setup-fixtures, vscode-e2e] + needs: [setup-extension-build, setup-fixtures, vscode-e2e, vscode-e2e-compat] if: always() runs-on: ubuntu-latest steps: @@ -429,9 +535,11 @@ jobs: echo "setup-extension-build: ${{ needs.setup-extension-build.result }}" echo "setup-fixtures: ${{ needs.setup-fixtures.result }}" echo "vscode-e2e (matrix): ${{ needs.vscode-e2e.result }}" + echo "compat matrix: ${{ needs.vscode-e2e-compat.result }}" if [ "${{ needs.setup-extension-build.result }}" != "success" ] || \ [ "${{ needs.setup-fixtures.result }}" != "success" ] || \ - [ "${{ needs.vscode-e2e.result }}" != "success" ]; then + [ "${{ needs.vscode-e2e.result }}" != "success" ] || \ + [ "${{ needs.vscode-e2e-compat.result }}" != "success" ]; then echo "::error::One or more vscode-e2e jobs failed" exit 1 fi diff --git a/apps/vs-code-designer/src/__test__/nodeUtilCompatibility.test.ts b/apps/vs-code-designer/src/__test__/nodeUtilCompatibility.test.ts new file mode 100644 index 00000000000..4f2fc192612 --- /dev/null +++ b/apps/vs-code-designer/src/__test__/nodeUtilCompatibility.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import util from 'node:util'; + +type LegacyNodeUtil = { + isArray?: (value: unknown) => value is unknown[]; + isNullOrUndefined?: (value: unknown) => value is null | undefined; + isNumber?: (value: unknown) => value is number; +}; + +const legacyUtil = util as LegacyNodeUtil; +const originalHelpers = { + isArray: legacyUtil.isArray, + isNullOrUndefined: legacyUtil.isNullOrUndefined, + isNumber: legacyUtil.isNumber, +}; + +function restoreHelper(name: T, value: LegacyNodeUtil[T]): void { + if (value === undefined) { + delete legacyUtil[name]; + } else { + legacyUtil[name] = value; + } +} + +describe('applyNodeUtilCompatibility', () => { + afterEach(() => { + restoreHelper('isArray', originalHelpers.isArray); + restoreHelper('isNullOrUndefined', originalHelpers.isNullOrUndefined); + restoreHelper('isNumber', originalHelpers.isNumber); + vi.resetModules(); + }); + + it('restores legacy util helpers removed from newer Node runtimes', async () => { + legacyUtil.isArray = undefined; + legacyUtil.isNullOrUndefined = undefined; + legacyUtil.isNumber = undefined; + + const { applyNodeUtilCompatibility } = await import('../nodeUtilCompatibility'); + applyNodeUtilCompatibility(); + + expect(legacyUtil.isArray?.([])).toBe(true); + expect(legacyUtil.isArray?.('value')).toBe(false); + expect(legacyUtil.isNullOrUndefined?.(null)).toBe(true); + expect(legacyUtil.isNullOrUndefined?.(undefined)).toBe(true); + expect(legacyUtil.isNullOrUndefined?.('value')).toBe(false); + expect(legacyUtil.isNumber?.(1)).toBe(true); + expect(legacyUtil.isNumber?.('1')).toBe(false); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/__test__/installRuntimeDependencies.test.ts b/apps/vs-code-designer/src/app/commands/__test__/installRuntimeDependencies.test.ts new file mode 100644 index 00000000000..88e3d846d20 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/__test__/installRuntimeDependencies.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PackageManager, dotnetDependencyName, funcDependencyName, nodeJsDependencyName } from '../../../constants'; +import { downloadAndExtractDependency } from '../../utils/binaries'; +import { executeCommand } from '../../utils/funcCoreTools/cpUtils'; +import { getNpmDistTag } from '../../utils/funcCoreTools/getNpmDistTag'; +import { ensureRuntimeDependenciesPath } from '../../utils/runtimeDependenciesPath'; +import { promptForFuncVersion } from '../../utils/vsCodeConfig/settings'; +import { installDotNet } from '../dotnet/installDotNet'; +import { installFuncCoreToolsBinaries, installFuncCoreToolsSystem } from '../funcCoreTools/installFuncCoreTools'; +import { installNodeJs } from '../nodeJs/installNodeJs'; + +vi.mock('../../../extensionVariables', () => ({ + ext: { + outputChannel: { + show: vi.fn(), + appendLog: vi.fn(), + }, + }, +})); + +vi.mock('../../utils/runtimeDependenciesPath', () => ({ + ensureRuntimeDependenciesPath: vi.fn(), +})); + +vi.mock('../../utils/binaries', () => ({ + downloadAndExtractDependency: vi.fn(), + getCpuArchitecture: vi.fn(() => 'x64'), + getDotNetBinariesReleaseUrl: vi.fn(() => 'https://dot.net/v1/dotnet-install.ps1'), + getFunctionCoreToolsBinariesReleaseUrl: vi.fn(() => 'https://example.com/func.zip'), + getLatestFunctionCoreToolsVersion: vi.fn(async () => '4.0.0'), + getLatestNodeJsVersion: vi.fn(async () => '20.0.0'), + getNodeJsBinariesReleaseUrl: vi.fn(() => 'https://example.com/node.zip'), +})); + +vi.mock('../../utils/funcCoreTools/cpUtils', () => ({ + executeCommand: vi.fn(), +})); + +vi.mock('../../utils/funcCoreTools/getBrewPackageName', () => ({ + getBrewPackageName: vi.fn(() => 'azure-functions-core-tools@4'), +})); + +vi.mock('../../utils/funcCoreTools/getNpmDistTag', () => ({ + getNpmDistTag: vi.fn(), +})); + +vi.mock('../../utils/vsCodeConfig/settings', () => ({ + promptForFuncVersion: vi.fn(), +})); + +vi.mock('vscode-nls', () => ({ + localize: (_key: string, message: string, ...args: unknown[]) => + message.replace(/{(\d+)}/g, (_match, index) => String(args[Number(index)] ?? '')), +})); + +describe('runtime dependency installers', () => { + const context = { telemetry: { properties: {} } } as any; + const originalPlatform = process.platform; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.mocked(ensureRuntimeDependenciesPath).mockResolvedValue('D:\\dependencies'); + vi.mocked(downloadAndExtractDependency).mockResolvedValue(undefined); + vi.mocked(getNpmDistTag).mockResolvedValue({ tag: '4.0.0' } as any); + vi.mocked(promptForFuncVersion).mockResolvedValue('4' as any); + }); + + it('installs .NET into the ensured runtime dependency path', async () => { + await installDotNet(context, '8'); + + expect(ensureRuntimeDependenciesPath).toHaveBeenCalled(); + expect(downloadAndExtractDependency).toHaveBeenCalledWith( + context, + 'https://dot.net/v1/dotnet-install.ps1', + 'D:\\dependencies', + dotnetDependencyName, + null, + '8' + ); + }); + + it('installs Functions Core Tools binaries into the ensured runtime dependency path', async () => { + await installFuncCoreToolsBinaries(context, '4'); + + expect(ensureRuntimeDependenciesPath).toHaveBeenCalled(); + expect(downloadAndExtractDependency).toHaveBeenCalledWith( + context, + 'https://example.com/func.zip', + 'D:\\dependencies', + funcDependencyName + ); + }); + + it('uses the macOS Functions Core Tools binary release when running on macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + await installFuncCoreToolsBinaries(context, '4'); + + expect(downloadAndExtractDependency).toHaveBeenCalledWith( + context, + 'https://example.com/func.zip', + 'D:\\dependencies', + funcDependencyName + ); + }); + + it('installs Node.js into the ensured runtime dependency path', async () => { + await installNodeJs(context, '20'); + + expect(ensureRuntimeDependenciesPath).toHaveBeenCalled(); + expect(downloadAndExtractDependency).toHaveBeenCalledWith( + context, + 'https://example.com/node.zip', + 'D:\\dependencies', + nodeJsDependencyName + ); + }); + + it('uses the Linux Node.js binary release when running on Linux', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + + await installNodeJs(context, '20'); + + expect(downloadAndExtractDependency).toHaveBeenCalledWith( + context, + 'https://example.com/node.zip', + 'D:\\dependencies', + nodeJsDependencyName + ); + }); + + it('installs Functions Core Tools through npm when system installation is selected', async () => { + await installFuncCoreToolsSystem(context, [PackageManager.npm], '4' as any); + + expect(executeCommand).toHaveBeenCalledWith(expect.anything(), undefined, 'npm', 'install', '-g', 'azure-functions-core-tools@4.0.0'); + }); + + it('installs Functions Core Tools through brew when that package manager is selected', async () => { + await installFuncCoreToolsSystem(context, [PackageManager.brew], '4' as any); + + expect(executeCommand).toHaveBeenCalledWith(expect.anything(), undefined, 'brew', 'tap', 'azure/functions'); + expect(executeCommand).toHaveBeenCalledWith(expect.anything(), undefined, 'brew', 'install', 'azure-functions-core-tools@4'); + }); + + it('rejects unsupported system package managers', async () => { + await expect(installFuncCoreToolsSystem(context, ['unknown' as PackageManager], '4' as any)).rejects.toThrow('Invalid package manager'); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts index 23d0b5571d3..68511af04ad 100644 --- a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts +++ b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { getDependencyTimeout } from '../../utils/binaries'; @@ -12,9 +11,9 @@ import { executeCommand } from '../../utils/funcCoreTools/cpUtils'; import { setFunctionsCommand } from '../../utils/funcCoreTools/funcVersion'; import { installLSPSDK } from '../../utils/languageServerProtocol'; import { setNodeJsCommand } from '../../utils/nodeJs/nodeJsVersion'; +import { ensureRuntimeDependenciesPath } from '../../utils/runtimeDependenciesPath'; import { runWithDurationTelemetry } from '../../utils/telemetry'; import { timeout } from '../../utils/timeout'; -import { getGlobalSetting, updateGlobalSetting } from '../../utils/vsCodeConfig/settings'; import { validateDotNetIsLatest } from '../dotnet/validateDotNetIsLatest'; import { validateFuncCoreToolsIsLatest } from '../funcCoreTools/validateFuncCoreToolsIsLatest'; import { validateNodeJsIsLatest } from '../nodeJs/validateNodeJsIsLatest'; @@ -43,10 +42,7 @@ export async function validateAndInstallBinaries(context: IActionContext) { const dependencyTimeout = getDependencyTimeout() * 1000; context.telemetry.properties.dependencyTimeout = `${dependencyTimeout} milliseconds`; - if (!getGlobalSetting(autoRuntimeDependenciesPathSettingKey)) { - await updateGlobalSetting(autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue); - context.telemetry.properties.dependencyPath = defaultDependencyPathValue; - } + context.telemetry.properties.dependencyPath = await ensureRuntimeDependenciesPath(); context.telemetry.properties.lastStep = 'getDependenciesVersion'; progress.report({ increment: 10, message: 'Get dependency version from CDN' }); diff --git a/apps/vs-code-designer/src/app/commands/dotnet/installDotNet.ts b/apps/vs-code-designer/src/app/commands/dotnet/installDotNet.ts index f14a890055b..860bbec0075 100644 --- a/apps/vs-code-designer/src/app/commands/dotnet/installDotNet.ts +++ b/apps/vs-code-designer/src/app/commands/dotnet/installDotNet.ts @@ -2,16 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autoRuntimeDependenciesPathSettingKey, dotnetDependencyName } from '../../../constants'; +import { dotnetDependencyName } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { downloadAndExtractDependency, getDotNetBinariesReleaseUrl } from '../../utils/binaries'; -import { getGlobalSetting } from '../../utils/vsCodeConfig/settings'; +import { ensureRuntimeDependenciesPath } from '../../utils/runtimeDependenciesPath'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; export async function installDotNet(context: IActionContext, majorVersion?: string): Promise { ext.outputChannel.show(); context.telemetry.properties.majorVersion = majorVersion; - const targetDirectory = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); + const targetDirectory = await ensureRuntimeDependenciesPath(); context.telemetry.properties.lastStep = 'getDotNetBinariesReleaseUrl'; const scriptUrl = getDotNetBinariesReleaseUrl(); diff --git a/apps/vs-code-designer/src/app/commands/funcCoreTools/installFuncCoreTools.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/installFuncCoreTools.ts index 0011aa4df07..dc4736eaac1 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/installFuncCoreTools.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/installFuncCoreTools.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PackageManager, autoRuntimeDependenciesPathSettingKey, funcDependencyName, funcPackageName } from '../../../constants'; +import { PackageManager, funcDependencyName, funcPackageName } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { downloadAndExtractDependency, @@ -13,7 +13,8 @@ import { import { executeCommand } from '../../utils/funcCoreTools/cpUtils'; import { getBrewPackageName } from '../../utils/funcCoreTools/getBrewPackageName'; import { getNpmDistTag } from '../../utils/funcCoreTools/getNpmDistTag'; -import { getGlobalSetting, promptForFuncVersion } from '../../utils/vsCodeConfig/settings'; +import { ensureRuntimeDependenciesPath } from '../../utils/runtimeDependenciesPath'; +import { promptForFuncVersion } from '../../utils/vsCodeConfig/settings'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { Platform, type FuncVersion, type INpmDistTag } from '@microsoft/vscode-extension-logic-apps'; import { localize } from 'vscode-nls'; @@ -21,7 +22,7 @@ import { localize } from 'vscode-nls'; export async function installFuncCoreToolsBinaries(context: IActionContext, majorVersion?: string): Promise { ext.outputChannel.show(); const arch = getCpuArchitecture(); - const targetDirectory = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); + const targetDirectory = await ensureRuntimeDependenciesPath(); context.telemetry.properties.lastStep = 'getLatestFunctionCoreToolsVersion'; const version = await getLatestFunctionCoreToolsVersion(context, majorVersion); let azureFunctionCoreToolsReleasesUrl: string; diff --git a/apps/vs-code-designer/src/app/commands/nodeJs/installNodeJs.ts b/apps/vs-code-designer/src/app/commands/nodeJs/installNodeJs.ts index 61c4324f685..cca0f8f89fd 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/installNodeJs.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/installNodeJs.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Platform } from '@microsoft/vscode-extension-logic-apps'; -import { autoRuntimeDependenciesPathSettingKey, nodeJsDependencyName } from '../../../constants'; +import { nodeJsDependencyName } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { downloadAndExtractDependency, @@ -11,13 +11,13 @@ import { getLatestNodeJsVersion, getNodeJsBinariesReleaseUrl, } from '../../utils/binaries'; -import { getGlobalSetting } from '../../utils/vsCodeConfig/settings'; +import { ensureRuntimeDependenciesPath } from '../../utils/runtimeDependenciesPath'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; export async function installNodeJs(context: IActionContext, majorVersion?: string): Promise { ext.outputChannel.show(); const arch = getCpuArchitecture(); - const targetDirectory = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); + const targetDirectory = await ensureRuntimeDependenciesPath(); context.telemetry.properties.lastStep = 'getLatestNodeJsVersion'; const version = await getLatestNodeJsVersion(context, majorVersion); let nodeJsReleaseUrl: string; diff --git a/apps/vs-code-designer/src/app/commands/workflows/__test__/authenticationMethodStep.test.ts b/apps/vs-code-designer/src/app/commands/workflows/__test__/authenticationMethodStep.test.ts new file mode 100644 index 00000000000..ab9c5950efd --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/__test__/authenticationMethodStep.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../localize', () => ({ + localize: (_key: string, defaultMessage: string) => defaultMessage, +})); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + AzureWizardPromptStep: class AzureWizardPromptStep {}, + parseError: (error: any) => ({ + isUserCancelledError: error?.isUserCancelledError === true, + message: error?.message ?? String(error), + }), +})); + +import { AuthenticationMethodSelectionStep } from '../authenticationMethodStep'; + +describe('AuthenticationMethodSelectionStep', () => { + let context: any; + + beforeEach(() => { + context = { + enabled: true, + telemetry: { properties: {}, measurements: {} }, + ui: { + showQuickPick: vi.fn(), + }, + }; + }); + + it('defaults to connection keys when the authentication prompt is cancelled', async () => { + context.ui.showQuickPick.mockRejectedValue({ isUserCancelledError: true }); + + await new AuthenticationMethodSelectionStep().prompt(context); + + expect(context.authenticationMethod).toBe('rawKeys'); + expect(context.telemetry.properties.authenticationMethodDefaulted).toBe('rawKeys'); + }); + + it('keeps explicit Managed Service Identity selections', async () => { + context.ui.showQuickPick.mockResolvedValue({ data: 'managedServiceIdentity' }); + + await new AuthenticationMethodSelectionStep().prompt(context); + + expect(context.authenticationMethod).toBe('managedServiceIdentity'); + }); + + it('keeps explicit connection-key selections', async () => { + context.ui.showQuickPick.mockResolvedValue({ data: 'rawKeys' }); + + await new AuthenticationMethodSelectionStep().prompt(context); + + expect(context.authenticationMethod).toBe('rawKeys'); + }); + + it('does not prompt when Azure connectors are disabled', () => { + context.enabled = false; + + expect(new AuthenticationMethodSelectionStep().shouldPrompt(context)).toBe(false); + }); + + it('rethrows non-cancellation errors', async () => { + context.ui.showQuickPick.mockRejectedValue(new Error('quick pick failed')); + + await expect(new AuthenticationMethodSelectionStep().prompt(context)).rejects.toThrow('quick pick failed'); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/__test__/azureConnectorWizard.test.ts b/apps/vs-code-designer/src/app/commands/workflows/__test__/azureConnectorWizard.test.ts new file mode 100644 index 00000000000..13caa97434c --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/workflows/__test__/azureConnectorWizard.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + defaultMsiAudience, + workflowAuthenticationMethodKey, + workflowLocationKey, + workflowManagementBaseURIKey, + workflowResourceGroupNameKey, + workflowsDynamicConnectionDefaultAuthAudienceKey, + workflowSubscriptionIdKey, + workflowTenantIdKey, +} from '../../../../constants'; +import { ext } from '../../../../extensionVariables'; +import { addOrUpdateLocalAppSettings } from '../../../utils/appSettings/localSettings'; +import { createAzureWizard, type IAzureConnectorsContext } from '../azureConnectorWizard'; + +vi.mock('@microsoft/vscode-azext-azureutils', () => ({ + ResourceGroupListStep: class ResourceGroupListStep {}, +})); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + AzureWizard: class AzureWizard { + constructor( + public context: T, + public options: any + ) {} + }, + AzureWizardExecuteStep: class AzureWizardExecuteStep {}, + AzureWizardPromptStep: class AzureWizardPromptStep {}, + parseError: (error: any) => ({ + isUserCancelledError: error?.isUserCancelledError === true, + }), +})); + +vi.mock('../../../../extensionVariables', () => ({ + ext: { + azureAccountTreeItem: { + getSubscriptionPromptStep: vi.fn(), + }, + languageClient: { + sendNotification: vi.fn(), + }, + }, +})); + +vi.mock('../../../../localize', () => ({ + localize: (_key: string, message: string, ...args: unknown[]) => + message.replace(/{(\d+)}/g, (_match, index) => String(args[Number(index)] ?? '')), +})); + +vi.mock('../../../utils/appSettings/localSettings', () => ({ + addOrUpdateLocalAppSettings: vi.fn(), +})); + +describe('createAzureWizard', () => { + const projectPath = 'D:\\workspace\\LogicApp'; + let context: IAzureConnectorsContext; + + beforeEach(() => { + vi.clearAllMocks(); + context = { + telemetry: { properties: {} }, + ui: { + showQuickPick: vi.fn(), + }, + } as any; + }); + + it('defaults cancelled connector selection to disabled raw keys', async () => { + vi.mocked(context.ui.showQuickPick).mockRejectedValue({ isUserCancelledError: true }); + const wizard = createAzureWizard(context, projectPath) as any; + + await wizard.options.promptSteps[0].prompt(context); + + expect(context.enabled).toBe(false); + expect(context.authenticationMethod).toBe('rawKeys'); + expect(context.telemetry.properties.azureConnectorsDefaulted).toBe('rawKeys'); + }); + + it('surfaces non-cancel connector selection failures', async () => { + const failure = new Error('pick failed'); + vi.mocked(context.ui.showQuickPick).mockRejectedValue(failure); + const wizard = createAzureWizard(context, projectPath) as any; + + await expect(wizard.options.promptSteps[0].prompt(context)).rejects.toThrow(failure); + }); + + it('provides Azure subscription and resource group prompt steps only when connectors are enabled', async () => { + const subscriptionStep = { prompt: vi.fn() } as any; + vi.mocked(ext.azureAccountTreeItem.getSubscriptionPromptStep).mockResolvedValue(subscriptionStep); + const wizard = createAzureWizard(context, projectPath) as any; + const azureConnectorStep = wizard.options.promptSteps[0]; + + await expect(azureConnectorStep.getSubWizard({ ...context, enabled: false })).resolves.toBeUndefined(); + + const subWizard = await azureConnectorStep.getSubWizard({ ...context, enabled: true }); + expect(subWizard.promptSteps).toHaveLength(2); + expect(subWizard.promptSteps[0]).toBe(subscriptionStep); + }); + + it('only prompts for connector selection when no prior selection exists', () => { + const wizard = createAzureWizard(context, projectPath) as any; + const azureConnectorStep = wizard.options.promptSteps[0]; + + expect(azureConnectorStep.shouldPrompt(context)).toBe(true); + expect(azureConnectorStep.shouldPrompt({ ...context, enabled: false })).toBe(false); + }); + + it('persists disabled raw-key settings when Azure connectors are skipped', async () => { + context.enabled = false; + context.authenticationMethod = 'rawKeys'; + const wizard = createAzureWizard(context, projectPath) as any; + + await wizard.options.executeSteps[0].execute(context); + + expect(addOrUpdateLocalAppSettings).toHaveBeenCalledWith(context, projectPath, { + [workflowSubscriptionIdKey]: '', + [workflowAuthenticationMethodKey]: 'rawKeys', + }); + }); + + it('persists Azure connector settings and notifies the language client when enabled', async () => { + Object.assign(context, { + enabled: true, + authenticationMethod: 'managedServiceIdentity', + tenantId: 'tenant-id', + subscriptionId: 'subscription-id', + resourceGroup: { name: 'rg', location: 'westus' }, + environment: { resourceManagerEndpointUrl: 'https://management.azure.com/' }, + }); + const wizard = createAzureWizard(context, projectPath) as any; + + await wizard.options.executeSteps[0].execute(context); + + expect(addOrUpdateLocalAppSettings).toHaveBeenCalledWith(context, projectPath, { + [workflowTenantIdKey]: 'tenant-id', + [workflowSubscriptionIdKey]: 'subscription-id', + [workflowResourceGroupNameKey]: 'rg', + [workflowLocationKey]: 'westus', + [workflowManagementBaseURIKey]: 'https://management.azure.com/', + [workflowAuthenticationMethodKey]: 'managedServiceIdentity', + [workflowsDynamicConnectionDefaultAuthAudienceKey]: defaultMsiAudience, + }); + expect(ext.languageClient.sendNotification).toHaveBeenCalledWith('custom/updateApiConfig', { + subscriptionId: 'subscription-id', + resourceGroup: { name: 'rg', location: 'westus' }, + }); + }); + + it('executes only when Azure connectors are disabled or an Azure scope was selected', () => { + const wizard = createAzureWizard(context, projectPath) as any; + const saveStep = wizard.options.executeSteps[0]; + + expect(saveStep.shouldExecute({ ...context, enabled: false })).toBe(true); + expect(saveStep.shouldExecute({ ...context, subscriptionId: 'subscription-id' })).toBe(true); + expect(saveStep.shouldExecute(context)).toBe(false); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/workflows/authenticationMethodStep.ts b/apps/vs-code-designer/src/app/commands/workflows/authenticationMethodStep.ts index 54b8f8f0a99..b594a87b23f 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/authenticationMethodStep.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/authenticationMethodStep.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; import type { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; import { localize } from '../../../localize'; @@ -19,7 +19,7 @@ export type AuthenticationMethodType = 'managedServiceIdentity' | 'rawKeys'; * and sets the `authenticationMethod` in the context. */ export class AuthenticationMethodSelectionStep< - T extends IActionContext & { authenticationMethod?: AuthenticationMethodType }, + T extends IActionContext & { authenticationMethod?: AuthenticationMethodType; enabled?: boolean }, > extends AzureWizardPromptStep { /** * Prompts the user to select an authentication method. @@ -49,11 +49,20 @@ export class AuthenticationMethodSelectionStep< }, ]; - const selectedMethod = await context.ui.showQuickPick(picks, { - placeHolder, - suppressPersistence: true, - ignoreFocusOut: true, - }); + const selectedMethod = await context.ui + .showQuickPick(picks, { + placeHolder, + suppressPersistence: true, + ignoreFocusOut: true, + }) + .catch((error) => { + if (parseError(error).isUserCancelledError) { + context.telemetry.properties.authenticationMethodDefaulted = 'rawKeys'; + return { data: 'rawKeys' as AuthenticationMethodType }; + } + + throw error; + }); // Save the selected authentication method context.authenticationMethod = selectedMethod.data; @@ -62,7 +71,7 @@ export class AuthenticationMethodSelectionStep< /** * Determines if this step should prompt again (only if no method is selected yet). */ - public shouldPrompt(): boolean { - return true; + public shouldPrompt(context: T): boolean { + return context.enabled === true && context.authenticationMethod === undefined; } } diff --git a/apps/vs-code-designer/src/app/commands/workflows/azureConnectorWizard.ts b/apps/vs-code-designer/src/app/commands/workflows/azureConnectorWizard.ts index bfff3f3660f..95fccbf40dc 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/azureConnectorWizard.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/azureConnectorWizard.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { ResourceGroupListStep } from '@microsoft/vscode-azext-azureutils'; -import { AzureWizard, AzureWizardExecuteStep, AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { AzureWizard, AzureWizardExecuteStep, AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; import type { IActionContext, IAzureQuickPickItem, IWizardOptions } from '@microsoft/vscode-azext-utils'; import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; import * as path from 'path'; @@ -57,7 +57,19 @@ class GetSubscriptionDetailsStep extends AzureWizardPromptStep { + if (parseError(error).isUserCancelledError) { + context.telemetry.properties.azureConnectorsDefaulted = 'rawKeys'; + return { data: 'no' }; + } + + throw error; + }); + + context.enabled = selectedAction.data === 'yes'; + if (!context.enabled) { + context.authenticationMethod = 'rawKeys'; + } } public shouldPrompt(context: IAzureConnectorsContext): boolean { @@ -94,6 +106,7 @@ class SaveAzureContext extends AzureWizardExecuteStep { const valuesToUpdateInSettings: Record = {}; if (context.enabled === false) { valuesToUpdateInSettings[workflowSubscriptionIdKey] = ''; + valuesToUpdateInSettings[workflowAuthenticationMethodKey] = 'rawKeys'; } else { const { resourceGroup, subscriptionId, tenantId, environment } = context; valuesToUpdateInSettings[workflowTenantIdKey] = tenantId; diff --git a/apps/vs-code-designer/src/app/languageServer/__test__/languageServer.test.ts b/apps/vs-code-designer/src/app/languageServer/__test__/languageServer.test.ts new file mode 100644 index 00000000000..e22e95f406e --- /dev/null +++ b/apps/vs-code-designer/src/app/languageServer/__test__/languageServer.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LogicAppsLanguageServer from '../languageServer'; +import path from 'path'; + +const mocks = vi.hoisted(() => ({ + createFileSystemWatcher: vi.fn(), + getAzureConnectorDetailsForLocalProject: vi.fn(), + getGlobalSetting: vi.fn(), + getWorkspaceFolderPath: vi.fn(), + languageClient: vi.fn(), + pathExists: vi.fn(), + readFile: vi.fn(), + readdir: vi.fn(), + showWarningMessage: vi.fn(), + tryGetLogicAppCustomCodeFunctionsProjects: vi.fn(), + tryGetLogicAppProjectRoot: vi.fn(), + getDotNetCommand: vi.fn(), +})); + +vi.mock('vscode', () => ({ + env: { + sessionId: 'test-session-id', + }, + MarkdownString: class MarkdownString { + public supportHtml = false; + public isTrusted: boolean | { enabledCommands: string[] } = false; + public supportThemeIcons = false; + + constructor( + public value: string, + public supportThemeIconsArg?: boolean + ) {} + }, + window: { + showWarningMessage: mocks.showWarningMessage, + }, + workspace: { + createFileSystemWatcher: mocks.createFileSystemWatcher, + workspaceFolders: [{ uri: { fsPath: 'D:\\workspace' } }], + }, +})); + +vi.mock('vscode-languageclient/node', () => ({ + LanguageClient: mocks.languageClient, +})); + +vi.mock('../../commands/workflows/switchDebugMode/switchDebugMode', () => ({ + getWorkspaceFolderPath: mocks.getWorkspaceFolderPath, +})); + +vi.mock('../../utils/verifyIsProject', () => ({ + tryGetLogicAppProjectRoot: mocks.tryGetLogicAppProjectRoot, +})); + +vi.mock('../../utils/customCodeUtils', () => ({ + tryGetLogicAppCustomCodeFunctionsProjects: mocks.tryGetLogicAppCustomCodeFunctionsProjects, +})); + +vi.mock('../../utils/dotnet/dotnet', () => ({ + getDotNetCommand: mocks.getDotNetCommand, +})); + +vi.mock('fs-extra', () => ({ + pathExists: mocks.pathExists, + readFile: mocks.readFile, + readdir: mocks.readdir, +})); + +vi.mock('../../utils/vsCodeConfig/settings', () => ({ + getGlobalSetting: mocks.getGlobalSetting, +})); + +vi.mock('../../utils/codeless/common', () => ({ + getAzureConnectorDetailsForLocalProject: mocks.getAzureConnectorDetailsForLocalProject, +})); + +vi.mock('../../../extensionVariables', () => ({ + ext: { + context: { + subscriptions: [], + }, + languageClient: undefined, + telemetryString: 'setInGitHubBuild', + }, +})); + +describe('LogicAppsLanguageServer', () => { + const dependenciesPath = 'D:\\dependencies'; + const projectPath = 'D:\\workspace\\logic-app'; + const sdkFolderPath = path.join(dependenciesPath, 'LanguageServerLogicApps'); + const lspServerPath = path.join(dependenciesPath, 'LSPServer', 'SdkLspServer.dll'); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.createFileSystemWatcher.mockReturnValue({ onDidChange: vi.fn() }); + mocks.getGlobalSetting.mockReturnValue(dependenciesPath); + mocks.getWorkspaceFolderPath.mockResolvedValue('D:\\workspace'); + mocks.languageClient.mockImplementation(() => ({ start: vi.fn().mockResolvedValue(undefined) })); + mocks.pathExists.mockResolvedValue(false); + mocks.readFile.mockResolvedValue('{}'); + mocks.readdir.mockResolvedValue([]); + mocks.tryGetLogicAppProjectRoot.mockResolvedValue(projectPath); + mocks.tryGetLogicAppCustomCodeFunctionsProjects.mockResolvedValue(['D:\\workspace\\custom-code-project']); + mocks.getDotNetCommand.mockReturnValue('D:\\dependencies\\DotNetSDK\\dotnet.exe'); + mocks.getAzureConnectorDetailsForLocalProject.mockResolvedValue({ + accessToken: 'Bearer token', + resourceGroupName: 'resource-group', + subscriptionId: 'subscription-id', + }); + }); + + it('does not start or read metadata when no Logic App project root is found', async () => { + mocks.tryGetLogicAppProjectRoot.mockResolvedValue(undefined); + + await new LogicAppsLanguageServer({} as any).start(); + + expect(mocks.readdir).not.toHaveBeenCalled(); + expect(mocks.getAzureConnectorDetailsForLocalProject).not.toHaveBeenCalled(); + expect(mocks.languageClient).not.toHaveBeenCalled(); + expect(mocks.showWarningMessage).not.toHaveBeenCalled(); + }); + + it('does not start or prompt for Azure connector metadata when no linked custom code project is found', async () => { + mocks.tryGetLogicAppCustomCodeFunctionsProjects.mockResolvedValue([]); + + await new LogicAppsLanguageServer({} as any).start(); + + expect(mocks.tryGetLogicAppCustomCodeFunctionsProjects).toHaveBeenCalledWith(projectPath); + expect(mocks.pathExists).not.toHaveBeenCalled(); + expect(mocks.readdir).not.toHaveBeenCalled(); + expect(mocks.getAzureConnectorDetailsForLocalProject).not.toHaveBeenCalled(); + expect(mocks.languageClient).not.toHaveBeenCalled(); + expect(mocks.showWarningMessage).not.toHaveBeenCalled(); + }); + + it('does not scan a missing SDK directory', async () => { + mocks.pathExists.mockImplementation(async (filePath: string) => filePath === lspServerPath); + + await new LogicAppsLanguageServer({} as any).start(); + + expect(mocks.pathExists).toHaveBeenCalledWith(sdkFolderPath); + expect(mocks.readdir).not.toHaveBeenCalled(); + expect(mocks.getAzureConnectorDetailsForLocalProject).not.toHaveBeenCalled(); + expect(mocks.languageClient).not.toHaveBeenCalled(); + expect(mocks.showWarningMessage).toHaveBeenCalledWith( + 'Install or repair Logic Apps language server SDK dependencies before starting C# workflow authoring.' + ); + }); + + it('does not join an undefined SDK package path when no SDK package is installed', async () => { + mocks.pathExists.mockImplementation(async (filePath: string) => filePath === lspServerPath || filePath === sdkFolderPath); + mocks.readdir.mockResolvedValue(['readme.txt']); + + await new LogicAppsLanguageServer({} as any).start(); + + expect(mocks.readdir).toHaveBeenCalledWith(sdkFolderPath); + expect(mocks.getAzureConnectorDetailsForLocalProject).not.toHaveBeenCalled(); + expect(mocks.languageClient).not.toHaveBeenCalled(); + expect(mocks.showWarningMessage).toHaveBeenCalledWith( + 'Install or repair Logic Apps language server SDK dependencies before starting C# workflow authoring.' + ); + }); + + it('starts the language client when project and language server dependencies are available', async () => { + const languageClient = { start: vi.fn().mockResolvedValue(undefined) }; + mocks.languageClient.mockReturnValue(languageClient); + mocks.pathExists.mockImplementation(async (filePath: string) => filePath === lspServerPath || filePath === sdkFolderPath); + mocks.readdir.mockResolvedValue(['Microsoft.Azure.Workflows.Sdk.1.0.0-preview.1.nupkg']); + + await new LogicAppsLanguageServer({} as any).start(); + + expect(mocks.getAzureConnectorDetailsForLocalProject).toHaveBeenCalledWith(expect.any(Object), projectPath); + expect(mocks.languageClient).toHaveBeenCalledWith( + 'logicAppsLanguageServer', + 'Logic Apps language server', + { + run: { + command: 'D:\\dependencies\\DotNetSDK\\dotnet.exe', + args: [lspServerPath, '--sdk', path.join(sdkFolderPath, 'Microsoft.Azure.Workflows.Sdk.1.0.0-preview.1.nupkg')], + }, + debug: { + command: 'D:\\dependencies\\DotNetSDK\\dotnet.exe', + args: [lspServerPath, '--sdk', path.join(sdkFolderPath, 'Microsoft.Azure.Workflows.Sdk.1.0.0-preview.1.nupkg')], + }, + }, + expect.objectContaining({ + initializationOptions: expect.objectContaining({ + apiConfig: expect.objectContaining({ + bearerToken: 'Bearer token', + resourceGroup: 'resource-group', + subscriptionId: 'subscription-id', + }), + }), + }) + ); + expect(languageClient.start).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/vs-code-designer/src/app/languageServer/languageServer.ts b/apps/vs-code-designer/src/app/languageServer/languageServer.ts index 738b9e2fce8..fe6d6cbba8b 100644 --- a/apps/vs-code-designer/src/app/languageServer/languageServer.ts +++ b/apps/vs-code-designer/src/app/languageServer/languageServer.ts @@ -20,12 +20,14 @@ import type { AzureConnectorDetails } from '@microsoft/vscode-extension-logic-ap import { getAzureConnectorDetailsForLocalProject } from '../utils/codeless/common'; import * as vscode from 'vscode'; import { filterCompletionResult } from './completionFilter'; +import { tryGetLogicAppCustomCodeFunctionsProjects } from '../utils/customCodeUtils'; +import { getDotNetCommand } from '../utils/dotnet/dotnet'; export default class LogicAppsLanguageServer { - protected lspServerPath: string; - protected sdkNupkgPath: string; + protected lspServerPath: string | undefined; + protected sdkNupkgPath: string | undefined; protected apiVersion = workflowAppApiVersion; - private projectPath: string; + private projectPath: string | undefined; protected readonly context: IActionContext; constructor(context: IActionContext) { @@ -39,22 +41,33 @@ export default class LogicAppsLanguageServer { const workspaceFolder = await getWorkspaceFolderPath(this.context); this.projectPath = await tryGetLogicAppProjectRoot(this.context, workspaceFolder, true /* suppressPrompt */); + + if (!this.projectPath) { + return; + } + + const customCodeProjectPaths = await tryGetLogicAppCustomCodeFunctionsProjects(this.projectPath); + if (!customCodeProjectPaths || customCodeProjectPaths.length === 0) { + return; + } + const { lspServerPath, sdkNupkgPath } = await this.getSDKPaths(); this.lspServerPath = lspServerPath; this.sdkNupkgPath = sdkNupkgPath; - const metaData = await this.getMetadata(); if (!this.lspServerPath) { - window.showWarningMessage('Set "azureLogicAppsStandard.languageServerDLLPath" to your C# server DLL.'); + window.showWarningMessage('Install or repair Logic Apps language server dependencies before starting C# workflow authoring.'); return; } if (!this.sdkNupkgPath) { - window.showWarningMessage('Set "azureLogicAppsStandard.languageServerNupkgPath" to your SDK NuGet package.'); + window.showWarningMessage('Install or repair Logic Apps language server SDK dependencies before starting C# workflow authoring.'); return; } + const metaData = await this.getMetadata(); + // Build server arguments (removed --connections) const serverArgs = [this.lspServerPath, '--sdk', this.sdkNupkgPath]; @@ -125,7 +138,7 @@ export default class LogicAppsLanguageServer { }; const run: Executable = { - command: 'dotnet', + command: getDotNetCommand(), args: serverArgs, }; @@ -166,23 +179,36 @@ export default class LogicAppsLanguageServer { private async getSDKPaths() { const dependenciesPath = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); + if (!dependenciesPath) { + return { lspServerPath: undefined, sdkNupkgPath: undefined }; + } // Support for development mode - override with environment variable const devLspServerPath = process.env.LSP_SERVER_DEV_PATH; - let lspServerPath: string; + let lspServerPath: string | undefined; if (devLspServerPath && (await fse.pathExists(devLspServerPath))) { lspServerPath = devLspServerPath; console.log(`[LSP] Using development server: ${lspServerPath}`); } else { lspServerPath = path.join(dependenciesPath, 'LSPServer', 'SdkLspServer.dll'); + if (!(await fse.pathExists(lspServerPath))) { + lspServerPath = undefined; + } } const sdkFolderPath = path.join(dependenciesPath, lspDirectory); + if (!(await fse.pathExists(sdkFolderPath))) { + return { lspServerPath, sdkNupkgPath: undefined }; + } + const files = await fse.readdir(sdkFolderPath); const sdkNupkgFile = files.find((file) => { return file.startsWith('Microsoft.Azure.Workflows.Sdk.') && file.endsWith('.nupkg'); }); + if (!sdkNupkgFile) { + return { lspServerPath, sdkNupkgPath: undefined }; + } const sdkNupkgPath = path.join(sdkFolderPath, sdkNupkgFile); @@ -190,6 +216,10 @@ export default class LogicAppsLanguageServer { } private async getMetadata() { + if (!this.projectPath) { + throw new Error('Logic Apps language server cannot start without a Logic App project path.'); + } + const azureDetails: AzureConnectorDetails = await getAzureConnectorDetailsForLocalProject(this.context, this.projectPath); const connectionFilePath: string = path.join(this.projectPath, connectionsFileName); diff --git a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts index e10c1081796..1b2a3246a3e 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import * as fs from 'fs'; +import * as path from 'path'; import axios from 'axios'; import * as vscode from 'vscode'; import { @@ -17,7 +18,7 @@ import { useBinariesDependencies, } from '../binaries'; import { ext } from '../../../extensionVariables'; -import { DependencyVersion } from '../../../constants'; +import { DependencyVersion, dotnetDependencyName, funcCoreToolsBinaryPathSettingKey, funcDependencyName } from '../../../constants'; import { validateAndInstallBinaries } from '../../commands/binaries/validateAndInstallBinaries'; import { executeCommand } from '../funcCoreTools/cpUtils'; import { getNpmCommand } from '../nodeJs/nodeJsVersion'; @@ -54,18 +55,24 @@ describe('binaries', () => { it('should download and extract dependency', async () => { const downloadUrl = 'https://example.com/dependency.zip'; const targetFolder = 'targetFolder'; - const dependencyName = 'dependency'; + const dependencyName = dotnetDependencyName; const folderName = 'folderName'; const dotNetVersion = '6.0'; const writer = { - on: vi.fn(), + on: vi.fn((event: string, callback: () => void) => { + if (event === 'finish') { + callback(); + } + return writer; + }), } as any; (axios.get as Mock).mockResolvedValue({ data: { + on: vi.fn(), pipe: vi.fn().mockImplementation((writer) => { - writer.on('finish'); + return writer; }), }, }); @@ -79,6 +86,102 @@ describe('binaries', () => { expect(executeCommand).toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', `Downloading dependency from: ${downloadUrl}`); }); + it('rejects when the file writer fails while downloading a dependency', async () => { + const downloadUrl = 'https://example.com/dependency.zip'; + const targetFolder = 'targetFolder'; + const dependencyName = dotnetDependencyName; + const folderName = 'folderName'; + const writerError = new Error('disk full'); + const writer = { + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + callback(writerError); + } + return writer; + }), + } as any; + + (axios.get as Mock).mockResolvedValue({ + data: { + on: vi.fn(), + pipe: vi.fn().mockImplementation((writer) => writer), + }, + }); + + (fs.createWriteStream as Mock).mockReturnValue(writer); + + await expect(downloadAndExtractDependency(context, downloadUrl, targetFolder, dependencyName, folderName)).rejects.toThrow( + 'Error downloading and extracting the DotNetSDK zip file: disk full' + ); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('disk full')); + expect(context.telemetry.properties.error).toContain('disk full'); + }); + + it('rejects when the readable download stream fails', async () => { + const downloadUrl = 'https://example.com/dependency.zip'; + const targetFolder = 'targetFolder'; + const dependencyName = dotnetDependencyName; + const folderName = 'folderName'; + const streamError = new Error('connection reset'); + const writer = { + on: vi.fn(), + } as any; + + (axios.get as Mock).mockResolvedValue({ + data: { + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + callback(streamError); + } + }), + pipe: vi.fn().mockImplementation((writer) => writer), + }, + }); + + (fs.createWriteStream as Mock).mockReturnValue(writer); + + await expect(downloadAndExtractDependency(context, downloadUrl, targetFolder, dependencyName, folderName)).rejects.toThrow( + 'Error downloading and extracting the DotNetSDK zip file: connection reset' + ); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('connection reset')); + expect(context.telemetry.properties.error).toContain('connection reset'); + }); + + it('rejects when download cleanup fails after a file writer error', async () => { + const downloadUrl = 'https://example.com/dependency.zip'; + const targetFolder = 'targetFolder'; + const dependencyName = dotnetDependencyName; + const folderName = 'folderName'; + const writerError = new Error('disk full'); + const cleanupError = new Error('folder locked'); + const writer = { + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + callback(writerError); + } + return writer; + }), + } as any; + + (axios.get as Mock).mockResolvedValue({ + data: { + on: vi.fn(), + pipe: vi.fn().mockImplementation((writer) => writer), + }, + }); + (fs.createWriteStream as Mock).mockReturnValue(writer); + (fs.rmSync as Mock).mockImplementationOnce(() => { + throw cleanupError; + }); + + await expect(downloadAndExtractDependency(context, downloadUrl, targetFolder, dependencyName, folderName)).rejects.toThrow( + 'Error downloading and extracting the DotNetSDK zip file: disk full' + ); + expect(executeCommand).toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', expect.stringContaining('Failed to remove')); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('disk full')); + expect(context.telemetry.properties.error).toContain('disk full'); + }); + it('should throw error when the compression file extension is not supported', async () => { const downloadUrl = 'https://example.com/dependency.zip222'; const targetFolder = 'targetFolder'; @@ -118,6 +221,22 @@ describe('binaries', () => { expect(result).toBe(false); }); + it('should return false if Func Core Tools folder exists but configured binary is missing', async () => { + const funcCoreToolsFolder = path.join('binariesLocation', funcDependencyName); + const funcBinary = path.join(funcCoreToolsFolder, 'func'); + (fs.existsSync as Mock).mockImplementation((filePath: string) => filePath === funcCoreToolsFolder); + const devContainerModule = await import('../devContainerUtils'); + vi.mocked(devContainerModule.isDevContainerWorkspace).mockResolvedValue(false); + (getGlobalSetting as Mock).mockImplementation((settingName?: string) => + settingName === funcCoreToolsBinaryPathSettingKey ? funcBinary : 'binariesLocation' + ); + + const result = await binariesExist(funcDependencyName); + + expect(result).toBe(false); + expect(executeCommand).toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', `FuncCoreTools binary is missing: ${funcBinary}`); + }); + it('should return false if useBinariesDependencies returns false', async () => { (fs.existsSync as Mock).mockReturnValue(false); (getGlobalSetting as Mock).mockReturnValue(false); diff --git a/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts b/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts index 198a1b717ef..1292d5db6af 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/languageServerProtocol.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { lspDirectory } from '../../../constants'; +import { autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue, lspDirectory } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { installLSPSDK } from '../languageServerProtocol'; import path from 'path'; @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => { readFile: vi.fn(), remove: vi.fn(), stat: vi.fn(), + updateGlobalSetting: vi.fn(), writeFile: vi.fn(), }; }); @@ -41,6 +42,7 @@ vi.mock('fs-extra', () => ({ vi.mock('../vsCodeConfig/settings', () => ({ getGlobalSetting: mocks.getGlobalSetting, + updateGlobalSetting: mocks.updateGlobalSetting, })); vi.mock('../../../extensionVariables', () => ({ @@ -82,6 +84,7 @@ describe('installLSPSDK', () => { mocks.pathExists.mockResolvedValue(false); mocks.readdir.mockResolvedValue([]); mocks.remove.mockResolvedValue(undefined); + mocks.updateGlobalSetting.mockResolvedValue(undefined); mocks.writeFile.mockResolvedValue(undefined); lspServerExtracted = false; configureReadFileMocks(); @@ -91,7 +94,7 @@ describe('installLSPSDK', () => { function setExistingPaths(paths: string[]): void { const existingPaths = new Set(paths); mocks.pathExists.mockImplementation(async (filePath: string) => { - return existingPaths.has(filePath) || (filePath === lspServerDllPath && lspServerExtracted); + return existingPaths.has(filePath) || (filePath.endsWith(path.join('LSPServer', 'SdkLspServer.dll')) && lspServerExtracted); }); } @@ -139,6 +142,17 @@ describe('installLSPSDK', () => { expect(mocks.writeFile).toHaveBeenCalledWith(sdkHashMarker, sdkPackageHash); }); + it('defaults the dependencies path before extracting LSP assets when the setting is unset', async () => { + mocks.getGlobalSetting.mockReturnValue(undefined); + setExistingPaths([]); + + await installLSPSDK(); + + expect(mocks.updateGlobalSetting).toHaveBeenCalledWith(autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue); + expect(mocks.ensureDir).toHaveBeenCalledWith(defaultDependencyPathValue); + expect(mocks.extractAllTo).toHaveBeenCalledWith(defaultDependencyPathValue, true, true); + }); + it('updates both assets when target files exist but hash markers are missing', async () => { setExistingPaths([lspServerPath, lspServerDllPath, sdkDestinationFile]); diff --git a/apps/vs-code-designer/src/app/utils/__test__/runtimeDependenciesPath.test.ts b/apps/vs-code-designer/src/app/utils/__test__/runtimeDependenciesPath.test.ts new file mode 100644 index 00000000000..128b5cc21f4 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/__test__/runtimeDependenciesPath.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fse from 'fs-extra'; +import { autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue } from '../../../constants'; +import { ensureRuntimeDependenciesPath } from '../runtimeDependenciesPath'; +import { getGlobalSetting, updateGlobalSetting } from '../vsCodeConfig/settings'; + +vi.mock('../vsCodeConfig/settings', () => ({ + getGlobalSetting: vi.fn(), + updateGlobalSetting: vi.fn(), +})); + +describe('ensureRuntimeDependenciesPath', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fse.ensureDir).mockResolvedValue(undefined); + vi.mocked(updateGlobalSetting).mockResolvedValue(undefined); + }); + + it('defaults the dependency path setting and creates the default directory when unset', async () => { + vi.mocked(getGlobalSetting).mockReturnValue(undefined); + + const result = await ensureRuntimeDependenciesPath(); + + expect(result).toBe(defaultDependencyPathValue); + expect(updateGlobalSetting).toHaveBeenCalledWith(autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue); + expect(fse.ensureDir).toHaveBeenCalledWith(defaultDependencyPathValue); + }); + + it('preserves and creates a configured dependency path', async () => { + const configuredPath = 'D:\\custom-dependencies'; + vi.mocked(getGlobalSetting).mockReturnValue(configuredPath); + + const result = await ensureRuntimeDependenciesPath(); + + expect(result).toBe(configuredPath); + expect(updateGlobalSetting).not.toHaveBeenCalled(); + expect(fse.ensureDir).toHaveBeenCalledWith(configuredPath); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/__test__/connectionKeys.test.ts b/apps/vs-code-designer/src/app/utils/appSettings/__test__/connectionKeys.test.ts index aac480604f7..84f9f1d9e01 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/__test__/connectionKeys.test.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/__test__/connectionKeys.test.ts @@ -53,6 +53,11 @@ describe('verifyLocalConnectionKeys', () => { }; (vscode.workspace as any).workspaceFolders = testWorkspaceFolders; ext.outputChannel.appendLog = vi.fn(); + vi.spyOn(common, 'getAzureConnectorDetailsForLocalProject').mockResolvedValue({ + enabled: true, + tenantId: '4bb01b15-004c-4b95-9568-5165b5f89c41', + workflowManagementBaseUrl: 'https://management.azure.com/', + }); }); afterEach(() => { @@ -103,6 +108,18 @@ describe('verifyLocalConnectionKeys', () => { expect(connection.saveConnectionReferences).toHaveBeenCalledWith(testContext, testLogicAppProjectPath1, connectionsAndSettingsToUpdate); }); + it('should skip connection key verification when Azure connectors are disabled', async () => { + vi.spyOn(common, 'getAzureConnectorDetailsForLocalProject').mockResolvedValue({ enabled: false } as AzureConnectorDetails); + const getConnectionsJsonSpy = vi + .spyOn(connection, 'getConnectionsJson') + .mockResolvedValue(JSON.stringify({ managedApiConnections: { conn1: {} } })); + + await verifyLocalConnectionKeys(testContext, testLogicAppProjectPath1); + + expect(getConnectionsJsonSpy).not.toHaveBeenCalled(); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('Azure connectors are disabled. Skipping connection key verification.'); + }); + it('should save connection references for the user-selected project', async () => { vi.spyOn(workspace, 'getWorkspaceFolder').mockResolvedValue(testWorkspaceFolders[0].uri.fsPath); vi.spyOn(verifyIsProject, 'tryGetLogicAppProjectRoot').mockResolvedValue(testWorkspaceFolders[0].uri.fsPath); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts b/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts index d755a89c83d..b23462df938 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts @@ -28,6 +28,13 @@ export async function verifyLocalConnectionKeys(context: IActionContext, project } const azureDetails = await getAzureConnectorDetailsForLocalProject(context, projectPath); + if (!azureDetails.enabled) { + ext.outputChannel.appendLog( + localize('azureConnectorsDisabled', 'Azure connectors are disabled. Skipping connection key verification.') + ); + return; + } + try { const connectionsJson = await getConnectionsJson(projectPath); if (isEmptyString(connectionsJson)) { diff --git a/apps/vs-code-designer/src/app/utils/binaries.ts b/apps/vs-code-designer/src/app/utils/binaries.ts index 4b427f43b35..2776be288f4 100644 --- a/apps/vs-code-designer/src/app/utils/binaries.ts +++ b/apps/vs-code-designer/src/app/utils/binaries.ts @@ -15,6 +15,7 @@ import { funcCoreToolsBinaryPathSettingKey, funcDependencyName, extensionBundleId, + nodeJsDependencyName, } from '../../constants'; import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; @@ -69,68 +70,92 @@ export async function downloadAndExtractDependency( executeCommand(ext.outputChannel, undefined, 'echo', `Downloading dependency from: ${downloadUrl}`); - axios.get(downloadUrl, { responseType: 'stream' }).then((response) => { + const response = await axios.get(downloadUrl, { responseType: 'stream' }); + await new Promise((resolve, reject) => { + const rejectDownload = async (error: Error) => { + const errorMessage = `Error downloading and extracting the ${dependencyName} zip file: ${error.message}`; + vscode.window.showErrorMessage(errorMessage); + context.telemetry.properties.error = errorMessage; + + try { + fs.rmSync(targetFolder, { recursive: true, force: true }); + await executeCommand(ext.outputChannel, undefined, 'echo', `[ExtractError]: Removed ${targetFolder}`); + } catch (cleanupError) { + try { + await executeCommand( + ext.outputChannel, + undefined, + 'echo', + `[ExtractError]: Failed to remove ${targetFolder}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}` + ); + } catch { + // Keep rejection behavior deterministic even if cleanup logging fails. + } + } finally { + reject(new Error(errorMessage)); + } + }; + executeCommand(ext.outputChannel, undefined, 'echo', `Creating temporary folder... ${tempFolderPath}`); fs.mkdirSync(tempFolderPath, { recursive: true }); fs.chmodSync(tempFolderPath, 0o777); const writer = fs.createWriteStream(dependencyFilePath); + response.data.on?.('error', rejectDownload); response.data.pipe(writer); writer.on('finish', async () => { - executeCommand(ext.outputChannel, undefined, 'echo', `Successfully downloaded ${dependencyName} dependency.`); - fs.chmodSync(dependencyFilePath, 0o777); - - // Extract to targetFolder - if (dependencyName === dotnetDependencyName) { - const version = dotNetVersion ?? semver.major(DependencyVersion.dotnet8); - if (process.platform === Platform.windows) { - await executeCommand( - ext.outputChannel, - undefined, - 'powershell -ExecutionPolicy Bypass -File', - dependencyFilePath, - '-InstallDir', - targetFolder, - '-Channel', - `${version}.0` - ); + try { + executeCommand(ext.outputChannel, undefined, 'echo', `Successfully downloaded ${dependencyName} dependency.`); + fs.chmodSync(dependencyFilePath, 0o777); + + // Extract to targetFolder + if (dependencyName === dotnetDependencyName) { + const version = dotNetVersion ?? semver.major(DependencyVersion.dotnet8); + if (process.platform === Platform.windows) { + await executeCommand( + ext.outputChannel, + undefined, + 'powershell -ExecutionPolicy Bypass -File', + dependencyFilePath, + '-InstallDir', + targetFolder, + '-Channel', + `${version}.0` + ); + } else { + await executeCommand(ext.outputChannel, undefined, dependencyFilePath, '-InstallDir', targetFolder, '-Channel', `${version}.0`); + } } else { - await executeCommand(ext.outputChannel, undefined, dependencyFilePath, '-InstallDir', targetFolder, '-Channel', `${version}.0`); - } - } else { - if (dependencyName === funcDependencyName || dependencyName === extensionBundleId) { - stopAllDesignTimeApis(); - } - await extractDependency(dependencyFilePath, targetFolder, dependencyName); - vscode.window.showInformationMessage(localize('successInstall', `Successfully installed ${dependencyName}`)); - if (dependencyName === funcDependencyName) { - // Add execute permissions for func and gozip binaries - if (process.platform !== Platform.windows) { - fs.chmodSync(`${targetFolder}/func`, 0o755); - fs.chmodSync(`${targetFolder}/gozip`, 0o755); - fs.chmodSync(`${targetFolder}/in-proc8/func`, 0o755); - fs.chmodSync(`${targetFolder}/in-proc6/func`, 0o755); + if (dependencyName === funcDependencyName || dependencyName === extensionBundleId) { + stopAllDesignTimeApis(); + } + await extractDependency(dependencyFilePath, targetFolder, dependencyName); + vscode.window.showInformationMessage(localize('successInstall', `Successfully installed ${dependencyName}`)); + if (dependencyName === funcDependencyName) { + // Add execute permissions for func and gozip binaries + if (process.platform !== Platform.windows) { + fs.chmodSync(`${targetFolder}/func`, 0o755); + fs.chmodSync(`${targetFolder}/gozip`, 0o755); + fs.chmodSync(`${targetFolder}/in-proc8/func`, 0o755); + fs.chmodSync(`${targetFolder}/in-proc6/func`, 0o755); + } + await setFunctionsCommand(); + await startAllDesignTimeApis(); + } else if (dependencyName === extensionBundleId) { + await startAllDesignTimeApis(); } - await setFunctionsCommand(); - await startAllDesignTimeApis(); - } else if (dependencyName === extensionBundleId) { - await startAllDesignTimeApis(); } + // remove the temp folder. + fs.rmSync(tempFolderPath, { recursive: true }); + executeCommand(ext.outputChannel, undefined, 'echo', `Removed ${tempFolderPath}`); + resolve(); + } catch (error) { + reject(error); } - // remove the temp folder. - fs.rmSync(tempFolderPath, { recursive: true }); - executeCommand(ext.outputChannel, undefined, 'echo', `Removed ${tempFolderPath}`); }); writer.on('error', async (error) => { - // log the error message the VSCode window and to telemetry. - const errorMessage = `Error downloading and extracting the ${dependencyName} zip file: ${error.message}`; - vscode.window.showErrorMessage(errorMessage); - context.telemetry.properties.error = errorMessage; - - // remove the target folder. - fs.rmSync(targetFolder, { recursive: true }); - await executeCommand(ext.outputChannel, undefined, 'echo', `[ExtractError]: Removed ${targetFolder}`); + await rejectDownload(error); }); }); } @@ -300,8 +325,23 @@ export async function binariesExist(dependencyName: string): Promise { } const binariesPath = path.join(binariesLocation, dependencyName); const binariesExist = fs.existsSync(binariesPath); + let expectedBinaryPath: string | undefined; + if (binariesExist) { + if (dependencyName === funcDependencyName) { + expectedBinaryPath = getGlobalSetting(funcCoreToolsBinaryPathSettingKey); + } else if (dependencyName === dotnetDependencyName) { + expectedBinaryPath = getGlobalSetting(dotNetBinaryPathSettingKey); + } else if (dependencyName === nodeJsDependencyName) { + expectedBinaryPath = getGlobalSetting(nodeJsBinaryPathSettingKey); + } + } executeCommand(ext.outputChannel, undefined, 'echo', `${dependencyName} Binaries: ${binariesPath}`); + if (expectedBinaryPath && !fs.existsSync(expectedBinaryPath)) { + executeCommand(ext.outputChannel, undefined, 'echo', `${dependencyName} binary is missing: ${expectedBinaryPath}`); + return false; + } + return binariesExist; } diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/common.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/common.test.ts new file mode 100644 index 00000000000..e61d4504349 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/common.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { workflowAuthenticationMethodKey, workflowSubscriptionIdKey } from '../../../../constants'; + +vi.mock('../../../../localize', () => ({ + localize: (_key: string, defaultMessage: string, ...args: unknown[]) => + defaultMessage.replace(/{(\d+)}/g, (_match, index) => String(args[Number(index)] ?? '')), +})); + +vi.mock('../../appSettings/localSettings', () => ({ + addOrUpdateLocalAppSettings: vi.fn(), + getLocalSettingsJson: vi.fn(), +})); + +vi.mock('../../../commands/workflows/azureConnectorWizard', () => ({ + createAzureWizard: vi.fn(), +})); + +vi.mock('../getAuthorizationToken', () => ({ + getAuthData: vi.fn(), +})); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + DialogResponses: { + cancel: { title: 'Cancel' }, + }, + parseError: (error: any) => ({ + isUserCancelledError: error?.isUserCancelledError === true, + message: error?.message ?? String(error), + }), + openUrl: vi.fn(), +})); + +vi.mock('fs', () => ({ + readFileSync: vi.fn(), +})); + +vi.mock('fs-extra', () => ({ + pathExists: vi.fn(), + readdir: vi.fn(), + lstat: vi.fn(), + writeFile: vi.fn(), +})); + +import { createAzureWizard } from '../../../commands/workflows/azureConnectorWizard'; +import { addOrUpdateLocalAppSettings, getLocalSettingsJson } from '../../appSettings/localSettings'; +import { getAuthData } from '../getAuthorizationToken'; +import { getAzureConnectorDetailsForLocalProject, invalidateAzureDetailsCache } from '../common'; + +describe('getAzureConnectorDetailsForLocalProject', () => { + const projectPath = 'D:\\workspace\\LogicApp'; + let context: any; + + beforeEach(() => { + vi.clearAllMocks(); + invalidateAzureDetailsCache(projectPath); + context = { + telemetry: { properties: {}, measurements: {} }, + }; + }); + + it('defaults cancelled Azure connector discovery to disabled raw-key settings', async () => { + vi.mocked(getLocalSettingsJson).mockResolvedValue({ Values: {} } as any); + vi.mocked(createAzureWizard).mockReturnValue({ + prompt: vi.fn().mockRejectedValue({ isUserCancelledError: true }), + execute: vi.fn(), + } as any); + + const details = await getAzureConnectorDetailsForLocalProject(context, projectPath); + + expect(details).toEqual({ enabled: false }); + expect(addOrUpdateLocalAppSettings).toHaveBeenCalledWith(context, projectPath, { + [workflowSubscriptionIdKey]: '', + [workflowAuthenticationMethodKey]: 'rawKeys', + }); + expect(getAuthData).not.toHaveBeenCalled(); + expect(context.telemetry.properties.azureConnectorsDefaulted).toBe('rawKeys'); + }); + + it('treats explicitly skipped Azure connectors as disabled without requesting auth', async () => { + vi.mocked(getLocalSettingsJson).mockResolvedValue({ Values: { [workflowSubscriptionIdKey]: '' } } as any); + + const details = await getAzureConnectorDetailsForLocalProject(context, projectPath); + + expect(details.enabled).toBe(false); + expect(createAzureWizard).not.toHaveBeenCalled(); + expect(getAuthData).not.toHaveBeenCalled(); + }); + + it('throws non-cancellation wizard failures', async () => { + vi.mocked(getLocalSettingsJson).mockResolvedValue({ Values: {} } as any); + vi.mocked(createAzureWizard).mockReturnValue({ + prompt: vi.fn().mockRejectedValue(new Error('wizard failed')), + execute: vi.fn(), + } as any); + + await expect(getAzureConnectorDetailsForLocalProject(context, projectPath)).rejects.toThrow('wizard failed'); + expect(addOrUpdateLocalAppSettings).not.toHaveBeenCalled(); + }); + + it('persists raw keys when the Azure wizard explicitly skips connectors', async () => { + vi.mocked(getLocalSettingsJson).mockResolvedValue({ Values: {} } as any); + vi.mocked(createAzureWizard).mockImplementation((wizardContext: any) => ({ + prompt: vi.fn(async () => { + wizardContext.enabled = false; + wizardContext.authenticationMethod = 'rawKeys'; + }), + execute: vi.fn(async () => { + await addOrUpdateLocalAppSettings(wizardContext, projectPath, { + [workflowSubscriptionIdKey]: '', + [workflowAuthenticationMethodKey]: 'rawKeys', + }); + }), + })) as any; + + const details = await getAzureConnectorDetailsForLocalProject(context, projectPath); + + expect(details.enabled).toBe(false); + expect(addOrUpdateLocalAppSettings).toHaveBeenCalledWith(context, projectPath, { + [workflowSubscriptionIdKey]: '', + [workflowAuthenticationMethodKey]: 'rawKeys', + }); + }); + + it('reads existing Azure connector settings without launching the wizard', async () => { + vi.mocked(getLocalSettingsJson).mockResolvedValue({ + Values: { + [workflowSubscriptionIdKey]: 'subscription-id', + WORKFLOWS_TENANT_ID: 'tenant-id', + WORKFLOWS_RESOURCE_GROUP_NAME: 'rg', + WORKFLOWS_LOCATION_NAME: 'westus', + }, + } as any); + vi.mocked(getAuthData).mockResolvedValue({ accessToken: 'token', account: { id: 'client-id.tenant-id' } } as any); + + const details = await getAzureConnectorDetailsForLocalProject(context, projectPath); + + expect(createAzureWizard).not.toHaveBeenCalled(); + expect(details).toEqual( + expect.objectContaining({ + enabled: true, + accessToken: 'Bearer token', + subscriptionId: 'subscription-id', + tenantId: 'tenant-id', + clientId: 'client-id', + }) + ); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts index 01c05fb8aed..a661045e3a9 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts @@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { window, workspace } from 'vscode'; import axios from 'axios'; import * as cp from 'child_process'; +import { EventEmitter } from 'events'; import findProcess from 'find-process'; import * as os from 'os'; import * as portfinder from 'portfinder'; import { ext } from '../../../../extensionVariables'; import * as workspaceUtils from '../../workspace'; -import { startAllDesignTimeApis, startDesignTimeApi, stopDesignTimeApi } from '../startDesignTimeApi'; +import { startAllDesignTimeApis, startDesignTimeApi, startDesignTimeProcess, stopDesignTimeApi } from '../startDesignTimeApi'; vi.mock('../../appSettings/localSettings', () => ({ addOrUpdateLocalAppSettings: vi.fn(), @@ -168,3 +169,127 @@ describe('startAllDesignTimeApis', () => { expect(cp.spawn).toHaveBeenCalledWith('kill', ['-9', '111']); }); }); + +describe('startDesignTimeProcess', () => { + let stdout: EventEmitter; + let stderr: EventEmitter; + let outputChannel: { append: ReturnType; appendLog: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + ext.designTimeInstances.clear(); + stdout = new EventEmitter(); + stderr = new EventEmitter(); + outputChannel = { + append: vi.fn(), + appendLog: vi.fn(), + }; + vi.mocked(cp.spawn).mockReturnValue({ + pid: 1234, + stdout, + stderr, + } as any); + }); + + it('suppresses repeated failing health-check output while preserving other host output', () => { + startDesignTimeProcess(outputChannel as any, 'D:/workspace/app-one/workflow-designtime', 'func', 'host', 'start'); + + stderr.emit('data', '[10:00:00] Health check failed: service is unhealthy\n'); + stderr.emit('data', '[10:00:01] Health check failed: service is unhealthy\n'); + stderr.emit('data', '[10:00:02] Health check failed: service is unhealthy\n'); + stderr.emit('data', 'A real startup error happened\n'); + + const appendedOutput = outputChannel.append.mock.calls.map(([value]) => value).join(''); + + expect(appendedOutput).toContain('[10:00:00] Health check failed: service is unhealthy\n'); + expect(appendedOutput).toContain('[Azure Logic Apps] Repeated failing health-check output suppressed.\n'); + expect(appendedOutput).toContain('A real startup error happened\n'); + expect(appendedOutput).not.toContain('[10:00:01] Health check failed: service is unhealthy\n'); + expect(appendedOutput).not.toContain('[10:00:02] Health check failed: service is unhealthy\n'); + }); + + it('suppresses repeated failing health-check output across chunked stdout lines', () => { + startDesignTimeProcess(outputChannel as any, 'D:/workspace/app-one/workflow-designtime', 'func', 'host', 'start'); + + stdout.emit('data', 'Health '); + stdout.emit('data', 'check failed: service is unhealthy\n'); + stdout.emit('data', 'Health check failed: service is unhealthy\n'); + stdout.emit('data', 'Host started\n'); + + const appendedOutput = outputChannel.append.mock.calls.map(([value]) => value).join(''); + + expect(appendedOutput).toContain('Health check failed: service is unhealthy\n'); + expect(appendedOutput).toContain('[Azure Logic Apps] Repeated failing health-check output suppressed.\n'); + expect(appendedOutput).toContain('Host started\n'); + }); + + it('normalizes Functions host health-check invocation ids and durations before suppressing repeats', () => { + startDesignTimeProcess(outputChannel as any, 'D:/workspace/app-one/workflow-designtime', 'func', 'host', 'start'); + + stderr.emit('data', "Executed 'Functions.HealthCheck' (Failed, Id=11111111-1111-1111-1111-111111111111, Duration=12ms)\n"); + stderr.emit('data', "Executed 'Functions.HealthCheck' (Failed, Id=22222222-2222-2222-2222-222222222222, Duration=48ms)\n"); + + const appendedOutput = outputChannel.append.mock.calls.map(([value]) => value).join(''); + + expect(appendedOutput).toContain("Executed 'Functions.HealthCheck' (Failed, Id=11111111-1111-1111-1111-111111111111, Duration=12ms)\n"); + expect(appendedOutput).toContain('[Azure Logic Apps] Repeated failing health-check output suppressed.\n'); + expect(appendedOutput).not.toContain('22222222-2222-2222-2222-222222222222'); + }); + + it('suppresses repeated process unhealthy logs with health-check entries', () => { + startDesignTimeProcess(outputChannel as any, 'D:/workspace/app-one/workflow-designtime', 'func', 'host', 'start'); + + const healthCheckEntries = + 'Process reporting unhealthy: Unhealthy. Health check entries are {"azure.functions.web_host.lifecycle":{"status":"Healthy","description":null},"azure.functions.script_host.lifecycle":{"status":"Healthy","description":null},"azure.functions.webjobs.storage":{"status":"Unhealthy","description":"Unable to create client for AzureWebJobsStorage"}}\n'; + + stderr.emit('data', healthCheckEntries); + stderr.emit('data', healthCheckEntries); + + const appendedOutput = outputChannel.append.mock.calls.map(([value]) => value).join(''); + + expect(appendedOutput).toContain(healthCheckEntries); + expect(appendedOutput).toContain('[Azure Logic Apps] Repeated failing health-check output suppressed.\n'); + expect(appendedOutput.indexOf(healthCheckEntries)).toBe(appendedOutput.lastIndexOf(healthCheckEntries)); + }); + + it('flushes final unterminated output when a host stream closes', () => { + startDesignTimeProcess(outputChannel as any, 'D:/workspace/app-one/workflow-designtime', 'func', 'host', 'start'); + + stderr.emit('data', 'A real startup error without newline'); + stderr.emit('close'); + + const appendedOutput = outputChannel.append.mock.calls.map(([value]) => value).join(''); + + expect(appendedOutput).toContain('A real startup error without newline'); + }); + + it('keeps stdout and stderr partial-line buffers independent', () => { + startDesignTimeProcess(outputChannel as any, 'D:/workspace/app-one/workflow-designtime', 'func', 'host', 'start'); + + stdout.emit('data', 'Health '); + stderr.emit('data', 'A real startup error\n'); + stdout.emit('data', 'check failed: service is unhealthy\n'); + + const appendedOutput = outputChannel.append.mock.calls.map(([value]) => value).join(''); + + expect(appendedOutput).toContain('A real startup error\n'); + expect(appendedOutput).toContain('Health check failed: service is unhealthy\n'); + expect(appendedOutput).not.toContain('Health A real startup error'); + }); + + it('does not suppress unrelated output or existing restart-trigger diagnostics', () => { + startDesignTimeProcess(outputChannel as any, 'D:/workspace/app-one/workflow-designtime', 'func', 'host', 'start'); + + stdout.emit('data', 'Failed to start a new language worker for runtime: node\n'); + stderr.emit('data', 'Port 7071 is unavailable. Close the process using that port, or specify another port using --port.\n'); + + const appendedOutput = outputChannel.append.mock.calls.map(([value]) => value).join(''); + + expect(appendedOutput).toContain('Failed to start a new language worker for runtime: node\n'); + expect(appendedOutput).toContain('Port 7071 is unavailable.'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( + 'Language worker issue found when launching func most likely due to a conflicting port. Restarting design-time process.' + ); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('Conflicting port found when launching func. Restarting design-time process.'); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/common.ts b/apps/vs-code-designer/src/app/utils/codeless/common.ts index 7110eecdd69..be73573cc4e 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/common.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/common.ts @@ -7,6 +7,7 @@ import { workflowManagementBaseURIKey, managementApiPrefix, workflowFileName, + workflowAuthenticationMethodKey, artifactsDirectory, mapsDirectory, schemasDirectory, @@ -19,11 +20,11 @@ import { localize } from '../../../localize'; import { createAzureWizard } from '../../commands/workflows/azureConnectorWizard'; import type { IAzureConnectorsContext } from '../../commands/workflows/azureConnectorWizard'; import type { RemoteWorkflowTreeItem } from '../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; -import { getLocalSettingsJson } from '../appSettings/localSettings'; +import { addOrUpdateLocalAppSettings, getLocalSettingsJson } from '../appSettings/localSettings'; import { writeFormattedJson } from '../fs'; import { getAuthData } from './getAuthorizationToken'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import { DialogResponses } from '@microsoft/vscode-azext-utils'; +import { DialogResponses, parseError } from '@microsoft/vscode-azext-utils'; import type { IWorkflowFileContent, StandardApp, @@ -192,6 +193,17 @@ export async function getArtifactsPathInLocalProject(projectPath: string): Promi const azureDetailsCache = new Map(); const AZURE_DETAILS_CACHE_TTL = 300000; // 5 minutes +async function defaultAzureConnectorDetailsToRawKeys(context: IActionContext, projectPath: string): Promise { + await addOrUpdateLocalAppSettings(context, projectPath, { + [workflowSubscriptionIdKey]: '', + [workflowAuthenticationMethodKey]: 'rawKeys', + }); + + return { + enabled: false, + }; +} + /** * Invalidates the cached Azure connector details for a project. * Call after Azure settings change (e.g., enabling Azure connectors). @@ -225,8 +237,19 @@ export async function getAzureConnectorDetailsForLocalProject( if (subscriptionId === undefined) { const wizard = createAzureWizard(connectorsContext, projectPath); - await wizard.prompt(); - await wizard.execute(); + try { + await wizard.prompt(); + await wizard.execute(); + } catch (error) { + if (!parseError(error).isUserCancelledError) { + throw error; + } + + context.telemetry.properties.azureConnectorsDefaulted = 'rawKeys'; + const defaultDetails = await defaultAzureConnectorDetailsToRawKeys(context, projectPath); + azureDetailsCache.set(projectPath, { timestamp: now, details: defaultDetails }); + return defaultDetails; + } tenantId = connectorsContext.tenantId; subscriptionId = connectorsContext.subscriptionId; diff --git a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts index 5a23e78a7bf..15920caa81a 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -27,7 +27,7 @@ const processValidationCache = new Map') + .replace(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?/g, '') + .replace(/\[\d{1,2}:\d{2}:\d{2}(?:\.\d+)?\]/g, '[]') + .replace(/\[[0-9a-f-]{16,}\]/gi, '[]') + .replace(/\bid=[0-9a-f-]{16,}/gi, 'id=') + .replace(/\bduration=\d+(?:\.\d+)?ms/gi, 'duration=') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function createDesignTimeHostOutputAppender(outputChannel: IAzExtOutputChannel): { append: (data: string) => void; flush: () => void } { + const seenFailingHealthChecks = new Set(); + const suppressedFailingHealthChecks = new Set(); + let bufferedData = ''; + + const appendLine = (line: string): void => { + if (!isFailingHealthCheckLogLine(line)) { + outputChannel.append(line); + return; + } + + const normalizedLine = normalizeHealthCheckLogLine(line); + if (!seenFailingHealthChecks.has(normalizedLine)) { + seenFailingHealthChecks.add(normalizedLine); + outputChannel.append(line); + return; + } + + if (!suppressedFailingHealthChecks.has(normalizedLine)) { + suppressedFailingHealthChecks.add(normalizedLine); + outputChannel.append('[Azure Logic Apps] Repeated failing health-check output suppressed.\n'); + } + }; + + const append = (data: string): void => { + bufferedData += data; + const completeLinePattern = /.*(?:\r\n|\n|\r)/g; + let match: RegExpExecArray | null; + let lastCompleteLineIndex = 0; + + while ((match = completeLinePattern.exec(bufferedData))) { + appendLine(match[0]); + lastCompleteLineIndex = completeLinePattern.lastIndex; + } + + bufferedData = bufferedData.slice(lastCompleteLineIndex); + if (bufferedData.length > 8192) { + outputChannel.append(bufferedData); + bufferedData = ''; + } + }; + + const flush = (): void => { + if (!bufferedData) { + return; + } + + appendLine(bufferedData); + bufferedData = ''; + }; + + return { append, flush }; +} + function normalizeTrackedChildProcessId(parentProcessId: number, childFuncPid?: string): string | undefined { return childFuncPid && childFuncPid !== parentProcessId.toString() ? childFuncPid : undefined; } @@ -242,7 +317,6 @@ export async function startDesignTimeApi(projectPath: string): Promise { actionContext.telemetry.properties.startDesignTimeApi = 'true'; updateFuncIgnore(projectPath, [`${designTimeDirectoryName}/`]); actionContext.telemetry.measurements.startDesignTimeApiDuration = (Date.now() - loadDesignTimeStart) / 1000; - getAzureConnectorDetailsForLocalProject(actionContext, projectPath).catch(() => {}); } catch (error) { const errorMessage = getErrorMessage(error); const viewOutput: MessageItem = { title: localize('viewOutput', 'View output') }; @@ -494,14 +568,16 @@ export function startDesignTimeProcess( } const projectPath = workingDirectory ? path.dirname(workingDirectory) : ''; + const appendStdout = outputChannel ? createDesignTimeHostOutputAppender(outputChannel) : undefined; + const appendStderr = outputChannel ? createDesignTimeHostOutputAppender(outputChannel) : undefined; const stdout = designChildProcess.stdout; stdout?.on('data', (data: string | Buffer) => { data = data.toString(); cmdOutput = cmdOutput.concat(data); cmdOutputIncludingStderr = cmdOutputIncludingStderr.concat(data); const languageWorkerText = 'Failed to start a new language worker for runtime: node'; - if (outputChannel) { - outputChannel.append(data); + if (appendStdout) { + appendStdout.append(data); } if (data.toLowerCase().includes(languageWorkerText.toLowerCase())) { ext.outputChannel.appendLog( @@ -512,14 +588,16 @@ export function startDesignTimeProcess( scheduleStartDesignTimeApi(projectPath); } }); + stdout?.on('end', () => appendStdout?.flush()); + stdout?.on('close', () => appendStdout?.flush()); const stderr = designChildProcess.stderr; stderr?.on('data', (data: string | Buffer) => { data = data.toString(); cmdOutputIncludingStderr = cmdOutputIncludingStderr.concat(data); const portUnavailableText = 'is unavailable. Close the process using that port, or specify another port using'; - if (outputChannel) { - outputChannel.append(data); + if (appendStderr) { + appendStderr.append(data); } if (data.toLowerCase().includes(portUnavailableText.toLowerCase())) { ext.outputChannel.appendLog('Conflicting port found when launching func. Restarting design-time process.'); @@ -528,6 +606,8 @@ export function startDesignTimeProcess( scheduleStartDesignTimeApi(projectPath); } }); + stderr?.on('end', () => appendStderr?.flush()); + stderr?.on('close', () => appendStderr?.flush()); const designTimeInst = ext.designTimeInstances.get(projectPath); if (designTimeInst) { diff --git a/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts b/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts index 71307ea6744..7907c65324a 100644 --- a/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts +++ b/apps/vs-code-designer/src/app/utils/languageServerProtocol.ts @@ -1,9 +1,9 @@ import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; import path from 'path'; import * as fse from 'fs-extra'; -import { autoRuntimeDependenciesPathSettingKey, assetsFolderName, lspDirectory } from '../../constants'; +import { assetsFolderName, lspDirectory } from '../../constants'; import { ext } from '../../extensionVariables'; -import { getGlobalSetting } from './vsCodeConfig/settings'; +import { ensureRuntimeDependenciesPath } from './runtimeDependenciesPath'; import AdmZip from 'adm-zip'; import { createHash } from 'crypto'; @@ -13,8 +13,7 @@ const lspSdkHashMarkerName = '.lspsdk-hash'; export async function installLSPSDK(): Promise { await callWithTelemetryAndErrorHandling('azureLogicAppsStandard.installLSPSDK', async () => { - const targetDirectory = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); - await fse.ensureDir(targetDirectory); + const targetDirectory = await ensureRuntimeDependenciesPath(); // Check if LSPServer needs to be extracted or updated const serverZipFile = path.join(__dirname, assetsFolderName, 'LSPServer', 'LSPServer.zip'); diff --git a/apps/vs-code-designer/src/app/utils/runtimeDependenciesPath.ts b/apps/vs-code-designer/src/app/utils/runtimeDependenciesPath.ts new file mode 100644 index 00000000000..f1e9ecf58b4 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/runtimeDependenciesPath.ts @@ -0,0 +1,15 @@ +import * as fse from 'fs-extra'; +import { autoRuntimeDependenciesPathSettingKey, defaultDependencyPathValue } from '../../constants'; +import { getGlobalSetting, updateGlobalSetting } from './vsCodeConfig/settings'; + +export async function ensureRuntimeDependenciesPath(): Promise { + const configuredPath = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); + const dependenciesPath = configuredPath || defaultDependencyPathValue; + + if (!configuredPath) { + await updateGlobalSetting(autoRuntimeDependenciesPathSettingKey, dependenciesPath); + } + + await fse.ensureDir(dependenciesPath); + return dependenciesPath; +} diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index 3b5697af6e7..20b16b7972a 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -1,3 +1,4 @@ +import './nodeUtilCompatibility'; import { LogicAppResolver } from './LogicAppResolver'; import { runPostWorkflowCreateStepsFromCache } from './app/commands/createWorkflow/createWorkflowSteps/workflowCreateStepBase'; import { promptParameterizeConnections } from './app/commands/parameterizeConnections'; diff --git a/apps/vs-code-designer/src/nodeUtilCompatibility.ts b/apps/vs-code-designer/src/nodeUtilCompatibility.ts new file mode 100644 index 00000000000..bd7e9a67be6 --- /dev/null +++ b/apps/vs-code-designer/src/nodeUtilCompatibility.ts @@ -0,0 +1,17 @@ +import util from 'node:util'; + +type LegacyNodeUtil = { + isArray?: (value: unknown) => value is unknown[]; + isNullOrUndefined?: (value: unknown) => value is null | undefined; + isNumber?: (value: unknown) => value is number; +}; + +export function applyNodeUtilCompatibility(): void { + const legacyUtil = util as LegacyNodeUtil; + + legacyUtil.isArray ??= Array.isArray; + legacyUtil.isNullOrUndefined ??= (value: unknown): value is null | undefined => value === null || value === undefined; + legacyUtil.isNumber ??= (value: unknown): value is number => typeof value === 'number'; +} + +applyNodeUtilCompatibility(); diff --git a/apps/vs-code-designer/src/test/ui/connectionPromptFallback.test.ts b/apps/vs-code-designer/src/test/ui/connectionPromptFallback.test.ts new file mode 100644 index 00000000000..1dbec0846b1 --- /dev/null +++ b/apps/vs-code-designer/src/test/ui/connectionPromptFallback.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { EditorView, Key, type WebDriver, VSBrowser, Workbench } from 'vscode-extension-tester'; +import { clearBlockingUI, dismissAllDialogs, dismissNotifications, sleep } from './helpers'; +import { + waitForExtensionValidationComplete, + openWorkspaceFileInSession, + openDesignerViaExplorer, + switchToDesignerWebview, +} from './designerHelpers'; +import { WORKSPACE_MANIFEST_PATH, loadWorkspaceManifest } from './workspaceManifest'; +import type { WorkspaceManifestEntry } from './workspaceManifest'; + +const TEST_TIMEOUT = 360_000; + +function removeAzureConnectorSentinels(appDir: string): { localSettingsPath: string; originalContents: string | undefined } { + const localSettingsPath = path.join(appDir, 'local.settings.json'); + const originalContents = fs.existsSync(localSettingsPath) ? fs.readFileSync(localSettingsPath, 'utf-8') : undefined; + const settings = fs.existsSync(localSettingsPath) + ? JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')) + : { IsEncrypted: false, Values: {} }; + settings.Values = settings.Values ?? {}; + + for (const key of [ + 'WORKFLOWS_SUBSCRIPTION_ID', + 'WORKFLOWS_TENANT_ID', + 'WORKFLOWS_RESOURCE_GROUP_NAME', + 'WORKFLOWS_LOCATION_NAME', + 'WORKFLOWS_AUTHENTICATION_METHOD', + ]) { + delete settings.Values[key]; + } + + fs.writeFileSync(localSettingsPath, JSON.stringify(settings, null, 2)); + console.log(`[connectionPromptFallback] Removed Azure connector sentinels from ${localSettingsPath}`); + return { localSettingsPath, originalContents }; +} + +async function cancelVisibleQuickPickIfPresent(driver: WebDriver): Promise { + const quickInputVisible = await driver + .executeScript( + 'return Array.from(document.querySelectorAll(".quick-input-widget")).some((w) => {' + + 'const s = getComputedStyle(w);' + + 'return s.display !== "none" && s.visibility !== "hidden" && w.offsetParent !== null;' + + '});' + ) + .catch(() => false); + + if (!quickInputVisible) { + return false; + } + + console.log('[connectionPromptFallback] Cancelling visible QuickPick'); + await driver.actions().sendKeys(Key.ESCAPE).perform(); + await sleep(1000); + return true; +} + +describe('Connection prompt fallback E2E', function () { + this.timeout(TEST_TIMEOUT); + + let driver: WebDriver; + let workbench: Workbench; + let manifest: WorkspaceManifestEntry[]; + let restoreLocalSettings: (() => void) | undefined; + + before(async function () { + this.timeout(TEST_TIMEOUT); + if (!fs.existsSync(WORKSPACE_MANIFEST_PATH)) { + assert.fail(`Workspace manifest not found at ${WORKSPACE_MANIFEST_PATH} - Phase 4.1 must run first`); + return; + } + + manifest = loadWorkspaceManifest(); + if (manifest.length === 0) { + assert.fail('Workspace manifest is empty - Phase 4.1 must create workspaces first'); + return; + } + + driver = VSBrowser.instance.driver; + workbench = new Workbench(); + if (process.env.LA_E2E_SKIP_VALIDATION_WAIT === '1') { + console.log('[connectionPromptFallback] Skipping dependency validation wait by scenario setting'); + } else { + await waitForExtensionValidationComplete(driver); + } + }); + + afterEach(async () => { + restoreLocalSettings?.(); + restoreLocalSettings = undefined; + + try { + await driver.switchTo().defaultContent(); + } catch { + /* ignore */ + } + try { + await new EditorView().closeAllEditors(); + } catch { + /* ignore */ + } + await sleep(1000); + }); + + it('loads the local designer when Azure connector prompts are cancelled/defaulted', async () => { + const entry = + manifest.find((candidate) => candidate.appType === 'standard' && candidate.wfType === 'Stateful') || + manifest.find((candidate) => candidate.appType === 'standard'); + if (!entry) { + assert.fail('No Standard workspace entry found in manifest'); + return; + } + + const localSettingsSnapshot = removeAzureConnectorSentinels(entry.appDir); + restoreLocalSettings = () => { + if (localSettingsSnapshot.originalContents === undefined) { + fs.rmSync(localSettingsSnapshot.localSettingsPath, { force: true }); + } else { + fs.writeFileSync(localSettingsSnapshot.localSettingsPath, localSettingsSnapshot.originalContents); + } + }; + + await openWorkspaceFileInSession(workbench, entry.wsFilePath); + driver = VSBrowser.instance.driver; + workbench = new Workbench(); + await sleep(4000); + await clearBlockingUI(driver); + + const workflowJsonPath = path.join(entry.wfDir, 'workflow.json'); + const opened = await openDesignerViaExplorer(driver, workflowJsonPath, entry.wfName || 'workflow', false, true); + assert.ok(opened, 'Designer should open even when Azure connector settings are missing'); + + await driver.switchTo().defaultContent(); + await cancelVisibleQuickPickIfPresent(driver); + await dismissAllDialogs(driver); + await dismissNotifications(driver); + + const webview = await switchToDesignerWebview(driver); + assert.ok(webview, 'Designer webview should load after connector prompt cancellation/default'); + + await driver.switchTo().defaultContent(); + const localSettings = JSON.parse(fs.readFileSync(path.join(entry.appDir, 'local.settings.json'), 'utf-8')); + assert.strictEqual(localSettings.Values?.WORKFLOWS_SUBSCRIPTION_ID, '', 'Cancellation should persist Azure connectors disabled'); + assert.strictEqual(localSettings.Values?.WORKFLOWS_AUTHENTICATION_METHOD, 'rawKeys', 'Cancellation should default to connection keys'); + }); +}); diff --git a/apps/vs-code-designer/src/test/ui/designerActions.test.ts b/apps/vs-code-designer/src/test/ui/designerActions.test.ts index 6b581d77fb3..8f51a76efdb 100644 --- a/apps/vs-code-designer/src/test/ui/designerActions.test.ts +++ b/apps/vs-code-designer/src/test/ui/designerActions.test.ts @@ -27,7 +27,10 @@ import { waitForRunStatusInList as waitForRunStatusInListWithRefresh, verifyAllNodesSucceeded as verifyAllNodesSucceededWithActions, } from './runHelpers'; -import { openWorkspaceFileInSession as openWorkspaceFileInSessionShared } from './designerHelpers'; +import { + openWorkspaceFileInSession as openWorkspaceFileInSessionShared, + waitForDependencyValidation as waitForDependencyValidationShared, +} from './designerHelpers'; let __warmedThisSession = false; @@ -741,145 +744,7 @@ async function openFileInEditor(workbench: Workbench, driver: WebDriver, filePat * openDesigner command is registered. */ async function waitForDependencyValidation(driver: WebDriver, timeoutMs = 60_000): Promise { - const t0 = Date.now(); - const VALIDATION_TEXT = 'Validating Runtime Dependency'; - const funcBinaryPath = path.join( - os.homedir(), - '.azurelogicapps', - 'dependencies', - 'FuncCoreTools', - process.platform === 'win32' ? 'func.exe' : 'func' - ); - - const isExecutableFile = (filePath: string): boolean => { - try { - fs.accessSync(filePath, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); - return true; - } catch { - return false; - } - }; - - const ensureRuntimeDependencyExecutablePermissions = (): void => { - // Fix execute permissions on downloaded runtime binaries. - // The extension's download/extract doesn't set chmod +x on Linux, causing - // "/bin/sh: 1: .../func: Permission denied" when running `func host start`. - if (process.platform !== 'linux' && process.platform !== 'darwin') { - return; - } - - const depsRoot = path.join(os.homedir(), '.azurelogicapps', 'dependencies'); - let fixedAny = false; - for (const subDir of ['FuncCoreTools', 'NodeJs', 'DotNetSDK']) { - const binDir = path.join(depsRoot, subDir); - if (fs.existsSync(binDir)) { - try { - const { execSync } = require('child_process'); - execSync(`chmod -R +x "${binDir}"`, { stdio: 'ignore' }); - fixedAny = true; - } catch { - /* ignore */ - } - } - } - if (fixedAny) { - console.log('[depValidation] Fixed execute permissions on runtime binaries'); - } - }; - - ensureRuntimeDependencyExecutablePermissions(); - - const isValidationVisible = async (): Promise => { - try { - return ( - (await driver.executeScript(` - var vt = ${JSON.stringify(VALIDATION_TEXT)}; - var els = document.querySelectorAll('[role="dialog"], .notification-toast, .notifications-toasts .notification-list-item'); - for (var i = 0; i < els.length; i++) { - if ((els[i].textContent || '').includes(vt)) return true; - } - return false; - `)) ?? false - ); - } catch { - return false; - } - }; - - // Check if func binary already exists (from a previous phase) - if (fs.existsSync(funcBinaryPath)) { - console.log(`[depValidation] func binary already exists at ${funcBinaryPath}`); - // Still wait for the notification to complete if visible - if (await isValidationVisible()) { - console.log(`[depValidation] "${VALIDATION_TEXT}" is visible — waiting for it to finish`); - while (Date.now() - t0 < timeoutMs && (await isValidationVisible())) { - await sleep(2000); - } - console.log(`[depValidation] Validation complete (${Date.now() - t0}ms)`); - } - if (isExecutableFile(funcBinaryPath)) { - return; - } - console.log('[depValidation] func binary exists but is not executable yet — continuing to poll'); - } - - // Wait for the notification to appear and complete - if (await isValidationVisible()) { - console.log(`[depValidation] "${VALIDATION_TEXT}" is visible — waiting for it to finish`); - while (Date.now() - t0 < timeoutMs) { - if (!(await isValidationVisible())) { - console.log(`[depValidation] Notification gone (${Date.now() - t0}ms)`); - break; - } - await sleep(2000); - } - } else { - // Wait for it to appear - const deadline = Date.now() + Math.min(timeoutMs, 30_000); - while (Date.now() < deadline) { - if (await isValidationVisible()) { - console.log(`[depValidation] "${VALIDATION_TEXT}" appeared (${Date.now() - t0}ms) — waiting for it to finish`); - while (Date.now() - t0 < timeoutMs && (await isValidationVisible())) { - await sleep(2000); - } - console.log(`[depValidation] Notification gone (${Date.now() - t0}ms)`); - break; - } - if (fs.existsSync(funcBinaryPath)) { - console.log(`[depValidation] func binary found (${Date.now() - t0}ms)`); - ensureRuntimeDependencyExecutablePermissions(); - if (isExecutableFile(funcBinaryPath)) { - return; - } - console.log('[depValidation] func binary found but is not executable yet — continuing to poll'); - } - await sleep(2000); - } - } - - // Wait for func binary on disk - const funcDeadline = Date.now() + Math.max(timeoutMs - (Date.now() - t0), 60_000); - while (Date.now() < funcDeadline) { - if (fs.existsSync(funcBinaryPath)) { - console.log(`[depValidation] func binary found at ${funcBinaryPath} (${Date.now() - t0}ms)`); - await sleep(3000); - ensureRuntimeDependencyExecutablePermissions(); - if (isExecutableFile(funcBinaryPath)) { - return; - } - console.log('[depValidation] func binary still not executable after chmod — continuing to poll'); - } - console.log(`[depValidation] Waiting for func binary... (${Date.now() - t0}ms)`); - await sleep(5000); - } - - if (fs.existsSync(funcBinaryPath) && !isExecutableFile(funcBinaryPath)) { - throw new Error( - `[depValidation] func binary exists but is not executable after ${Math.round((Date.now() - t0) / 1000)}s: ${funcBinaryPath}` - ); - } - - throw new Error(`[depValidation] func binary not found after ${Math.round((Date.now() - t0) / 1000)}s: ${funcBinaryPath}`); + await waitForDependencyValidationShared(driver, timeoutMs); } /** @@ -2965,7 +2830,7 @@ describe('Designer Actions Tests', function () { }; before(async function () { - this.timeout(300_000); + this.timeout(420_000); fs.mkdirSync(EXPLICIT_SCREENSHOT_DIR, { recursive: true }); if (!fs.existsSync(WORKSPACE_MANIFEST_PATH)) { @@ -2981,7 +2846,7 @@ describe('Designer Actions Tests', function () { driver = VSBrowser.instance.driver; workbench = new Workbench(); - await waitForDependencyValidation(driver, 300_000); + await waitForDependencyValidation(driver, 360_000); }); beforeEach(async function () { diff --git a/apps/vs-code-designer/src/test/ui/designerHelpers.ts b/apps/vs-code-designer/src/test/ui/designerHelpers.ts index 9bcd2f42681..3fb52e0afa2 100644 --- a/apps/vs-code-designer/src/test/ui/designerHelpers.ts +++ b/apps/vs-code-designer/src/test/ui/designerHelpers.ts @@ -153,11 +153,28 @@ function isExecutableFile(filePath: string): boolean { function getFuncCoreToolsCandidatePaths(): string[] { const executableName = process.platform === 'win32' ? 'func.exe' : 'func'; const funcToolsRoot = path.join(os.homedir(), '.azurelogicapps', 'dependencies', 'FuncCoreTools'); - return [ + const candidates = [ path.join(funcToolsRoot, executableName), path.join(funcToolsRoot, 'in-proc8', executableName), path.join(funcToolsRoot, 'in-proc6', executableName), ]; + + const directoriesToScan = [funcToolsRoot]; + for (const directory of directoriesToScan) { + if (!fs.existsSync(directory)) { + continue; + } + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + directoriesToScan.push(entryPath); + } else if (entry.name === executableName) { + candidates.push(entryPath); + } + } + } + + return [...new Set(candidates)]; } function getFuncCoreToolsPath(): string { @@ -752,7 +769,7 @@ export async function openFileInEditor(workbench: Workbench, driver: WebDriver, export async function waitForDependencyValidation(driver: WebDriver, timeoutMs = DEPENDENCY_VALIDATION_TIMEOUT): Promise { const t0 = Date.now(); const VALIDATION_TEXT = 'Validating Runtime Dependency'; - const funcBinaryPath = getFuncCoreToolsPath(); + const getCurrentFuncBinaryPath = (): string => getFuncCoreToolsPath(); // The extension's download/extract can leave Linux/macOS binaries without // execute bits. Apply this before and after validation because validation can @@ -851,6 +868,7 @@ export async function waitForDependencyValidation(driver: WebDriver, timeoutMs = } // Check if func binary already exists (validation may have completed before we started) + const funcBinaryPath = getCurrentFuncBinaryPath(); if (fs.existsSync(funcBinaryPath)) { if (Date.now() - t0 < 15_000) { await sleep(2000); @@ -868,7 +886,7 @@ export async function waitForDependencyValidation(driver: WebDriver, timeoutMs = await sleep(2000); } - if (!everAppeared && !fs.existsSync(funcBinaryPath)) { + if (!everAppeared && !fs.existsSync(getCurrentFuncBinaryPath())) { console.log('[depValidation] Notification never appeared and func not found — waiting for func binary on disk'); } } @@ -878,6 +896,7 @@ export async function waitForDependencyValidation(driver: WebDriver, timeoutMs = // it may disappear between dependency stages or get auto-dismissed. const funcDeadline = Date.now() + Math.max(timeoutMs - (Date.now() - t0), 60_000); while (Date.now() < funcDeadline) { + const funcBinaryPath = getCurrentFuncBinaryPath(); if (fs.existsSync(funcBinaryPath)) { console.log(`[depValidation] func binary found at ${funcBinaryPath} (${Date.now() - t0}ms)`); diff --git a/apps/vs-code-designer/src/test/ui/inlineJavascript.test.ts b/apps/vs-code-designer/src/test/ui/inlineJavascript.test.ts index 3370b03ba8b..d77d632086a 100644 --- a/apps/vs-code-designer/src/test/ui/inlineJavascript.test.ts +++ b/apps/vs-code-designer/src/test/ui/inlineJavascript.test.ts @@ -246,6 +246,8 @@ describe(`Inline JavaScript Tests (shape=${TARGET_SHAPE})`, function () { // Debug → Run → Verify workbench = new Workbench(); await startDebugging(workbench, driver); + const runtimeReady = await waitForRuntimeReady(driver, { requireHostRunning: true, workspacePaths: [entry.appDir] }); + assert.ok(runtimeReady, 'Functions runtime should start and become ready before opening overview'); try { await new EditorView().closeAllEditors(); await sleep(1000); @@ -254,8 +256,6 @@ describe(`Inline JavaScript Tests (shape=${TARGET_SHAPE})`, function () { } workbench = new Workbench(); const ovWv = await waitForOverviewView(workbench, driver, wjp); - const runtimeReady = await waitForRuntimeReady(driver); - assert.ok(runtimeReady, 'Functions runtime should start and become ready'); assert.ok(await invokeWorkflowCallback(driver, { workflowName: entry.wfName }), 'Workflow callback should be invokable'); await sleep(1000); await clickRefresh(driver); diff --git a/apps/vs-code-designer/src/test/ui/run-e2e.js b/apps/vs-code-designer/src/test/ui/run-e2e.js index 9c462288d51..df3f10fc177 100644 --- a/apps/vs-code-designer/src/test/ui/run-e2e.js +++ b/apps/vs-code-designer/src/test/ui/run-e2e.js @@ -16,7 +16,8 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); -const { exec } = require('child_process'); +const { exec, execSync } = require('child_process'); +const { ExTester } = require('vscode-extension-tester'); const projectDir = path.resolve(__dirname, '..', '..', '..'); const distDir = path.join(projectDir, 'dist'); @@ -27,7 +28,16 @@ const distDir = path.join(projectDir, 'dist'); * change between VS Code versions. Pinning ensures the same version is used * locally and in CI. Update this when ExTester releases support for newer versions. */ -const VSCODE_VERSION = '1.108.0'; +const DEFAULT_VSCODE_VERSION = '1.108.0'; +const VSCODE_VERSION_SOURCE = process.env.E2E_VSCODE_VERSION ? 'E2E_VSCODE_VERSION' : process.env.CODE_VERSION ? 'CODE_VERSION' : 'default'; +if (process.env.E2E_VSCODE_VERSION && process.env.CODE_VERSION && process.env.E2E_VSCODE_VERSION !== process.env.CODE_VERSION) { + throw new Error( + `E2E_VSCODE_VERSION (${process.env.E2E_VSCODE_VERSION}) and CODE_VERSION (${process.env.CODE_VERSION}) disagree. ` + + 'Set only one VS Code version override for run-e2e.js.' + ); +} +const VSCODE_VERSION = process.env.E2E_VSCODE_VERSION || process.env.CODE_VERSION || DEFAULT_VSCODE_VERSION; +process.env.CODE_VERSION = VSCODE_VERSION; const DOWNLOAD_RETRY_ATTEMPTS = 3; // Store test-extensions in test-resources/ (alongside VS Code download) rather @@ -36,6 +46,14 @@ const DOWNLOAD_RETRY_ATTEMPTS = 3; const extDir = path.join(os.tmpdir(), 'test-resources', 'test-extensions'); const testGlob = path.resolve(projectDir, 'out', 'test', '*.js').replace(/\\/g, '/'); +function createExTester() { + return new ExTester( + undefined, // storageFolder — use default (os.tmpdir()/test-resources) + undefined, // releaseType — Stable + extDir // extensionsDir — isolated dir, passed as --extensions-dir + ); +} + /** * Recursively copy a directory, skipping test-extensions itself to avoid infinite recursion. */ @@ -93,9 +111,80 @@ function installExtensionWithCli(cliBase, dep, label = dep) { }); } -async function downloadExTesterAssets(extest) { - await withDownloadRetry(`download VS Code ${VSCODE_VERSION}`, () => extest.downloadCode(VSCODE_VERSION)); - await withDownloadRetry(`download ChromeDriver ${VSCODE_VERSION}`, () => extest.downloadChromeDriver(VSCODE_VERSION)); +function findNestedWindowsCliPath(codeFolder) { + if (process.platform !== 'win32') { + return undefined; + } + + try { + for (const entry of fs.readdirSync(codeFolder, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const candidate = path.join(codeFolder, entry.name, 'resources', 'app', 'out', 'cli.js'); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + /* diagnostic only */ + } + + return undefined; +} + +function preflightVSCodeCli(extest) { + const codeFolder = extest.code.getCodeFolder(); + const cliPath = extest.code.getCliPath(); + const executablePath = extest.code.executablePath; + const nestedCliPath = findNestedWindowsCliPath(codeFolder); + + if (!fs.existsSync(executablePath)) { + throw new Error(`VS Code executable not found at ${executablePath}. Clear ${codeFolder} and rerun the E2E launcher.`); + } + + if (!fs.existsSync(cliPath)) { + const nestedHint = nestedCliPath ? ` Found nested Windows CLI at ${nestedCliPath}, but ExTester resolved ${cliPath}.` : ''; + throw new Error( + `VS Code CLI not found at ${cliPath}.${nestedHint} ` + + `Clear ${codeFolder} and rerun, or update vscode-extension-tester if the VS Code archive layout changed.` + ); + } + + let output; + try { + output = execSync(`${extest.code.getCliInitCommand()} -v`, { + encoding: 'utf8', + env: extest.code.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const nestedHint = nestedCliPath ? ` Nested Windows CLI candidate: ${nestedCliPath}.` : ''; + throw new Error( + `VS Code CLI preflight failed for ${cliPath}.${nestedHint} ` + + `Clear ${codeFolder} and rerun, or update vscode-extension-tester if the VS Code archive layout changed.\n${error.message}` + ); + } + + const actualVersion = output.split(/\r?\n/)[0]?.trim(); + if (/^\d+\.\d+\.\d+/.test(VSCODE_VERSION) && actualVersion !== VSCODE_VERSION) { + throw new Error( + `VS Code CLI preflight resolved version ${actualVersion || ''}, expected ${VSCODE_VERSION}. Clear ${codeFolder} and rerun.` + ); + } + + console.log(` ✓ VS Code CLI preflight: ${actualVersion} (${cliPath})`); +} + +async function downloadExTesterAssets() { + await withDownloadRetry(`download VS Code ${VSCODE_VERSION}`, async () => { + const downloadTester = createExTester(); + await downloadTester.downloadCode(VSCODE_VERSION); + preflightVSCodeCli(createExTester()); + }); + + await withDownloadRetry(`download ChromeDriver ${VSCODE_VERSION}`, () => createExTester().downloadChromeDriver(VSCODE_VERSION)); } /** @@ -261,8 +350,6 @@ async function parallelLimit(taskFns, limit) { } async function main() { - const { ExTester } = require('vscode-extension-tester'); - // ------------------------------------------------------------------ // D-001 pre-flight: enforce that *.fixtures.test.ts files do not write // workspace files directly. Fixture data must come from the wizard. @@ -313,22 +400,21 @@ async function main() { console.log(`dist/ source: ${distDir}`); console.log(`Extensions dir: ${extDir}`); - // Create ExTester WITHOUT coverage — we don't need --extensionDevelopmentPath - // because we're copying our extension directly into --extensions-dir - const extest = new ExTester( - undefined, // storageFolder — use default (os.tmpdir()/test-resources) - undefined, // releaseType — Stable - extDir // extensionsDir — isolated dir, passed as --extensions-dir - ); - // Step 1: Download VS Code + ChromeDriver (skips if already cached) console.log('\n=== Step 1: Download VS Code + ChromeDriver ==='); - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); - // Step 2: Install extension dependencies from the marketplace (PARALLEL) + // Create ExTester WITHOUT coverage — we don't need --extensionDevelopmentPath + // because we're copying our extension directly into --extensions-dir. + // This must happen after download/preflight so it cannot retain stale CLI + // state from a VS Code archive layout that changed during download. + const extest = createExTester(); + + // Step 2: Install extension dependencies from the marketplace. // Skip deps already present in test-extensions/. For uncached deps, - // run VS Code CLI --install-extension in parallel instead of sequentially - // to cut install time from ~60-90s to ~30-40s (limited by the largest dep). + // run VS Code CLI --install-extension sequentially. Parallel CLI installs + // race on shared extension/cache directories and can leave VS Code with + // partially extracted dependencies even when a follow-up retry succeeds. const getExtensionEntries = (extensionId) => { if (!fs.existsSync(extDir)) { return []; @@ -390,13 +476,7 @@ async function main() { // but we can run it with async exec instead of execSync. const cliBase = extest.code.getCliInitCommand(); - // Concurrency limit of 3 to avoid EPERM/ENOENT race conditions. - // Multiple CLI processes that install the same transitive dependency - // (e.g., both csharp and csdevkit pull in dotnet-runtime) corrupt - // the shared CachedExtensionVSIXs directory when run simultaneously. - // With 3 slots, smaller deps finish first and free a slot before - // the larger dotnet deps start, reducing overlap. - const CONCURRENCY = 3; + const CONCURRENCY = 1; console.log(` Installing ${depsToInstall.length} deps (concurrency=${CONCURRENCY})...`); const taskFns = depsToInstall.map((dep) => () => { @@ -885,6 +965,7 @@ async function main() { const phase1aFiles = [testFile('createWorkspace.fixtures.test.js')]; const phase2Files = [testFile('designerActions.test.js')]; + const connectionPromptFallbackFiles = [testFile('connectionPromptFallback.test.js')]; // Each new test gets its own phase (fresh VS Code session) to avoid // workspace-switch contention with the previous test's debug processes. @@ -990,6 +1071,13 @@ async function main() { settings: { validateDependencies: false, autoStartDesignTime: false }, env: { LA_E2E_SHAPE: 'rulesEngine', LA_E2E_SKIP_VALIDATION_WAIT: '1' }, }, + { + id: 'p42-connectionprompt', + testFile: connectionPromptFallbackFiles[0], + workspaceSpec: { appType: 'standard', wfType: 'Stateful' }, + settings: { validateDependencies: false, autoStartDesignTime: false }, + env: { LA_E2E_SKIP_VALIDATION_WAIT: '1' }, + }, // Phases 4.3-4.6 — runtime-touching consumer tests { @@ -1077,6 +1165,7 @@ async function main() { const e2eMode = (process.env.E2E_MODE || 'full').toLowerCase(); console.log(`\nE2E mode: ${e2eMode}`); + console.log(`VS Code version: ${VSCODE_VERSION} (${VSCODE_VERSION_SOURCE})`); // Note: shard reliability is gated by helpers in runHelpers.ts (waitForRuntimeReady, // clickRunTrigger, assertRunTriggerable) and helpers.ts (selectCreateWorkspaceCommand, // switchToWebviewFrame, openFolderInSession, waitForWorkbenchReady). All CI-dependent @@ -1264,7 +1353,7 @@ async function main() { /* ignore */ } } - const phaseTester = new ExTester(undefined, undefined, extDir); + const phaseTester = createExTester(); const code = await phaseTester.runTests(files, phaseRunOptions); console.log(` ${phaseName} exit code: ${code}`); return code; @@ -1688,19 +1777,19 @@ namespace ${namespaceName} process.exit(2); } console.log(`\nRunning single scenario (LA_E2E_SCENARIO): ${singleScenarioId}`); - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const singleExit = await runScenarioPhases([scenarioEntry]); process.exit(singleExit); } if (e2eMode === 'scenarios') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const scenariosExit = await runScenarioPhases(scenarios); process.exit(scenariosExit); } if (e2eMode === 'scenarios-pilot') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); // Pilot exactly one scenario: inlineJavascript. Decision gate per the // per-scenario re-architecture plan — if this passes where the current // createplusnewtests shard fails Phase 4.3, the new pattern is validated. @@ -1725,7 +1814,7 @@ namespace ${namespaceName} } if (e2eMode === 'codefuldebugonly') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const phase10Exit = await runCodefulDebugPhases('phase10-only'); process.exit(phase10Exit); } @@ -1733,7 +1822,7 @@ namespace ${namespaceName} if (e2eMode === 'nonlogicappstartup') { // Startup regression test: intentionally omit runtime dependency paths to // exercise extension activation in a plain, non-Logic-App folder. - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: false, autoStartDesignTime: false, includeRuntimeDependencyPaths: false }); await prepareFreshSession('nonlogicappstartup-only'); @@ -1743,7 +1832,7 @@ namespace ${namespaceName} if (e2eMode === 'designeronly') { // Ensure VS Code and ChromeDriver are downloaded - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: shouldValidateRuntimeDependencies(), autoStartDesignTime: true }); await prepareFreshSession('phase2-only'); @@ -1756,7 +1845,7 @@ namespace ${namespaceName} if (e2eMode === 'newtestsonly') { // Run only the new tests (phases 4.3–4.6) each in their own session - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: shouldValidateRuntimeDependencies(), autoStartDesignTime: true }); const wsResources = getPhase2Resources(); const exits = []; @@ -1783,7 +1872,7 @@ namespace ${namespaceName} if (e2eMode === 'conversiononly') { // Run only the workspace conversion tests (phases 4.8a–4.8d) - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); // ALL conversion tests need validateDependencies ON so the extension // fully activates and detects legacy projects / shows conversion dialog. writeTestSettings({ validateDependencies: true, autoStartDesignTime: false }); @@ -1873,7 +1962,7 @@ namespace ${namespaceName} if (e2eMode === 'conversioncreateonly') { // Run only Phase 4.8b: Open legacy project folder (no .code-workspace), // click Yes, then verify one Create click starts and completes workspace creation. - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: true, autoStartDesignTime: false }); const legacyDir = createLegacyProjectFixture('conversioncreateonly'); @@ -1885,7 +1974,7 @@ namespace ${namespaceName} } if (e2eMode === 'createonly') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); await prepareFreshSession('phase1-only'); const phase1Exit = await runPhase('Phase 4.1: createWorkspace session', phase1Files); process.exit(phase1Exit); @@ -1909,7 +1998,7 @@ namespace ${namespaceName} // ---------------------------------------------------------------------- if (e2eMode === 'independentonly') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; // Phase 4.0: nonLogicAppStartup — plain folder, no Logic App context. @@ -1932,7 +2021,7 @@ namespace ${namespaceName} } if (e2eMode === 'createplusdesigner') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; // Phase 4.1: createWorkspace — needed to produce the manifest consumed @@ -1949,6 +2038,13 @@ namespace ${namespaceName} const phase2Resources = getPhase2Resources(); exits.push(await runPhase('Phase 4.2: designerActions', phase2Files, { resources: phase2Resources })); + // Phase 4.2b: connector prompt cancellation fallback — deliberately + // removes Azure connector sentinel settings and verifies designer load. + writeTestSettings({ validateDependencies: false, autoStartDesignTime: false }); + await new Promise((r) => setTimeout(r, 3000)); + await prepareFreshSession('phase2b-shard'); + exits.push(await runPhase('Phase 4.2b: connectionPromptFallback', connectionPromptFallbackFiles, { resources: phase2Resources })); + // Phase 4.7: demo/smoke/standalone/dataMapper. dataMapper depends on // Phase 4.1's manifest; the others are quick (~14s total for the three). // Co-locating with `designer` keeps them all on a Phase-4.1-aware runner @@ -1959,12 +2055,14 @@ namespace ${namespaceName} exits.push(await runPhase('Phase 4.7: remaining suites', phase7Files)); const finalExit = Math.max(...exits); - console.log(`\n=== Designer shard results: 4.1=${exits[0]}, 4.2=${exits[1]}, 4.7=${exits[2]} → exit ${finalExit} ===`); + console.log( + `\n=== Designer shard results: 4.1=${exits[0]}, 4.2=${exits[1]}, 4.2b=${exits[2]}, 4.7=${exits[3]} → exit ${finalExit} ===` + ); process.exit(finalExit); } if (e2eMode === 'createplusnewtests') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; writeTestSettings({ validateDependencies: true, autoStartDesignTime: true }); @@ -1998,7 +2096,7 @@ namespace ${namespaceName} } if (e2eMode === 'createplusconversion') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; writeTestSettings({ validateDependencies: true, autoStartDesignTime: true }); diff --git a/apps/vs-code-designer/src/test/ui/runHelpers.ts b/apps/vs-code-designer/src/test/ui/runHelpers.ts index 3ee306e4b54..c830bb0a1c2 100644 --- a/apps/vs-code-designer/src/test/ui/runHelpers.ts +++ b/apps/vs-code-designer/src/test/ui/runHelpers.ts @@ -404,7 +404,7 @@ export async function startDebugging(workbench: Workbench, driver: WebDriver): P */ export async function waitForRuntimeReady( driver: WebDriver, - opts: { requireHostRunning?: boolean; timeoutMs?: number } = {} + opts: { requireHostRunning?: boolean; timeoutMs?: number; workspacePaths?: string[] } = {} ): Promise { const timeoutMs = opts.timeoutMs ?? 300_000; const requireHostRunning = opts.requireHostRunning ?? false; @@ -676,28 +676,36 @@ export async function waitForRuntimeReady( /* ignore - diagnostic only */ } - // Dump E — launch.json from the test workspace (best-effort env probe) + // Dump E — workspace config from the active test workspace (best-effort) try { const candidates = [ + ...(opts.workspacePaths ?? []), process.env.LA_E2E_LEGACY_PROJECT_DIR, process.env.LA_E2E_CODEFUL_MODERN_DIR, process.env.LA_E2E_CODEFUL_LEGACY_DIR, - ].filter((p): p is string => typeof p === 'string' && p.length > 0); + ].filter((p, index, arr): p is string => typeof p === 'string' && p.length > 0 && arr.indexOf(p) === index); let logged = false; for (const wsDir of candidates) { - const launchPath = path.join(wsDir, '.vscode', 'launch.json'); - if (fs.existsSync(launchPath)) { - const content = fs.readFileSync(launchPath, 'utf8').slice(0, 2000); - console.log(`[waitForRuntimeReady][diag] launch.json (${launchPath}): ${content}`); + console.log(`[waitForRuntimeReady][diag] workspace candidate: ${wsDir}`); + for (const relativePath of ['.vscode/launch.json', '.vscode/tasks.json', 'host.json', 'local.settings.json']) { + const configPath = path.join(wsDir, relativePath); + if (!fs.existsSync(configPath)) { + console.log(`[waitForRuntimeReady][diag] ${relativePath} (${configPath}): (missing)`); + continue; + } + let content = fs.readFileSync(configPath, 'utf8').slice(0, 4000); + if (relativePath === 'local.settings.json') { + content = content.replace(/"([^"]*(?:KEY|TOKEN|SECRET|PASSWORD|CONNECTION|STRING)[^"]*)"\s*:\s*"[^"]*"/gi, '"$1":""'); + } + console.log(`[waitForRuntimeReady][diag] ${relativePath} (${configPath}): ${content}`); logged = true; - break; } } if (!logged) { - console.log('[waitForRuntimeReady][diag] launch.json: not found (no workspace path in scope)'); + console.log('[waitForRuntimeReady][diag] workspace config: not found (no workspace path in scope)'); } } catch (e: any) { - console.log(`[waitForRuntimeReady][diag] launch.json read failed: ${e?.message ?? e}`); + console.log(`[waitForRuntimeReady][diag] workspace config read failed: ${e?.message ?? e}`); } return false; diff --git a/apps/vs-code-designer/test-setup.ts b/apps/vs-code-designer/test-setup.ts index daf25f6d702..84792c82632 100644 --- a/apps/vs-code-designer/test-setup.ts +++ b/apps/vs-code-designer/test-setup.ts @@ -76,6 +76,12 @@ vi.mock('fs', () => ({ mkdirSync: vi.fn(), chmodSync: vi.fn(), createWriteStream: vi.fn(), + readdirSync: vi.fn(() => []), + renameSync: vi.fn(), + rmSync: vi.fn(), + statSync: vi.fn(() => ({ + isDirectory: vi.fn(() => false), + })), dirent: vi.fn().mockImplementation(() => ({ isDirectory: vi.fn().mockImplementation(() => { return true;