diff --git a/.github/instructions/vs-code-designer.instructions.md b/.github/instructions/vs-code-designer.instructions.md index c9100d3d431..7d8862dec57 100644 --- a/.github/instructions/vs-code-designer.instructions.md +++ b/.github/instructions/vs-code-designer.instructions.md @@ -143,6 +143,7 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten | 4.5 | designerViewExtended.test.ts | Parallel branches + run-after (ADO #10109401) | | 4.6 | keyboardNavigation.test.ts | Ctrl+Up/Down navigation (ADO #10273324) | | 4.7 | dataMapper.test.ts, demo, smoke, standalone | Data Mapper + generic tests | +| 4.9 | azuriteAutostartFailure.test.ts, azuriteAutostartFailureAssert.test.ts | Azurite auto-start debug regression | ### Shared Helper Modules @@ -179,6 +180,8 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten 12. **Always run tests automatically after creating or modifying them**: After writing or editing any test file, immediately: lint (`npx biome check --write`), build (`npx tsup`), and run (`node src/test/ui/run-e2e.js`) — don't wait for the user to ask. Report pass/fail results with any failure details. +13. **Debug regression tests must use the real workspace launch flow**: Create the workspace through the Create Workspace webview, reopen the generated `.code-workspace` in a fresh `run-e2e.js` phase, wait for `workflow-designtime/`, and validate terminal/output/log evidence. Do not replace the suite path with one-off scripts, hand-made workspaces, or the wrong launch configuration. + ### Running Tests ```bash @@ -186,10 +189,11 @@ cd apps/vs-code-designer npx tsup --config tsup.e2e.test.config.ts # Compile # Run modes: -$env:E2E_MODE = "full" # All phases (4.1-4.7) +$env:E2E_MODE = "full" # All phases (4.1-4.9) $env:E2E_MODE = "createonly" # Phase 4.1 only $env:E2E_MODE = "designeronly" # Phase 4.2 only $env:E2E_MODE = "newtestsonly" # Phases 4.3-4.6 only +$env:E2E_MODE = "azuriteonly" # Phase 4.9 Azurite auto-start debug regression node src/test/ui/run-e2e.js ``` diff --git a/apps/vs-code-designer/CLAUDE.md b/apps/vs-code-designer/CLAUDE.md index c9100d3d431..7d8862dec57 100644 --- a/apps/vs-code-designer/CLAUDE.md +++ b/apps/vs-code-designer/CLAUDE.md @@ -143,6 +143,7 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten | 4.5 | designerViewExtended.test.ts | Parallel branches + run-after (ADO #10109401) | | 4.6 | keyboardNavigation.test.ts | Ctrl+Up/Down navigation (ADO #10273324) | | 4.7 | dataMapper.test.ts, demo, smoke, standalone | Data Mapper + generic tests | +| 4.9 | azuriteAutostartFailure.test.ts, azuriteAutostartFailureAssert.test.ts | Azurite auto-start debug regression | ### Shared Helper Modules @@ -179,6 +180,8 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten 12. **Always run tests automatically after creating or modifying them**: After writing or editing any test file, immediately: lint (`npx biome check --write`), build (`npx tsup`), and run (`node src/test/ui/run-e2e.js`) — don't wait for the user to ask. Report pass/fail results with any failure details. +13. **Debug regression tests must use the real workspace launch flow**: Create the workspace through the Create Workspace webview, reopen the generated `.code-workspace` in a fresh `run-e2e.js` phase, wait for `workflow-designtime/`, and validate terminal/output/log evidence. Do not replace the suite path with one-off scripts, hand-made workspaces, or the wrong launch configuration. + ### Running Tests ```bash @@ -186,10 +189,11 @@ cd apps/vs-code-designer npx tsup --config tsup.e2e.test.config.ts # Compile # Run modes: -$env:E2E_MODE = "full" # All phases (4.1-4.7) +$env:E2E_MODE = "full" # All phases (4.1-4.9) $env:E2E_MODE = "createonly" # Phase 4.1 only $env:E2E_MODE = "designeronly" # Phase 4.2 only $env:E2E_MODE = "newtestsonly" # Phases 4.3-4.6 only +$env:E2E_MODE = "azuriteonly" # Phase 4.9 Azurite auto-start debug regression node src/test/ui/run-e2e.js ``` diff --git a/apps/vs-code-designer/src/app/azuriteExtension/__test__/executeOnAzuriteExt.test.ts b/apps/vs-code-designer/src/app/azuriteExtension/__test__/executeOnAzuriteExt.test.ts new file mode 100644 index 00000000000..fc059ce9d2f --- /dev/null +++ b/apps/vs-code-designer/src/app/azuriteExtension/__test__/executeOnAzuriteExt.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { azuriteExtensionId, extensionCommand } from '../../../constants'; +import { executeOnAzurite } from '../executeOnAzuriteExt'; + +const vscodeMocks = vi.hoisted(() => ({ + executeCommand: vi.fn(), + getExtension: vi.fn(), +})); + +vi.mock('vscode', () => ({ + commands: { + executeCommand: vscodeMocks.executeCommand, + }, + extensions: { + getExtension: vscodeMocks.getExtension, + }, +})); + +describe('executeOnAzurite', () => { + const context = { + telemetry: { + properties: {}, + measurements: {}, + }, + ui: { + showWarningMessage: vi.fn(), + }, + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + context.telemetry.properties = {}; + }); + + it('throws a startup error when the Azurite extension is unavailable', async () => { + vscodeMocks.getExtension.mockReturnValue(undefined); + + await expect(executeOnAzurite(context, extensionCommand.azureAzuriteStart)).rejects.toThrow( + 'Azurite extension is not installed or is unavailable in the current VS Code extension host.' + ); + + expect(vscodeMocks.getExtension).toHaveBeenCalledWith(azuriteExtensionId); + expect(vscodeMocks.executeCommand).not.toHaveBeenCalled(); + expect(context.ui.showWarningMessage).not.toHaveBeenCalled(); + expect(context.telemetry.properties.azuriteExtensionAvailable).toBe('false'); + }); + + it('activates the Azurite extension before issuing the start command', async () => { + const activate = vi.fn(async () => undefined); + vscodeMocks.getExtension.mockReturnValue({ + isActive: false, + activate, + }); + + await executeOnAzurite(context, extensionCommand.azureAzuriteStart); + + expect(activate).toHaveBeenCalledTimes(1); + expect(vscodeMocks.executeCommand).toHaveBeenCalledWith(extensionCommand.azureAzuriteStart, {}); + expect(context.telemetry.properties.azuriteExtensionAvailable).toBe('true'); + expect(context.telemetry.properties.azuriteExtensionActive).toBe('true'); + expect(context.telemetry.properties.azuriteStartCommandIssued).toBe('true'); + }); + + it('throws a startup error when the Azurite extension fails activation', async () => { + vscodeMocks.getExtension.mockReturnValue({ + isActive: false, + activate: vi.fn(async () => { + throw new Error('activation failed'); + }), + }); + + await expect(executeOnAzurite(context, extensionCommand.azureAzuriteStart)).rejects.toThrow( + 'Azurite extension could not be activated.' + ); + + expect(vscodeMocks.executeCommand).not.toHaveBeenCalled(); + expect(context.telemetry.properties.azuriteExtensionAvailable).toBe('true'); + expect(context.telemetry.properties.azuriteExtensionActive).toBe('false'); + }); +}); diff --git a/apps/vs-code-designer/src/app/azuriteExtension/executeOnAzuriteExt.ts b/apps/vs-code-designer/src/app/azuriteExtension/executeOnAzuriteExt.ts index 2ac0994e2fe..155d2f569ee 100644 --- a/apps/vs-code-designer/src/app/azuriteExtension/executeOnAzuriteExt.ts +++ b/apps/vs-code-designer/src/app/azuriteExtension/executeOnAzuriteExt.ts @@ -11,12 +11,35 @@ import * as vscode from 'vscode'; export async function executeOnAzurite(context: IActionContext, command: string, ...args: any[]): Promise { const azuriteExtension = extensions.getExtension(azuriteExtensionId); - if (azuriteExtension?.isActive) { - vscode.commands.executeCommand(command, { - ...args, - }); - } else { - const message: string = localize('deactivatedAzuriteExt', 'Azurite extension is deactivated, make sure to activate it'); - await context.ui.showWarningMessage(message); + if (!azuriteExtension) { + context.telemetry.properties.azuriteExtensionAvailable = 'false'; + throw new Error( + localize( + 'missingAzuriteExt', + 'Azurite extension is not installed or is unavailable in the current VS Code extension host. Make sure the Azurite extension is installed and enabled, then try debugging again.' + ) + ); } + + context.telemetry.properties.azuriteExtensionAvailable = 'true'; + if (!azuriteExtension.isActive) { + context.telemetry.properties.azuriteExtensionActive = 'false'; + try { + await azuriteExtension.activate(); + } catch (error) { + throw new Error( + localize( + 'activateAzuriteExtFailed', + 'Azurite extension could not be activated. Make sure the Azurite extension is installed and enabled, then try debugging again. {0}', + error instanceof Error ? error.message : String(error) + ) + ); + } + } + + context.telemetry.properties.azuriteExtensionActive = 'true'; + context.telemetry.properties.azuriteStartCommandIssued = 'true'; + await vscode.commands.executeCommand(command, { + ...args, + }); } diff --git a/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.azurite.test.ts b/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.azurite.test.ts new file mode 100644 index 00000000000..2264aa67333 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/__test__/pickFuncProcess.azurite.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { preDebugValidate } from '../../debug/validatePreDebug'; +import { verifyLocalConnectionKeys } from '../../utils/appSettings/connectionKeys'; +import { activateAzurite } from '../../utils/azurite/activateAzurite'; +import { getProjFiles } from '../../utils/dotnet/dotnet'; +import { tryBuildCustomCodeFunctionsProject } from '../buildCustomCodeFunctionsProject'; +import { pickFuncProcessInternal } from '../pickFuncProcess'; + +const capturedMessages: string[] = []; +const telemetryContexts: any[] = []; +const azuriteTimeoutMessage = + 'Azurite did not become ready within "5" seconds. Make sure the Azurite extension is installed and running, then try debugging again.'; + +vi.mock('@microsoft/vscode-azext-utils', () => { + return { + callWithTelemetryAndErrorHandling: vi.fn(async (_callbackId: string, callback: (context: any) => Promise) => { + const context = { + telemetry: { + properties: {}, + measurements: {}, + }, + errorHandling: {}, + ui: { + showWarningMessage: vi.fn(async (message: string) => { + capturedMessages.push(message); + return undefined; + }), + }, + }; + telemetryContexts.push(context); + try { + return await callback(context); + } catch (error) { + if (!context.errorHandling.suppressDisplay) { + capturedMessages.push(error instanceof Error ? error.message : String(error)); + } + if (context.errorHandling.rethrow) { + throw error; + } + return undefined; + } + }), + UserCancelledError: class UserCancelledError extends Error {}, + }; +}); + +vi.mock('../../debug/validatePreDebug', () => ({ + preDebugValidate: vi.fn(async () => { + capturedMessages.push( + 'Failed to verify "AzureWebJobsStorage" connection specified in "local.settings.json". Is the local emulator installed and running?' + ); + return false; + }), +})); + +vi.mock('../../utils/azurite/activateAzurite', () => ({ + activateAzurite: vi.fn(), +})); + +vi.mock('../../utils/appSettings/connectionKeys', () => ({ + verifyLocalConnectionKeys: vi.fn(), +})); + +vi.mock('../../utils/dotnet/dotnet', () => ({ + getProjFiles: vi.fn(), +})); + +vi.mock('../buildCustomCodeFunctionsProject', () => ({ + tryBuildCustomCodeFunctionsProject: vi.fn(), +})); + +describe('pickFuncProcess Azurite startup', () => { + const projectPath = 'D:\\workspace\\LogicApp'; + const workspaceFolder = { uri: vscode.Uri.file(projectPath), name: 'LogicApp', index: 0 }; + const debugConfig = { type: 'workflow', request: 'attach', name: 'Attach to Logic App' }; + const context = { + telemetry: { + properties: {}, + measurements: {}, + }, + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + capturedMessages.length = 0; + telemetryContexts.length = 0; + vi.mocked(activateAzurite).mockRejectedValue(new Error(azuriteTimeoutMessage)); + vi.mocked(verifyLocalConnectionKeys).mockResolvedValue(undefined); + vi.mocked(getProjFiles).mockResolvedValue([]); + vi.mocked(tryBuildCustomCodeFunctionsProject).mockResolvedValue(undefined); + }); + + it('stops debug startup after Azurite auto-start fails without showing AzureWebJobsStorage warning', async () => { + await expect(pickFuncProcessInternal(context, debugConfig, workspaceFolder, projectPath)).rejects.toThrow(azuriteTimeoutMessage); + + expect(capturedMessages).not.toContain(azuriteTimeoutMessage); + expect(activateAzurite).toHaveBeenCalledWith(telemetryContexts[0], projectPath); + expect(capturedMessages).not.toContain( + 'Failed to verify "AzureWebJobsStorage" connection specified in "local.settings.json". Is the local emulator installed and running?' + ); + expect(verifyLocalConnectionKeys).not.toHaveBeenCalled(); + expect(preDebugValidate).not.toHaveBeenCalled(); + expect(tryBuildCustomCodeFunctionsProject).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts b/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts index 2feda44f5b3..98bb5d17b4e 100644 --- a/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts +++ b/apps/vs-code-designer/src/app/commands/pickFuncProcess.ts @@ -71,8 +71,14 @@ export async function pickFuncProcessInternal( projectPath: string ): Promise { await callWithTelemetryAndErrorHandling(autoStartAzuriteSetting, async (actionContext: IActionContext) => { + actionContext.errorHandling.rethrow = true; await runWithDurationTelemetry(actionContext, autoStartAzuriteSetting, async () => { - await activateAzurite(context, projectPath); + try { + await activateAzurite(actionContext, projectPath); + } catch (error) { + actionContext.errorHandling.suppressDisplay = true; + throw error instanceof Error ? error : new Error(String(error)); + } }); }); diff --git a/apps/vs-code-designer/src/app/debug/__test__/validatePreDebug.test.ts b/apps/vs-code-designer/src/app/debug/__test__/validatePreDebug.test.ts new file mode 100644 index 00000000000..e6056727cae --- /dev/null +++ b/apps/vs-code-designer/src/app/debug/__test__/validatePreDebug.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as azureStorage from 'azure-storage'; +import { autoStartAzuriteSetting, localEmulatorConnectionString } from '../../../constants'; +import { validateFuncCoreToolsInstalled } from '../../commands/funcCoreTools/validateFuncCoreToolsInstalled'; +import { getAzureWebJobsStorage } from '../../utils/appSettings/localSettings'; +import { getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; +import { preDebugValidate, validateEmulatorIsRunning } from '../validatePreDebug'; + +vi.mock('azure-storage', () => ({ + createBlobService: vi.fn(() => ({ + doesContainerExist: (_container: string, callback: (err?: Error) => void) => callback(new Error('connection refused')), + })), +})); + +vi.mock('../../commands/funcCoreTools/validateFuncCoreToolsInstalled', () => ({ + validateFuncCoreToolsInstalled: vi.fn(), +})); + +vi.mock('../../utils/appSettings/localSettings', () => ({ + getAzureWebJobsStorage: vi.fn(), + setLocalAppSetting: vi.fn(), +})); + +vi.mock('../../utils/vsCodeConfig/settings', () => ({ + getFunctionsWorkerRuntime: vi.fn(), + getWorkspaceSetting: vi.fn(), +})); + +describe('validatePreDebug', () => { + const projectPath = 'D:\\workspace\\LogicApp'; + const context = { + telemetry: { + properties: {}, + measurements: {}, + }, + ui: { + showWarningMessage: vi.fn(), + }, + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + context.telemetry.properties = {}; + vi.mocked(getAzureWebJobsStorage).mockResolvedValue(localEmulatorConnectionString); + }); + + it('does not offer Debug anyway when auto-started Azurite cannot be reached', async () => { + vi.mocked(validateFuncCoreToolsInstalled).mockResolvedValue(true); + vi.mocked(getWorkspaceSetting).mockImplementation((key: string) => { + return key === autoStartAzuriteSetting ? true : undefined; + }); + + const result = await preDebugValidate(context, projectPath); + + expect(result).toBe(false); + expect(context.ui.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('Failed to verify "AzureWebJobsStorage"'), + expect.objectContaining({ modal: true }) + ); + }); + + it('blocks debug when AzureWebJobsStorage is missing', async () => { + vi.mocked(validateFuncCoreToolsInstalled).mockResolvedValue(true); + vi.mocked(getAzureWebJobsStorage).mockResolvedValue(undefined); + + const result = await preDebugValidate(context, projectPath); + + expect(result).toBe(false); + expect(context.ui.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('Missing required "AzureWebJobsStorage"'), + expect.objectContaining({ modal: true }) + ); + }); + + it('keeps Debug anyway available when explicitly allowed', async () => { + context.ui.showWarningMessage.mockImplementation(async (_message: string, _options: unknown, debugAnyway: unknown) => debugAnyway); + + const result = await validateEmulatorIsRunning(context, projectPath, { allowDebugAnyway: true }); + + expect(result).toBe(true); + expect(context.ui.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('Failed to verify "AzureWebJobsStorage"'), + expect.objectContaining({ modal: true }), + expect.objectContaining({ title: 'Debug anyway' }) + ); + }); + + it('keeps Debug anyway available in pre-debug validation when Azurite auto-start is disabled', async () => { + vi.mocked(validateFuncCoreToolsInstalled).mockResolvedValue(true); + vi.mocked(getWorkspaceSetting).mockImplementation((key: string) => { + return key === autoStartAzuriteSetting ? false : undefined; + }); + context.ui.showWarningMessage.mockImplementation(async (_message: string, _options: unknown, debugAnyway: unknown) => debugAnyway); + + const result = await preDebugValidate(context, projectPath); + + expect(result).toBe(true); + expect(context.ui.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('Failed to verify "AzureWebJobsStorage"'), + expect.objectContaining({ modal: true }), + expect.objectContaining({ title: 'Debug anyway' }) + ); + }); + + it('probes the local emulator when AzureWebJobsStorage uses development storage', async () => { + await validateEmulatorIsRunning(context, projectPath, false); + + expect(azureStorage.createBlobService).toHaveBeenCalledWith(localEmulatorConnectionString); + }); +}); diff --git a/apps/vs-code-designer/src/app/debug/validatePreDebug.ts b/apps/vs-code-designer/src/app/debug/validatePreDebug.ts index 8ab6ad36e50..e018103a63c 100644 --- a/apps/vs-code-designer/src/app/debug/validatePreDebug.ts +++ b/apps/vs-code-designer/src/app/debug/validatePreDebug.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { + autoStartAzuriteSetting, projectLanguageSetting, workerRuntimeKey, localEmulatorConnectionString, @@ -20,6 +21,11 @@ import { MismatchBehavior, Platform } from '@microsoft/vscode-extension-logic-ap import * as azureStorage from 'azure-storage'; import * as vscode from 'vscode'; +export interface ValidateEmulatorOptions { + promptWarningMessage?: boolean; + allowDebugAnyway?: boolean; +} + /** * Validates functions core tools is installed and azure emulator is running * @param {IActionContext} context - Command context. @@ -45,7 +51,15 @@ export async function preDebugValidate(context: IActionContext, projectPath: str await validateWorkerRuntime(context, projectLanguage, projectPath); context.telemetry.properties.lastValidateStep = 'emulatorRunning'; - shouldContinue = await validateEmulatorIsRunning(context, projectPath); + const azureWebJobsStorage: string | undefined = await getAzureWebJobsStorage(context, projectPath); + if (azureWebJobsStorage?.trim()) { + const autoStartAzurite = !!getWorkspaceSetting(autoStartAzuriteSetting); + shouldContinue = await validateEmulatorIsRunning(context, projectPath, { + allowDebugAnyway: !autoStartAzurite, + }); + } else { + shouldContinue = await showMissingAzureWebJobsStorageWarning(context); + } } } catch (error) { if (parseError(error).isUserCancelledError) { @@ -104,18 +118,31 @@ async function validateWorkerRuntime(context: IActionContext, projectLanguage: s } } +async function showMissingAzureWebJobsStorageWarning(context: IActionContext): Promise { + const message: string = localize( + 'missingAzureWebJobsStorage', + 'Missing required "{0}" connection in "{1}". Add a storage connection string before debugging this project.', + azureWebJobsStorageKey, + localSettingsFileName + ); + await context.ui.showWarningMessage(message, { modal: true }); + return false; +} + /** * If AzureWebJobsStorage is set, pings the emulator to make sure it's actually running * @param {IActionContext} context - Command context. * @param {string} projectPath - Project path. - * @param {boolean} promptWarningMessage - Boolean to determine whether prompt a message to ask user if emulator is running. + * @param {boolean | ValidateEmulatorOptions} options - Options for prompting and allowing debug continuation. * @returns {boolean} Returns true if a valid emulator is running, otherwise returns false. */ export async function validateEmulatorIsRunning( context: IActionContext, projectPath: string, - promptWarningMessage = true + options: boolean | ValidateEmulatorOptions = true ): Promise { + const promptWarningMessage = typeof options === 'boolean' ? options : (options.promptWarningMessage ?? true); + const allowDebugAnyway = typeof options === 'boolean' ? true : (options.allowDebugAnyway ?? true); const azureWebJobsStorage: string | undefined = await getAzureWebJobsStorage(context, projectPath); if (azureWebJobsStorage && azureWebJobsStorage.toLowerCase() === localEmulatorConnectionString.toLowerCase()) { @@ -143,6 +170,11 @@ export async function validateEmulatorIsRunning( ); const learnMoreLink: string = process.platform === Platform.windows ? 'https://aka.ms/AA4ym56' : 'https://aka.ms/AA4yef8'; + if (!allowDebugAnyway) { + await context.ui.showWarningMessage(message, { learnMoreLink, modal: true }); + return false; + } + const debugAnyway: vscode.MessageItem = { title: localize('debugAnyway', 'Debug anyway') }; const result: vscode.MessageItem = await context.ui.showWarningMessage(message, { learnMoreLink, modal: true }, debugAnyway); return result === debugAnyway; diff --git a/apps/vs-code-designer/src/app/utils/__test__/startRuntimeApi.azurite.test.ts b/apps/vs-code-designer/src/app/utils/__test__/startRuntimeApi.azurite.test.ts new file mode 100644 index 00000000000..e025b978059 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/__test__/startRuntimeApi.azurite.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { preDebugValidate } from '../../debug/validatePreDebug'; +import { verifyLocalConnectionKeys } from '../appSettings/connectionKeys'; +import { activateAzurite } from '../azurite/activateAzurite'; +import { startRuntimeApi } from '../startRuntimeApi'; + +const capturedMessages: string[] = []; +const telemetryContexts: any[] = []; +const azuriteTimeoutMessage = + 'Azurite did not become ready within "5" seconds. Make sure the Azurite extension is installed and running, then try debugging again.'; + +vi.mock('@microsoft/vscode-azext-utils', () => { + return { + callWithTelemetryAndErrorHandling: vi.fn(async (_callbackId: string, callback: (context: any) => Promise) => { + const context = { + telemetry: { + properties: {}, + measurements: {}, + }, + errorHandling: {}, + ui: { + showWarningMessage: vi.fn(async (message: string) => { + capturedMessages.push(message); + return undefined; + }), + }, + }; + telemetryContexts.push(context); + try { + return await callback(context); + } catch (error) { + if (!context.errorHandling.suppressDisplay) { + capturedMessages.push(error instanceof Error ? error.message : String(error)); + } + if (context.errorHandling.rethrow) { + throw error; + } + return undefined; + } + }), + UserCancelledError: class UserCancelledError extends Error {}, + }; +}); + +vi.mock('../../debug/validatePreDebug', () => ({ + preDebugValidate: vi.fn(async () => { + capturedMessages.push( + 'Failed to verify "AzureWebJobsStorage" connection specified in "local.settings.json". Is the local emulator installed and running?' + ); + return false; + }), +})); + +vi.mock('../azurite/activateAzurite', () => ({ + activateAzurite: vi.fn(), +})); + +vi.mock('../appSettings/connectionKeys', () => ({ + verifyLocalConnectionKeys: vi.fn(), +})); + +describe('startRuntimeApi Azurite startup', () => { + const projectPath = 'D:\\workspace\\LogicApp'; + + beforeEach(() => { + vi.clearAllMocks(); + capturedMessages.length = 0; + telemetryContexts.length = 0; + vi.mocked(activateAzurite).mockRejectedValue(new Error(azuriteTimeoutMessage)); + vi.mocked(verifyLocalConnectionKeys).mockResolvedValue(undefined); + }); + + it('stops runtime startup after Azurite auto-start fails without showing AzureWebJobsStorage warning', async () => { + await startRuntimeApi(projectPath); + + expect(capturedMessages).toContain(azuriteTimeoutMessage); + expect(activateAzurite).toHaveBeenCalledWith(telemetryContexts[1], projectPath); + expect(capturedMessages).not.toContain( + 'Failed to verify "AzureWebJobsStorage" connection specified in "local.settings.json". Is the local emulator installed and running?' + ); + expect(verifyLocalConnectionKeys).not.toHaveBeenCalled(); + expect(preDebugValidate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/azurite/__test__/activateAzurite.test.ts b/apps/vs-code-designer/src/app/utils/azurite/__test__/activateAzurite.test.ts new file mode 100644 index 00000000000..da3417cf0a5 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/azurite/__test__/activateAzurite.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { + autoStartAzuriteSetting, + azuriteBinariesLocationSetting, + azuriteExtensionPrefix, + azuriteLocationSetting, + defaultAzuritePathValue, + showAutoStartAzuriteWarning, +} from '../../../../constants'; +import { executeOnAzurite } from '../../../azuriteExtension/executeOnAzuriteExt'; +import { validateEmulatorIsRunning } from '../../../debug/validatePreDebug'; +import { delay } from '../../delay'; +import { updateWorkspaceSetting } from '../../vsCodeConfig/settings'; +import { activateAzurite } from '../activateAzurite'; + +vi.mock('../../../azuriteExtension/executeOnAzuriteExt', () => ({ + executeOnAzurite: vi.fn(), +})); + +vi.mock('../../../debug/validatePreDebug', () => ({ + validateEmulatorIsRunning: vi.fn(), +})); + +vi.mock('../../delay', () => ({ + delay: vi.fn(), +})); + +vi.mock('../../verifyIsProject', () => ({ + tryGetLogicAppProjectRoot: vi.fn(), +})); + +vi.mock('../../workspace', () => ({ + getWorkspaceFolder: vi.fn(), +})); + +vi.mock('../../vsCodeConfig/settings', () => ({ + getWorkspaceSetting: vi.fn((key: string, _projectPath?: string, prefix?: string) => { + if (key === azuriteLocationSetting && prefix === azuriteExtensionPrefix) { + return undefined; + } + + if (key === azuriteBinariesLocationSetting) { + return defaultAzuritePathValue; + } + + if (key === showAutoStartAzuriteWarning) { + return false; + } + + if (key === autoStartAzuriteSetting) { + return true; + } + + return undefined; + }), + updateGlobalSetting: vi.fn(), + updateWorkspaceSetting: vi.fn(), +})); + +describe('activateAzurite', () => { + const projectPath = 'D:\\workspace\\LogicApp'; + const azuriteTimeoutMessage = + 'Azurite did not become ready within "5" seconds. Make sure the Azurite extension is installed and running, then try debugging again.'; + const context = { + telemetry: { + properties: {}, + measurements: {}, + }, + ui: { + showWarningMessage: vi.fn(), + showInputBox: vi.fn(), + }, + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + (vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: projectPath } }]; + context.telemetry.properties = {}; + }); + + it('waits for Azurite to become ready after starting it', async () => { + vi.mocked(validateEmulatorIsRunning).mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + await activateAzurite(context, projectPath); + + expect(updateWorkspaceSetting).toHaveBeenCalledWith( + azuriteLocationSetting, + defaultAzuritePathValue, + projectPath, + azuriteExtensionPrefix + ); + expect(executeOnAzurite).toHaveBeenCalledTimes(1); + expect(validateEmulatorIsRunning).toHaveBeenNthCalledWith(1, context, projectPath, false); + expect(validateEmulatorIsRunning).toHaveBeenNthCalledWith(2, context, projectPath, false); + expect(validateEmulatorIsRunning).toHaveBeenNthCalledWith(3, context, projectPath, false); + expect(context.telemetry.properties.azuriteReady).toBe('true'); + }); + + it('throws a startup error after Azurite does not become ready', async () => { + vi.mocked(validateEmulatorIsRunning).mockResolvedValue(false); + + await expect(activateAzurite(context, projectPath)).rejects.toThrow(azuriteTimeoutMessage); + + expect(executeOnAzurite).toHaveBeenCalledTimes(1); + expect(validateEmulatorIsRunning).toHaveBeenCalledTimes(11); + expect(delay).toHaveBeenCalledTimes(9); + expect(context.telemetry.properties.azuriteStartupAttempt).toBe('10'); + expect(context.telemetry.properties.azuriteReady).toBe('false'); + }); + + it('propagates Azurite extension startup errors without waiting for readiness', async () => { + const extensionError = new Error('Azurite extension is not installed or is unavailable in the current VS Code extension host.'); + vi.mocked(validateEmulatorIsRunning).mockResolvedValue(false); + vi.mocked(executeOnAzurite).mockRejectedValue(extensionError); + + await expect(activateAzurite(context, projectPath)).rejects.toThrow(extensionError.message); + + expect(validateEmulatorIsRunning).toHaveBeenCalledTimes(1); + expect(delay).not.toHaveBeenCalled(); + expect(context.telemetry.properties.azuriteReady).toBeUndefined(); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts b/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts index 6d82355dd78..2b280c46dc1 100644 --- a/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts +++ b/apps/vs-code-designer/src/app/utils/azurite/activateAzurite.ts @@ -14,6 +14,7 @@ import { import { localize } from '../../../localize'; import { executeOnAzurite } from '../../azuriteExtension/executeOnAzuriteExt'; import { validateEmulatorIsRunning } from '../../debug/validatePreDebug'; +import { delay } from '../delay'; import { tryGetLogicAppProjectRoot } from '../verifyIsProject'; import { getWorkspaceSetting, updateGlobalSetting, updateWorkspaceSetting } from '../vsCodeConfig/settings'; import { getWorkspaceFolder } from '../workspace'; @@ -21,6 +22,9 @@ import { DialogResponses, type IActionContext } from '@microsoft/vscode-azext-ut import * as vscode from 'vscode'; import type { MessageItem } from 'vscode'; +const azuriteStartupRetryCount = 10; +const azuriteStartupRetryDelayMs = 500; + /** * Prompts user to set azurite.location and Start Azurite. * If azurite extension location was not set: @@ -42,7 +46,7 @@ export async function activateAzurite(context: IActionContext, projectPath?: str const showAutoStartAzuriteWarningSetting = !!getWorkspaceSetting(showAutoStartAzuriteWarning); - const autoStartAzurite = !!getWorkspaceSetting(autoStartAzuriteSetting); + let autoStartAzurite = !!getWorkspaceSetting(autoStartAzuriteSetting); context.telemetry.properties.autoStartAzurite = `${autoStartAzurite}`; if (showAutoStartAzuriteWarningSetting) { @@ -60,6 +64,8 @@ export async function activateAzurite(context: IActionContext, projectPath?: str } else if (result === enableMessage) { await updateGlobalSetting(showAutoStartAzuriteWarning, false); await updateGlobalSetting(autoStartAzuriteSetting, true); + autoStartAzurite = true; + context.telemetry.properties.autoStartAzurite = 'true'; // User has not configured workspace azurite.location. if (!azuriteLocationExtSetting) { @@ -92,7 +98,31 @@ export async function activateAzurite(context: IActionContext, projectPath?: str await executeOnAzurite(context, extensionCommand.azureAzuriteStart); context.telemetry.properties.azuriteStart = 'true'; context.telemetry.properties.azuriteLocation = azuriteLocation; + await waitForAzuriteReady(context, projectPath); } } } } + +async function waitForAzuriteReady(context: IActionContext, projectPath: string): Promise { + for (let attempt = 1; attempt <= azuriteStartupRetryCount; attempt++) { + context.telemetry.properties.azuriteStartupAttempt = attempt.toString(); + if (await validateEmulatorIsRunning(context, projectPath, false)) { + context.telemetry.properties.azuriteReady = 'true'; + return; + } + + if (attempt < azuriteStartupRetryCount) { + await delay(azuriteStartupRetryDelayMs); + } + } + + context.telemetry.properties.azuriteReady = 'false'; + throw new Error( + localize( + 'azuriteFailedToStart', + 'Azurite did not become ready within "{0}" seconds. Make sure the Azurite extension is installed and running, then try debugging again.', + (azuriteStartupRetryCount * azuriteStartupRetryDelayMs) / 1000 + ) + ); +} diff --git a/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts b/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts index c6bed8f3297..aeebc8c5413 100644 --- a/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/startRuntimeApi.ts @@ -27,8 +27,14 @@ import { Platform } from '@microsoft/vscode-extension-logic-apps'; export async function startRuntimeApi(projectPath: string): Promise { await callWithTelemetryAndErrorHandling('azureLogicAppsStandard.startRuntimeProcess', async (context: IActionContext) => { await callWithTelemetryAndErrorHandling(autoStartAzuriteSetting, async (actionContext: IActionContext) => { + actionContext.errorHandling.rethrow = true; await runWithDurationTelemetry(actionContext, autoStartAzuriteSetting, async () => { - await activateAzurite(context, projectPath); + try { + await activateAzurite(actionContext, projectPath); + } catch (error) { + actionContext.errorHandling.suppressDisplay = true; + throw error instanceof Error ? error : new Error(String(error)); + } }); }); diff --git a/apps/vs-code-designer/src/test/ui/SKILL.md b/apps/vs-code-designer/src/test/ui/SKILL.md index 47f6a030831..eebbcb6e81f 100644 --- a/apps/vs-code-designer/src/test/ui/SKILL.md +++ b/apps/vs-code-designer/src/test/ui/SKILL.md @@ -77,6 +77,7 @@ pnpm run test:ui # Runs node src/test/ui/run-e2e.js | (unset) | Runs Phase 4.0 (non-Logic-App startup), Phase 4.1 (createWorkspace), then later designer/conversion phases | | `nonlogicappstartup` | Runs only Phase 4.0 with minimal settings and no runtime dependency paths | | `designeronly` | Skips Phase 4.1, runs Phase 4.2 using workspaces from a previous Phase 4.1 run | +| `azuriteonly` | Runs the Azurite auto-start debug regression create/assert phases | **IMPORTANT**: `E2E_MODE=designeronly` requires that Phase 4.1 has been run previously in the same session and workspaces still exist on disk. If the previous run's `after()` hook cleaned up workspaces, Phase 4.2 tests will fail with "Missing workspace directories" errors. @@ -152,6 +153,22 @@ The Compose action parameter panel uses a Lexical contenteditable editor, not a - If settings cleanup encounters file locks, clear stale VS Code test processes and retry with the existing cleanup fallback (`settings/User` deletion path). - Use actions-only runs while iterating on discovery panel selectors, then promote to full Phase 4.2 once stable. +### Azurite auto-start failure regression pattern + +The Azurite auto-start regression showed that prompt suppression is not enough: the test must prove the debug path stops after Azurite readiness fails and does not continue into `AzureWebJobsStorage` validation. + +For Azurite auto-start failure coverage: +- Wire the test through `run-e2e.js` with a dedicated `E2E_MODE`. +- Use a unique temp workspace parent per run so stale Windows locks cannot poison the next run. +- Create the Logic App workspace through the Create Workspace webview instead of hand-authoring a folder. +- Patch generated `launch.json` only after creation, using `type: "logicapp"`, `request: "launch"`, `funcRuntime: "coreclr"`, and `isCodeless: true`. +- End the creation session, then reopen the generated `.code-workspace` in a fresh session. +- Wait for `workflow-designtime/`; if it is missing, design-time startup did not actually happen. +- Install a fake Azurite extension that registers `azurite.start` but does not start Azurite. +- Block ports `10000`, `10001`, and `10002` with fast HTTP responders, not bare TCP sockets. +- Assert the Azurite timeout is visible. +- Assert `Failed to verify "AzureWebJobsStorage" connection` and `Debug anyway` do not appear. +- Capture visible workbench text plus terminal/output logs when the expected timeout is not visible. ### How ExTester Loads the Extension diff --git a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts new file mode 100644 index 00000000000..1294d015197 --- /dev/null +++ b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailure.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Azurite auto-start failure workspace creation E2E. + * + * Phase 4.9 uses this file with AZURITE_E2E_STEP=create to create the real + * workspace through the Create Workspace webview. Debug assertion coverage lives + * in azuriteAutostartFailureAssert.test.ts, which reopens the generated + * workspace in a fresh VS Code session. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { By, Key, type WebDriver, type WebElement, VSBrowser, WebView, Workbench, until } from 'vscode-extension-tester'; +import { dismissNotifications, sleep } from './helpers'; + +const TEST_TIMEOUT = 240_000; +const WORKSPACE_PARENT_DIR = + process.env.AZURITE_E2E_WORKSPACE_PARENT ?? path.join(os.tmpdir(), 'la-e2e-test', 'azurite-autostart-failure-parent'); +const WORKSPACE_NAME = 'azuritews'; +const APP_NAME = 'azuriteapp'; +const WORKFLOW_NAME = 'workflow1'; +const WORKSPACE_DIR = path.join(WORKSPACE_PARENT_DIR, WORKSPACE_NAME); +const PROJECT_DIR = path.join(WORKSPACE_DIR, APP_NAME); +const WORKSPACE_FILE = path.join(WORKSPACE_DIR, `${WORKSPACE_NAME}.code-workspace`); +const E2E_STEP = (process.env.AZURITE_E2E_STEP || 'create').toLowerCase(); + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function readJson(filePath: string): any { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +function toXPathLiteral(value: string): string { + if (!value.includes("'")) { + return `'${value}'`; + } + + if (!value.includes('"')) { + return `"${value}"`; + } + + const parts = value.split("'"); + return `concat(${parts + .map((part, index) => { + const literals = []; + if (part) { + literals.push(`'${part}'`); + } + if (index < parts.length - 1) { + literals.push(`"'"`); + } + return literals.join(', '); + }) + .filter(Boolean) + .join(', ')})`; +} + +async function selectCreateWorkspaceCommand(workbench: Workbench): Promise { + await dismissNotifications(workbench.getDriver()); + let input: Awaited> | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + try { + input = await workbench.openCommandPrompt(); + await sleep(1000); + await input.setText('> logic app workspace'); + await sleep(2000); + break; + } catch (error) { + console.log(`[azurite-e2e] Create Workspace command input attempt ${attempt + 1}/3 failed: ${error}`); + try { + await input?.cancel(); + } catch { + // Ignore cleanup failure; the next attempt reopens the command palette. + } + if (attempt === 2) { + throw error; + } + await sleep(2000); + } + } + + if (!input) { + throw new Error('Could not open command prompt'); + } + + let picks = await input.getQuickPicks(); + for (const pick of picks) { + const label = await pick.getLabel(); + const lowerLabel = label.toLowerCase(); + if (lowerLabel.includes('workspace') && !lowerLabel.includes('package') && !lowerLabel.includes('from')) { + console.log(`[azurite-e2e] Selecting create workspace command: "${label}"`); + await pick.select(); + await sleep(2000); + return; + } + } + + const labels: string[] = []; + await input.setText('> Create new logic'); + await sleep(2000); + picks = await input.getQuickPicks(); + for (const pick of picks) { + const label = await pick.getLabel(); + labels.push(label); + const lowerLabel = label.toLowerCase(); + if (lowerLabel.includes('workspace') && !lowerLabel.includes('package') && !lowerLabel.includes('from')) { + console.log(`[azurite-e2e] Selecting create workspace command after retry: "${label}"`); + await pick.select(); + await sleep(2000); + return; + } + } + + await input.cancel(); + throw new Error(`Could not find Create Workspace command. Available picks: ${JSON.stringify(labels)}`); +} + +async function withTimeout(operation: Promise, timeoutMs: number, label: string): Promise { + let timeoutHandle: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(`${label} did not complete within ${timeoutMs}ms`)), timeoutMs); + }); + + try { + return await Promise.race([operation, timeout]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +} + +async function switchToCreateWorkspaceWebview(driver: WebDriver): Promise { + await dismissNotifications(driver); + await driver.switchTo().defaultContent(); + const outerFrame = await driver.wait( + until.elementLocated(By.css("iframe[class='webview ready']")), + 60_000, + 'Create Workspace webview iframe not found' + ); + await driver.switchTo().frame(outerFrame); + const innerFrame = await driver.wait(until.elementLocated(By.id('active-frame')), 15_000, '#active-frame not found'); + await driver.switchTo().frame(innerFrame); + await sleep(1000); + return new WebView(); +} + +async function clearNotificationsAndReturnToCreateWorkspaceWebview(driver: WebDriver): Promise { + await driver.switchTo().defaultContent(); + await dismissNotifications(driver); + try { + await driver.executeScript(` + document + .querySelectorAll('.notifications-toasts .codicon-close, .notifications-toasts .codicon-notifications-clear-all') + .forEach((button) => button.click()); + `); + } catch { + // Best-effort cleanup; the next frame switch still succeeds if nothing was present. + } + return await switchToCreateWorkspaceWebview(driver); +} + +async function findInputByLabel(driver: WebDriver, labelText: string): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const labels = await driver.findElements(By.xpath(`//label[contains(text(), ${toXPathLiteral(labelText)})]`)); + for (const label of labels) { + const forAttr = await label.getAttribute('for'); + if (forAttr) { + const inputs = await driver.findElements(By.id(forAttr)); + for (const input of inputs) { + if (await input.isDisplayed().catch(() => false)) { + return input; + } + } + } + } + + for (const label of labels) { + const parent = await label.findElement(By.xpath('..')); + const inputs = await parent.findElements(By.css('input')); + for (const input of inputs) { + if (await input.isDisplayed().catch(() => false)) { + return input; + } + } + } + await sleep(500); + } + + throw new Error(`Could not find input for label "${labelText}"`); +} + +async function clearAndType(element: WebElement, text: string): Promise { + await element.click(); + await element.sendKeys(Key.chord(Key.CONTROL, 'a'), Key.BACK_SPACE); + await sleep(100); + await element.sendKeys(text); + await sleep(500); +} + +async function selectRadioOption(driver: WebDriver, optionLabel: string): Promise { + const labels = await driver.findElements(By.xpath(`//label[contains(text(), ${toXPathLiteral(optionLabel)})]`)); + if (labels.length === 0) { + throw new Error(`Radio option "${optionLabel}" not found`); + } + await labels[0].click(); + await sleep(500); +} + +async function findDropdownByLabel(driver: WebDriver, labelText: string): Promise { + const labels = await driver.findElements(By.xpath(`//label[contains(text(), ${toXPathLiteral(labelText)})]`)); + for (const label of labels) { + const forAttr = await label.getAttribute('for'); + if (forAttr) { + const buttons = await driver.findElements(By.id(forAttr)); + if (buttons.length > 0) { + return buttons[0]; + } + } + } + const comboboxes = await driver.findElements(By.css('button[role="combobox"]')); + if (comboboxes.length > 0) { + return comboboxes[comboboxes.length - 1]; + } + throw new Error(`Could not find dropdown for label "${labelText}"`); +} + +async function selectDropdownOption(driver: WebDriver, dropdown: WebElement, optionText: string): Promise { + await dropdown.click(); + await sleep(500); + const options = await driver.findElements(By.css('[role="option"]')); + for (const option of options) { + const text = await option.getText(); + if (text.trim() === optionText) { + await option.click(); + await sleep(500); + return; + } + } + throw new Error(`Dropdown option "${optionText}" not found`); +} + +async function fillStandardWorkspaceWebviewForm(driver: WebDriver): Promise { + await clearAndType(await findInputByLabel(driver, 'Workspace parent folder path'), WORKSPACE_PARENT_DIR); + await sleep(1500); + await clearAndType(await findInputByLabel(driver, 'Workspace name'), WORKSPACE_NAME); + await clearAndType(await findInputByLabel(driver, 'Logic app name'), APP_NAME); + await selectRadioOption(driver, 'Logic app (Standard)'); + await clearAndType(await findInputByLabel(driver, 'Workflow name'), WORKFLOW_NAME); + await selectDropdownOption(driver, await findDropdownByLabel(driver, 'Workflow type'), 'Stateful'); +} + +async function waitForNextButton(driver: WebDriver): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const buttons = await driver.findElements(By.xpath("//button[contains(text(), 'Next')]")); + for (const button of buttons) { + const disabled = await button.getAttribute('disabled'); + const ariaDisabled = await button.getAttribute('aria-disabled'); + if (disabled !== 'true' && disabled !== '' && ariaDisabled !== 'true') { + return button; + } + } + await sleep(500); + } + throw new Error('Next button did not become enabled'); +} + +async function waitForCreateWorkspaceButton(driver: WebDriver): Promise { + const deadline = Date.now() + 45_000; + while (Date.now() < deadline) { + const buttons = await driver.findElements(By.xpath("//button[contains(text(), 'Create workspace')]")); + for (const button of buttons) { + const disabled = await button.getAttribute('disabled'); + const ariaDisabled = await button.getAttribute('aria-disabled'); + if (disabled !== 'true' && disabled !== '' && ariaDisabled !== 'true') { + return button; + } + } + await sleep(500); + } + throw new Error('Create workspace button not found'); +} + +async function createWorkspaceFromWebview(workbench: Workbench, driver: WebDriver): Promise { + await removeWorkspaceParent(); + fs.mkdirSync(WORKSPACE_PARENT_DIR, { recursive: true }); + + await selectCreateWorkspaceCommand(workbench); + let webview = await switchToCreateWorkspaceWebview(driver); + await fillStandardWorkspaceWebviewForm(driver); + webview = await clearNotificationsAndReturnToCreateWorkspaceWebview(driver); + const nextButton = await waitForNextButton(driver); + await driver.executeScript('arguments[0].click();', nextButton); + await sleep(2000); + + webview = await clearNotificationsAndReturnToCreateWorkspaceWebview(driver); + const createButton = await waitForCreateWorkspaceButton(driver); + await driver.executeScript('arguments[0].click();', createButton); + await sleep(15_000); + try { + await webview.switchBack(); + } catch { + await driver.switchTo().defaultContent(); + } + + if (!fs.existsSync(WORKSPACE_FILE) || !fs.existsSync(PROJECT_DIR)) { + throw new Error(`Create Workspace webview did not create expected workspace at ${WORKSPACE_FILE}`); + } +} + +function configureGeneratedWorkspaceForAzuriteFailure(): void { + const workspaceJson = readJson(WORKSPACE_FILE); + workspaceJson.settings = { + ...(workspaceJson.settings ?? {}), + 'azureLogicAppsStandard.autoStartAzurite': true, + 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, + 'azureLogicAppsStandard.autoStartDesignTime': true, + 'azureLogicAppsStandard.showStartDesignTimeMessage': false, + 'azureLogicAppsStandard.showProjectWarning': false, + 'azureLogicAppsStandard.verifyConnectionKeys': false, + 'azureFunctions.suppressProject': true, + 'debug.internalConsoleOptions': 'neverOpen', + }; + writeJson(WORKSPACE_FILE, workspaceJson); + + const settingsPath = path.join(PROJECT_DIR, '.vscode', 'settings.json'); + const settingsJson = fs.existsSync(settingsPath) ? readJson(settingsPath) : {}; + writeJson(settingsPath, { + ...settingsJson, + 'azureLogicAppsStandard.autoStartAzurite': true, + 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, + 'azureLogicAppsStandard.autoStartDesignTime': true, + 'azureLogicAppsStandard.showStartDesignTimeMessage': false, + 'azureLogicAppsStandard.showProjectWarning': false, + 'azureLogicAppsStandard.verifyConnectionKeys': false, + 'azureFunctions.suppressProject': true, + 'debug.internalConsoleOptions': 'neverOpen', + }); + + const launchPath = path.join(PROJECT_DIR, '.vscode', 'launch.json'); + writeJson(launchPath, { + version: '0.2.0', + configurations: [ + { + name: `Run/Debug logic app ${APP_NAME}`, + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + isCodeless: true, + }, + ], + }); + + const localSettingsPath = path.join(PROJECT_DIR, 'local.settings.json'); + const localSettingsJson = readJson(localSettingsPath); + localSettingsJson.Values = { + ...(localSettingsJson.Values ?? {}), + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + WORKFLOWS_SUBSCRIPTION_ID: '', + WORKFLOWS_TENANT_ID: '', + WORKFLOWS_RESOURCE_GROUP_NAME: '', + WORKFLOWS_LOCATION_NAME: '', + }; + writeJson(localSettingsPath, localSettingsJson); +} + +async function removeWorkspaceParent(): Promise { + for (let attempt = 1; attempt <= 8; attempt++) { + try { + fs.rmSync(WORKSPACE_PARENT_DIR, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + return; + } catch (error) { + if (attempt === 8) { + throw error; + } + await sleep(3000); + } + } +} + +describe('Azurite auto-start failure E2E workspace creation', function () { + this.timeout(TEST_TIMEOUT); + + let driver: WebDriver; + + before(async function () { + this.timeout(30_000); + driver = VSBrowser.instance.driver; + }); + + after(async function () { + this.timeout(30_000); + try { + await driver.switchTo().defaultContent(); + } catch { + /* ignore */ + } + }); + + if (E2E_STEP === 'create') { + it('creates a Logic Apps workspace through the Create Workspace webview', async function () { + this.timeout(TEST_TIMEOUT); + await withTimeout(createWorkspaceFromWebview(new Workbench(), driver), 180_000, 'Create Workspace webview flow'); + configureGeneratedWorkspaceForAzuriteFailure(); + }); + } else { + it('is intentionally create-phase only', function () { + console.log( + `[azurite-e2e] Skipping AZURITE_E2E_STEP=${E2E_STEP}. Debug assertion coverage lives in azuriteAutostartFailureAssert.test.ts.` + ); + this.skip(); + }); + } +}); diff --git a/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts new file mode 100644 index 00000000000..83fd26ef733 --- /dev/null +++ b/apps/vs-code-designer/src/test/ui/azuriteAutostartFailureAssert.test.ts @@ -0,0 +1,384 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Azurite auto-start failure assertion E2E. + * + * This runs in a fresh VS Code session after azuriteAutostartFailure.test.ts + * creates the workspace through the Create Workspace webview. + */ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { Key, type WebDriver, VSBrowser, Workbench } from 'vscode-extension-tester'; +import { captureScreenshot, sleep } from './helpers'; +import { startDebugging } from './runHelpers'; + +const TEST_TIMEOUT = 240_000; +const AZURITE_TIMEOUT_TEXT = 'Azurite did not become ready'; +const AZURE_WEB_JOBS_STORAGE_TEXT = 'Failed to verify "AzureWebJobsStorage" connection'; +const DEBUG_ANYWAY_TEXT = 'Debug anyway'; +const WORKSPACE_PARENT_DIR = + process.env.AZURITE_E2E_WORKSPACE_PARENT ?? path.join(os.tmpdir(), 'la-e2e-test', 'azurite-autostart-failure-parent'); +const WORKSPACE_NAME = 'azuritews'; +const APP_NAME = 'azuriteapp'; +const WORKSPACE_DIR = path.join(WORKSPACE_PARENT_DIR, WORKSPACE_NAME); +const PROJECT_DIR = path.join(WORKSPACE_DIR, APP_NAME); +const WORKSPACE_FILE = path.join(WORKSPACE_DIR, `${WORKSPACE_NAME}.code-workspace`); +const DESIGN_TIME_DIR = path.join(PROJECT_DIR, 'workflow-designtime'); +const EXPLICIT_SCREENSHOT_DIR = path.join( + process.env.TEMP || process.cwd(), + 'test-resources', + 'screenshots', + 'azuriteAutostartFailure-explicit', + new Date().toISOString().replace(/[:.]/g, '-') +); + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function readJson(filePath: string): any { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +function configureGeneratedWorkspaceForAzuriteFailure(): void { + const workspaceJson = readJson(WORKSPACE_FILE); + workspaceJson.settings = { + ...(workspaceJson.settings ?? {}), + 'azureLogicAppsStandard.autoStartAzurite': true, + 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, + 'azureLogicAppsStandard.autoStartDesignTime': true, + 'azureLogicAppsStandard.showStartDesignTimeMessage': false, + 'azureLogicAppsStandard.showProjectWarning': false, + 'azureLogicAppsStandard.verifyConnectionKeys': false, + 'azureFunctions.suppressProject': true, + 'debug.internalConsoleOptions': 'neverOpen', + }; + writeJson(WORKSPACE_FILE, workspaceJson); + + const settingsPath = path.join(PROJECT_DIR, '.vscode', 'settings.json'); + const settingsJson = fs.existsSync(settingsPath) ? readJson(settingsPath) : {}; + writeJson(settingsPath, { + ...settingsJson, + 'azureLogicAppsStandard.autoStartAzurite': true, + 'azureLogicAppsStandard.showAutoStartAzuriteWarning': false, + 'azureLogicAppsStandard.autoStartDesignTime': true, + 'azureLogicAppsStandard.showStartDesignTimeMessage': false, + 'azureLogicAppsStandard.showProjectWarning': false, + 'azureLogicAppsStandard.verifyConnectionKeys': false, + 'azureFunctions.suppressProject': true, + 'debug.internalConsoleOptions': 'neverOpen', + }); + + const launchPath = path.join(PROJECT_DIR, '.vscode', 'launch.json'); + writeJson(launchPath, { + version: '0.2.0', + configurations: [ + { + name: `Run/Debug logic app ${APP_NAME}`, + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + isCodeless: true, + }, + ], + }); + + const localSettingsPath = path.join(PROJECT_DIR, 'local.settings.json'); + const localSettingsJson = readJson(localSettingsPath); + localSettingsJson.Values = { + ...(localSettingsJson.Values ?? {}), + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + WORKFLOWS_SUBSCRIPTION_ID: '', + WORKFLOWS_TENANT_ID: '', + WORKFLOWS_RESOURCE_GROUP_NAME: '', + WORKFLOWS_LOCATION_NAME: '', + }; + writeJson(localSettingsPath, localSettingsJson); +} + +async function bindPort(port: number): Promise { + return await new Promise((resolve, reject) => { + const server = http.createServer((_, response) => { + response.statusCode = 403; + response.setHeader('Connection', 'close'); + response.end('Azurite blocked by E2E test'); + }); + server.requestTimeout = 1000; + server.headersTimeout = 1000; + server.keepAliveTimeout = 1000; + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.off('error', reject); + resolve(server); + }); + }); +} + +async function blockAzuritePorts(): Promise { + const servers: http.Server[] = []; + for (const port of [10000, 10001, 10002]) { + try { + servers.push(await bindPort(port)); + console.log(`[azurite-e2e] Bound local port ${port}`); + } catch (error) { + for (const server of servers) { + server.close(); + } + throw new Error(`Unable to bind Azurite port ${port}. Stop any local Azurite instance and retry. ${error}`); + } + } + return servers; +} + +async function closeServers(servers: http.Server[]): Promise { + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + const timeout = setTimeout(resolve, 1000); + server.close(() => { + clearTimeout(timeout); + resolve(); + }); + }) + ) + ); +} + +async function getVisibleWorkbenchText(driver: WebDriver): Promise { + return await driver.executeScript(` + const selectors = [ + '.monaco-dialog-box', + '[role="dialog"]', + '.notification-toast', + '.notifications-toasts', + '.quick-input-widget:not(.hidden)', + '.monaco-workbench' + ]; + return selectors + .flatMap((sel) => Array.from(document.querySelectorAll(sel))) + .map((el) => el.textContent || '') + .join('\\n'); + `); +} + +async function focusTerminalPanel(driver: WebDriver): Promise { + try { + await driver.actions().keyDown(Key.CONTROL).sendKeys('`').keyUp(Key.CONTROL).perform(); + await sleep(1000); + await driver.actions().sendKeys(Key.ESCAPE).perform(); + } catch (error) { + console.log(`[azurite-e2e] Could not focus terminal panel: ${error}`); + } +} + +async function getPanelDiagnostics(driver: WebDriver): Promise { + return await driver.executeScript(` + const selectors = [ + '.terminal-wrapper', + '.xterm-screen', + '.xterm-rows', + '.panel .monaco-list', + '.output-view', + '.notifications-toasts', + '[role="dialog"]', + '.quick-input-widget:not(.hidden)' + ]; + return selectors + .map((sel) => { + const text = Array.from(document.querySelectorAll(sel)).map((el) => el.textContent || '').join('\\n'); + return text ? '--- ' + sel + ' ---\\n' + text : ''; + }) + .filter(Boolean) + .join('\\n'); + `); +} + +async function logPanelDiagnostics(driver: WebDriver, label: string): Promise { + try { + await driver.switchTo().defaultContent(); + const diagnostics = await getPanelDiagnostics(driver); + console.log(`[azurite-e2e] ${label} diagnostics:\n${diagnostics || ''}`); + } catch (error) { + console.log(`[azurite-e2e] Failed to capture ${label} diagnostics: ${error}`); + } +} + +function logLatestLogicAppsOutput(label: string): void { + try { + const logsRoot = path.join(os.tmpdir(), 'test-resources', 'settings', 'logs'); + if (!fs.existsSync(logsRoot)) { + console.log(`[azurite-e2e] ${label} output log: logs root does not exist`); + return; + } + + const matches: string[] = []; + const collect = (directory: string) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + collect(entryPath); + } else if (entry.name.includes('Azure Logic Apps') && entry.name.endsWith('.log')) { + matches.push(entryPath); + } + } + }; + collect(logsRoot); + + const latest = matches + .map((filePath) => ({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs })) + .sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath; + + if (!latest) { + console.log(`[azurite-e2e] ${label} output log: no Azure Logic Apps output log found`); + return; + } + + const content = fs.readFileSync(latest, 'utf-8'); + console.log(`[azurite-e2e] ${label} output log (${latest}):\n${content.slice(-6000)}`); + } catch (error) { + console.log(`[azurite-e2e] Failed to read ${label} output log: ${error}`); + } +} + +async function waitForWorkbenchText(driver: WebDriver, text: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const visibleText = await getVisibleWorkbenchText(driver); + if (visibleText.includes(text)) { + return true; + } + await sleep(500); + } + return false; +} + +async function assertTextDoesNotAppear(driver: WebDriver, text: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const visibleText = await getVisibleWorkbenchText(driver); + assert.ok(!visibleText.includes(text), `Unexpected workbench text appeared: ${text}`); + await sleep(500); + } +} + +async function isDebugToolbarVisible(driver: WebDriver): Promise { + return await driver.executeScript(` + const toolbar = document.querySelector('.debug-toolbar, [class*="debug-toolbar"], [class*="debugging-actions"]'); + if (!toolbar) { + return false; + } + const style = window.getComputedStyle(toolbar); + return style.display !== 'none' && style.visibility !== 'hidden'; + `); +} + +async function waitForWorkspaceOpen(driver: WebDriver): Promise { + const deadline = Date.now() + 45_000; + let lastTitle = ''; + let lastExplorerState = ''; + + while (Date.now() < deadline) { + await driver + .switchTo() + .defaultContent() + .catch(() => undefined); + lastTitle = await driver.getTitle().catch(() => ''); + lastExplorerState = await driver + .executeScript( + ` + const rows = document.querySelectorAll('.explorer-viewlet .monaco-list-row, .explorer-folders-view .monaco-list-row'); + return Array.from(rows).map((row) => row.textContent || '').join('\\n'); + ` + ) + .catch(() => ''); + + const title = lastTitle.toLowerCase(); + const explorer = lastExplorerState.toLowerCase(); + if (title.includes(WORKSPACE_NAME) || explorer.includes(WORKSPACE_NAME) || explorer.includes(APP_NAME)) { + return; + } + + await sleep(1000); + } + + await captureScreenshot(driver, 'azurite-workspace-not-open', EXPLICIT_SCREENSHOT_DIR); + throw new Error( + `Generated workspace did not open in fresh session: ${WORKSPACE_FILE}. Title="${lastTitle}", Explorer="${lastExplorerState.substring( + 0, + 200 + )}"` + ); +} + +async function waitForDesignTimeFolder(): Promise { + const deadline = Date.now() + 45_000; + while (Date.now() < deadline) { + if (fs.existsSync(DESIGN_TIME_DIR)) { + return; + } + await sleep(1000); + } + throw new Error(`Expected design-time startup to create ${DESIGN_TIME_DIR}`); +} + +describe('Azurite auto-start failure E2E assertion', function () { + this.timeout(TEST_TIMEOUT); + + let driver: WebDriver; + let portBlockers: http.Server[] = []; + + before(async function () { + this.timeout(30_000); + fs.mkdirSync(EXPLICIT_SCREENSHOT_DIR, { recursive: true }); + driver = VSBrowser.instance.driver; + }); + + after(async function () { + this.timeout(30_000); + try { + await driver.switchTo().defaultContent(); + } catch { + /* ignore */ + } + await closeServers(portBlockers); + }); + + it('stops debug after Azurite auto-start failure without showing AzureWebJobsStorage warning', async function () { + this.timeout(TEST_TIMEOUT); + assert.ok(fs.existsSync(WORKSPACE_FILE), `Expected generated workspace file to exist: ${WORKSPACE_FILE}`); + configureGeneratedWorkspaceForAzuriteFailure(); + await waitForWorkspaceOpen(driver); + await waitForDesignTimeFolder(); + console.log(`[azurite-e2e] Design-time folder exists: ${DESIGN_TIME_DIR}`); + await sleep(3000); + + const workbench = new Workbench(); + await focusTerminalPanel(driver); + await logPanelDiagnostics(driver, 'before debug'); + portBlockers = await blockAzuritePorts(); + await startDebugging(workbench, driver); + await focusTerminalPanel(driver); + await logPanelDiagnostics(driver, 'after debug command'); + logLatestLogicAppsOutput('after debug command'); + + const sawAzuriteFailure = await waitForWorkbenchText(driver, AZURITE_TIMEOUT_TEXT, 45_000); + if (!sawAzuriteFailure) { + await focusTerminalPanel(driver); + await logPanelDiagnostics(driver, 'missing Azurite failure'); + logLatestLogicAppsOutput('missing Azurite failure'); + await captureScreenshot(driver, 'azurite-failure-message-not-found', EXPLICIT_SCREENSHOT_DIR); + } + assert.ok(sawAzuriteFailure, 'Expected Azurite auto-start timeout to be visible'); + + await assertTextDoesNotAppear(driver, AZURE_WEB_JOBS_STORAGE_TEXT, 20_000); + await assertTextDoesNotAppear(driver, DEBUG_ANYWAY_TEXT, 5_000); + assert.strictEqual(await isDebugToolbarVisible(driver), false, 'Debug toolbar should not be visible after Azurite auto-start failure'); + }); +}); diff --git a/apps/vs-code-designer/src/test/ui/designerHelpers.ts b/apps/vs-code-designer/src/test/ui/designerHelpers.ts index 7f1d32c93b7..0e0b59ef926 100644 --- a/apps/vs-code-designer/src/test/ui/designerHelpers.ts +++ b/apps/vs-code-designer/src/test/ui/designerHelpers.ts @@ -54,7 +54,7 @@ import { VSBrowser, type WebElement, Key, - InputBox, + type InputBox, } from 'vscode-extension-tester'; import { execSync } from 'child_process'; import { @@ -331,6 +331,7 @@ export async function openWorkspaceFileInSession(workbench: Workbench, wsFilePat // shows a simple text input (files.simpleDialog.enable=true is set by ExTester). const isWorkspaceFile = wsFilePath.endsWith('.code-workspace'); const openPath = isWorkspaceFile ? wsFilePath : wsFilePath; + const expectedTitleText = isWorkspaceFile ? path.basename(wsFilePath, '.code-workspace') : path.basename(wsFilePath); for (let attempt = 0; attempt < 3; attempt++) { try { @@ -378,21 +379,15 @@ export async function openWorkspaceFileInSession(workbench: Workbench, wsFilePat // Wait for the simple dialog input box to appear await sleep(2000); - // The simple dialog shows an input box. Type the full path. - // ExTester sets files.simpleDialog.enable=true so this should be a text input. - try { - const dialogInput = new InputBox(); - await dialogInput.setText(openPath); - await sleep(500); - await dialogInput.confirm(); - } catch (inputErr: any) { - console.log(`[openWorkspaceFileInSession] InputBox approach failed: ${inputErr.message}, trying sendKeys`); - // Fallback: type directly and press Enter - const body = await driver.findElement(By.css('body')); - await body.sendKeys(openPath, Key.ENTER); - } + // The simple dialog shows a quick-input text box. Use raw Selenium here + // so we do not accidentally keep interacting with the command palette input. + const dialogInput = await driver.findElement(By.css('.quick-input-widget:not(.hidden) .quick-input-box input')); + await dialogInput.sendKeys(Key.chord(Key.CONTROL, 'a')); + await dialogInput.sendKeys(openPath); + await sleep(500); + await dialogInput.sendKeys(Key.ENTER); - await sleep(5000); + await sleep(7000); // Wait for the workbench to be ready after potential reload try { @@ -405,8 +400,8 @@ export async function openWorkspaceFileInSession(workbench: Workbench, wsFilePat const titleAfter = await driver.getTitle().catch(() => ''); console.log(`[openWorkspaceFileInSession] VS Code title AFTER: "${titleAfter}"`); - if (titleAfter !== 'Visual Studio Code') { - console.log('[openWorkspaceFileInSession] Workspace opened successfully (title changed)'); + if (titleAfter.toLowerCase().includes(expectedTitleText.toLowerCase())) { + console.log('[openWorkspaceFileInSession] Workspace opened successfully (expected title)'); break; } @@ -416,14 +411,14 @@ export async function openWorkspaceFileInSession(workbench: Workbench, wsFilePat ` const rows = document.querySelectorAll('.explorer-viewlet .monaco-list-row, .explorer-folders-view .monaco-list-row'); if (rows.length === 0) return 'EMPTY'; - return 'ROWS=' + rows.length; + return Array.from(rows).map((row) => row.textContent || '').join('\\n'); ` ) .catch(() => 'ERROR'); console.log(`[openWorkspaceFileInSession] Explorer: ${explorerState}`); - if (explorerState !== 'EMPTY') { - console.log('[openWorkspaceFileInSession] Folder opened (explorer has rows)'); + if (explorerState.toLowerCase().includes(expectedTitleText.toLowerCase())) { + console.log('[openWorkspaceFileInSession] Workspace opened successfully (expected Explorer contents)'); break; } @@ -440,6 +435,23 @@ export async function openWorkspaceFileInSession(workbench: Workbench, wsFilePat } } + const finalTitle = await driver.getTitle().catch(() => ''); + const finalExplorerState = await driver + .executeScript( + ` + const rows = document.querySelectorAll('.explorer-viewlet .monaco-list-row, .explorer-folders-view .monaco-list-row'); + return Array.from(rows).map((row) => row.textContent || '').join('\\n'); + ` + ) + .catch(() => ''); + if ( + !finalTitle.toLowerCase().includes(expectedTitleText.toLowerCase()) && + !finalExplorerState.toLowerCase().includes(expectedTitleText.toLowerCase()) + ) { + await captureScreenshot(driver, 'workspace-open-failed'); + throw new Error(`Workspace did not open: ${wsFilePath}. Title="${finalTitle}", Explorer="${finalExplorerState.substring(0, 200)}"`); + } + await clearBlockingUI(driver); await sleep(3000); await clearBlockingUI(driver); diff --git a/apps/vs-code-designer/src/test/ui/helpers.ts b/apps/vs-code-designer/src/test/ui/helpers.ts index b319a35a1ed..6ecfef20ffc 100644 --- a/apps/vs-code-designer/src/test/ui/helpers.ts +++ b/apps/vs-code-designer/src/test/ui/helpers.ts @@ -73,9 +73,51 @@ export async function captureScreenshot(driver: WebDriver, fileName: string, scr // Notification / Dialog dismissal // =========================================================================== +function isWorkspaceConversionDialog(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes('logic app projects must exist inside a workspace') || + lowerMessage.includes('copy your projects to a new workspace') + ); +} + /** Dismiss any VS Code notification toasts that may block interactions. */ export async function dismissNotifications(driver: WebDriver): Promise { try { + const dismissedKnownPrompt = await driver.executeScript(` + const containers = Array.from(document.querySelectorAll('.notification-toast, .notification-list-item, [role="dialog"]')); + let dismissed = 0; + for (const container of containers) { + const text = (container.textContent || '').toLowerCase(); + const isKnownSignInPrompt = + text.includes('visual studio subscription benefits') || + text.includes('c# dev kit') || + (text.includes('sign in') && text.includes('subscription benefits')); + if (!isKnownSignInPrompt) { + continue; + } + + const candidates = Array.from( + container.querySelectorAll('button, .monaco-text-button, .monaco-button, .action-label, [role="button"], [aria-label="Close"]') + ); + const closeButton = + candidates.find((button) => { + const label = ((button.textContent || button.getAttribute('aria-label') || button.getAttribute('title') || '')).toLowerCase(); + return label.includes('close') || label.includes('not now') || label.includes('dismiss') || label.includes('cancel'); + }) || + container.querySelector('[aria-label="Close"], .codicon-close, .action-label.codicon-close, .codicon-notifications-clear-all'); + if (closeButton) { + closeButton.click(); + dismissed++; + } + } + return dismissed; + `); + if (dismissedKnownPrompt > 0) { + console.log(`[dismissNotifications] Dismissed ${dismissedKnownPrompt} known sign-in prompt(s)`); + await sleep(500); + } + const closeButtons = await driver.findElements( By.css('.notifications-toasts .codicon-notifications-clear-all, .notification-toast .action-label.codicon-close') ); @@ -118,6 +160,11 @@ export async function dismissAllDialogs(driver: WebDriver): Promise { return false; } + if (isWorkspaceConversionDialog(message)) { + console.log('[dismissAllDialogs] Skipping workspace conversion dialog — conversion tests must handle it'); + return false; + } + // Dismiss GitHub API rate-limit errors (403) that block the UI. // These occur when the extension checks for latest versions in CI. if (message.includes('Error reading JSON from URL') || message.includes('status code 403')) { @@ -202,6 +249,11 @@ export async function dismissAllDialogs(driver: WebDriver): Promise { continue; } + if (isWorkspaceConversionDialog(messageText)) { + console.log('[dismissAllDialogs] Skipping workspace conversion dialog — conversion tests must handle it'); + continue; + } + // Dismiss GitHub API rate-limit errors (403) via raw selector if (messageText.includes('Error reading JSON from URL') || messageText.includes('status code 403')) { try { @@ -215,17 +267,45 @@ export async function dismissAllDialogs(driver: WebDriver): Promise { } } - if (messageText.includes('sign in') || messageText.includes('Sign in') || messageText.includes('wants to sign in')) { + if ( + messageText.includes('sign in') || + messageText.includes('Sign in') || + messageText.includes('wants to sign in') || + messageText.includes('Visual Studio subscription benefits') || + messageText.includes('C# Dev Kit') + ) { try { - const cancelBtns = await dialogs[0].findElements(By.css('button, .monaco-text-button, .monaco-button')); + const cancelBtns = await dialogs[0].findElements( + By.css('button, .monaco-text-button, .monaco-button, .action-label, [role="button"]') + ); for (const btn of cancelBtns) { - const label = await btn.getText().catch(() => ''); - if (label.toLowerCase().includes('cancel')) { + const label = + (await btn.getText().catch(() => '')) || + (await btn.getAttribute('aria-label').catch(() => '')) || + (await btn.getAttribute('title').catch(() => '')); + const lower = label.toLowerCase(); + if (lower.includes('cancel') || lower.includes('close') || lower.includes('not now') || lower.includes('dismiss')) { + console.log(`[dismissAllDialogs] Clicking "${label}" on sign-in prompt`); await btn.click(); await sleep(1000); return true; } } + const closedWithScript = await driver.executeScript( + ` + const dialog = arguments[0]; + const closeButton = dialog.querySelector('[aria-label="Close"], .codicon-close, .action-label.codicon-close'); + if (!closeButton) return false; + closeButton.click(); + return true; + `, + dialogs[0] + ); + if (closedWithScript) { + console.log('[dismissAllDialogs] Closed sign-in prompt via script'); + await sleep(1000); + return true; + } } catch { /* ignore */ } @@ -639,11 +719,13 @@ export async function openFolderInSession(driver: WebDriver, folderPath: string) await dialogInput.sendKeys(Key.ENTER); await sleep(5000); - // Check if folder opened by looking at the title + // Check if folder opened by looking at the title. VS Code can append + // suffixes like "[Administrator]", so require the folder name itself. const title = await driver.getTitle().catch(() => ''); console.log(`[openFolderInSession] VS Code title: "${title}"`); + const expectedFolderName = path.basename(folderPath); - if (title !== 'Visual Studio Code') { + if (title.toLowerCase().includes(expectedFolderName.toLowerCase())) { console.log('[openFolderInSession] Folder opened successfully'); return; } @@ -670,5 +752,5 @@ export async function openFolderInSession(driver: WebDriver, folderPath: string) await sleep(2000); } } - console.log('[openFolderInSession] All attempts exhausted'); + throw new Error(`Unable to open folder in VS Code: ${folderPath}`); } 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 69d9af32dcd..8c9397fdca3 100644 --- a/apps/vs-code-designer/src/test/ui/run-e2e.js +++ b/apps/vs-code-designer/src/test/ui/run-e2e.js @@ -90,6 +90,72 @@ function rebuildExtensionsJson(extensionsDir) { console.log(` ✓ Rebuilt extensions.json with ${entries.length} entries`); } +function installFakeAzuriteExtension(extensionsDir) { + console.log('\n=== Installing fake Azurite extension for failure E2E ==='); + fs.mkdirSync(extensionsDir, { recursive: true }); + for (const entry of fs.readdirSync(extensionsDir)) { + if (entry === 'extensions.json' || entry === '.obsolete') continue; + if (entry.toLowerCase().startsWith('azurite.azurite')) { + fs.rmSync(path.join(extensionsDir, entry), { recursive: true, force: true }); + console.log(` Removed real Azurite extension: ${entry}`); + } + } + + const fakeDirName = 'azurite.azurite-0.0.0-e2e'; + const fakeDir = path.join(extensionsDir, fakeDirName); + fs.mkdirSync(fakeDir, { recursive: true }); + fs.writeFileSync( + path.join(fakeDir, 'package.json'), + JSON.stringify( + { + name: 'azurite', + publisher: 'Azurite', + version: '0.0.0-e2e', + engines: { vscode: '^1.80.0' }, + main: './extension.js', + activationEvents: ['*'], + contributes: { + configuration: { + type: 'object', + title: 'Azurite', + properties: { + 'azurite.location': { + type: 'string', + default: '', + description: 'Azurite workspace location.', + }, + }, + }, + commands: [ + { + command: 'azurite.start', + title: 'Azurite: Start', + }, + ], + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(fakeDir, 'extension.js'), + [ + "const vscode = require('vscode');", + 'function activate(context) {', + " context.subscriptions.push(vscode.commands.registerCommand('azurite.start', async () => {", + " console.log('[fake-azurite-e2e] azurite.start invoked without starting emulator');", + ' }));', + '}', + 'function deactivate() {}', + 'module.exports = { activate, deactivate };', + '', + ].join('\n') + ); + rebuildExtensionsJson(extensionsDir); + console.log(` ✓ Fake Azurite extension installed: ${fakeDirName}`); +} + function createLegacyProjectFixture(label) { const legacyRoot = fs.mkdtempSync(path.join(os.tmpdir(), `la-e2e-${label}-`)); const legacyDir = path.join(legacyRoot, 'legacy-project'); @@ -680,6 +746,8 @@ async function main() { 'azureLogicAppsStandard.autoStartDesignTime': autoStartDesignTime, // Suppress the "Start design time?" prompt dialog on project load. 'azureLogicAppsStandard.showStartDesignTimeMessage': false, + // Suppress connection parameterization prompts in prompt-sensitive phases. + 'azureLogicAppsStandard.parameterizeConnectionsInProjectLoad': false, // Suppress "wants to sign in" auth dialog — uses silent auth that // returns undefined instead of prompting when no cached token exists. 'azureLogicAppsStandard.silentAuth': true, @@ -744,6 +812,25 @@ async function main() { const phase6Files = [testFile('keyboardNavigation.test.js')]; const phase7Files = [testFile('demo.test.js'), testFile('smoke.test.js'), testFile('standalone.test.js'), testFile('dataMapper.test.js')]; + const phase9CreateFiles = [testFile('azuriteAutostartFailure.test.js')]; + const phase9Files = [testFile('azuriteAutostartFailureAssert.test.js')]; + const azuriteWorkspaceParentDir = path.join(os.tmpdir(), 'la-e2e-test', `azurite-autostart-failure-parent-${process.pid}-${Date.now()}`); + const azuriteWorkspaceFile = path.join(azuriteWorkspaceParentDir, 'azuritews', 'azuritews.code-workspace'); + + const cleanupAzuriteWorkspace = async () => { + for (let attempt = 1; attempt <= 8; attempt++) { + try { + fs.rmSync(azuriteWorkspaceParentDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + return; + } catch (error) { + if (attempt === 8) { + console.warn(` Could not remove Azurite E2E workspace: ${error.message}`); + return; + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } + }; // Conversion tests (ADO #31054994, Steps 5-15) // Each gets its own session because they need different startup folders. @@ -1075,6 +1162,34 @@ async function main() { process.exit(finalExit); } + if (e2eMode === 'azuriteonly') { + await extest.downloadCode(VSCODE_VERSION); + await extest.downloadChromeDriver(VSCODE_VERSION); + writeTestSettings({ validateDependencies: false, autoStartDesignTime: false }); + process.env.AZURITE_E2E_WORKSPACE_PARENT = azuriteWorkspaceParentDir; + process.env.AZURITE_E2E_STEP = 'create'; + await prepareFreshSession('phase9-create-only'); + const phase9CreateExit = await runPhase('Phase 4.9a: azurite workspace creation', phase9CreateFiles); + if (phase9CreateExit !== 0) { + process.exit(phase9CreateExit); + } + + installFakeAzuriteExtension(extDir); + writeTestSettings({ validateDependencies: false, autoStartDesignTime: true }); + process.env.AZURITE_E2E_STEP = 'assert'; + await new Promise((resolve) => setTimeout(resolve, 3000)); + await prepareFreshSession('phase9-assert-only'); + const phase9Exit = await runPhase('Phase 4.9b: azuriteAutostartFailure', phase9Files, { + resources: [azuriteWorkspaceFile], + }); + delete process.env.AZURITE_E2E_STEP; + delete process.env.AZURITE_E2E_WORKSPACE_PARENT; + if (phase9Exit === 0) { + await cleanupAzuriteWorkspace(); + } + process.exit(phase9Exit); + } + 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. @@ -1164,6 +1279,29 @@ async function main() { await prepareFreshSession('phase7'); const phase7Exit = await runPhase('Phase 4.7: remaining suites', phase7Files); + writeTestSettings({ validateDependencies: false, autoStartDesignTime: false }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + process.env.AZURITE_E2E_WORKSPACE_PARENT = azuriteWorkspaceParentDir; + process.env.AZURITE_E2E_STEP = 'create'; + await prepareFreshSession('phase9-create'); + const phase9CreateExit = await runPhase('Phase 4.9a: azurite workspace creation', phase9CreateFiles); + if (phase9CreateExit !== 0) console.log(`\n⚠ Phase 4.9a exited with code ${phase9CreateExit} — continuing`); + + installFakeAzuriteExtension(extDir); + writeTestSettings({ validateDependencies: false, autoStartDesignTime: true }); + process.env.AZURITE_E2E_STEP = 'assert'; + await new Promise((resolve) => setTimeout(resolve, 3000)); + await prepareFreshSession('phase9-assert'); + const phase9Exit = await runPhase('Phase 4.9b: azuriteAutostartFailure', phase9Files, { + resources: [azuriteWorkspaceFile], + }); + delete process.env.AZURITE_E2E_STEP; + delete process.env.AZURITE_E2E_WORKSPACE_PARENT; + if (phase9Exit === 0) { + await cleanupAzuriteWorkspace(); + } + if (phase9Exit !== 0) console.log(`\n⚠ Phase 4.9 exited with code ${phase9Exit} — continuing`); + // Phases 4.8a–4.8e: Workspace conversion tests (ADO #31054994, Steps 5-15) // ALL conversion tests need validateDependencies ON so the extension fully // activates and can detect legacy projects / show the conversion dialog. @@ -1258,13 +1396,15 @@ async function main() { phase8aExit, phase8bExit, phase8cExit, - phase8eExit + phase8eExit, + phase9CreateExit, + phase9Exit ); if (phase8dExit !== 0) { console.log(`\n⚠ Phase 4.8d (conversionYes) failed but is excluded from final exit code (known flaky in CI)`); } console.log( - `\n=== Final results: 4.0=${phase0Exit}, 4.1=${phase1Exit}, 4.2=${phase2Exit}, 4.3=${phase3Exit}, 4.4=${phase4Exit}, 4.5=${phase5Exit}, 4.6=${phase6Exit}, 4.7=${phase7Exit}, 4.8a=${phase8aExit}, 4.8b=${phase8bExit}, 4.8c=${phase8cExit}, 4.8d=${phase8dExit}, 4.8e=${phase8eExit} → exit ${finalExit} ===` + `\n=== Final results: 4.0=${phase0Exit}, 4.1=${phase1Exit}, 4.2=${phase2Exit}, 4.3=${phase3Exit}, 4.4=${phase4Exit}, 4.5=${phase5Exit}, 4.6=${phase6Exit}, 4.7=${phase7Exit}, 4.8a=${phase8aExit}, 4.8b=${phase8bExit}, 4.8c=${phase8cExit}, 4.8d=${phase8dExit}, 4.8e=${phase8eExit}, 4.9=${phase9Exit} → exit ${finalExit} ===` ); process.exit(finalExit); } catch (err) { diff --git a/docs/ai-setup/packages/vs-code-designer.md b/docs/ai-setup/packages/vs-code-designer.md index 4fd1a56ab7a..876db35d24c 100644 --- a/docs/ai-setup/packages/vs-code-designer.md +++ b/docs/ai-setup/packages/vs-code-designer.md @@ -142,6 +142,7 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten | 4.5 | designerViewExtended.test.ts | Parallel branches + run-after (ADO #10109401) | | 4.6 | keyboardNavigation.test.ts | Ctrl+Up/Down navigation (ADO #10273324) | | 4.7 | dataMapper.test.ts, demo, smoke, standalone | Data Mapper + generic tests | +| 4.9 | azuriteAutostartFailure.test.ts, azuriteAutostartFailureAssert.test.ts | Azurite auto-start debug regression | ### Shared Helper Modules @@ -178,6 +179,8 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten 12. **Always run tests automatically after creating or modifying them**: After writing or editing any test file, immediately: lint (`npx biome check --write`), build (`npx tsup`), and run (`node src/test/ui/run-e2e.js`) — don't wait for the user to ask. Report pass/fail results with any failure details. +13. **Debug regression tests must use the real workspace launch flow**: Create the workspace through the Create Workspace webview, reopen the generated `.code-workspace` in a fresh `run-e2e.js` phase, wait for `workflow-designtime/`, and validate terminal/output/log evidence. Do not replace the suite path with one-off scripts, hand-made workspaces, or the wrong launch configuration. + ### Running Tests ```bash @@ -185,10 +188,11 @@ cd apps/vs-code-designer npx tsup --config tsup.e2e.test.config.ts # Compile # Run modes: -$env:E2E_MODE = "full" # All phases (4.1-4.7) +$env:E2E_MODE = "full" # All phases (4.1-4.9) $env:E2E_MODE = "createonly" # Phase 4.1 only $env:E2E_MODE = "designeronly" # Phase 4.2 only $env:E2E_MODE = "newtestsonly" # Phases 4.3-4.6 only +$env:E2E_MODE = "azuriteonly" # Phase 4.9 Azurite auto-start debug regression node src/test/ui/run-e2e.js ```