diff --git a/.vscode/launch.json b/.vscode/launch.json index 402650c8d..998b9540c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -72,11 +72,12 @@ "env": { "MOCHA_grep": "", // RegExp of tests to run (empty for all) "MOCHA_timeout": "0", // Disable time-outs - "DEBUGTELEMETRY": "v", + "DEBUGTELEMETRY": "", "NODE_DEBUG": "", "FUNC_PATH": "func", "AZFUNC_UPDATE_BACKUP_TEMPLATES": "", - "AzCode_EnableLongRunningTestsLocal": "", + "AzCode_EnableLongRunningTestsLocal": "true", + "AzCode_RunScenarioExtended": "durable-azurestorage-jsnode" } }, { diff --git a/extension.bundle.ts b/extension.bundle.ts index 8c4c823f2..64d812dd3 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -30,9 +30,11 @@ export * from './src/commands/initProjectForVSCode/initProjectForVSCode'; export * from './src/constants'; export * from './src/utils/durableUtils'; // Export activate/deactivate for main.js +export * from './src/commands/createFunction/durableSteps/DurableProjectConfigureStep'; export { activateInternal, deactivateInternal } from './src/extension'; export * from './src/extensionVariables'; export * from './src/funcConfig/function'; +export * from './src/funcConfig/host'; export * from './src/funcCoreTools/hasMinFuncCliVersion'; export * from './src/FuncVersion'; export * from './src/templates/CentralTemplateProvider'; diff --git a/src/commands/appSettings/localSettings/getLocalSettingsFile.ts b/src/commands/appSettings/localSettings/getLocalSettingsFile.ts index 77428080e..d44a7206f 100644 --- a/src/commands/appSettings/localSettings/getLocalSettingsFile.ts +++ b/src/commands/appSettings/localSettings/getLocalSettingsFile.ts @@ -15,7 +15,7 @@ import { tryGetFunctionProjectRoot } from '../../createNewProject/verifyIsProjec * Otherwise, prompt */ export async function getLocalSettingsFile(context: IActionContext, message: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { - workspaceFolder ||= await getRootWorkspaceFolder(); + workspaceFolder ||= await getRootWorkspaceFolder(context); if (workspaceFolder) { const projectPath: string | undefined = await tryGetFunctionProjectRoot(context, workspaceFolder); if (projectPath) { diff --git a/src/commands/createFunction/createFunction.ts b/src/commands/createFunction/createFunction.ts index 27e62aae7..c1df27c43 100644 --- a/src/commands/createFunction/createFunction.ts +++ b/src/commands/createFunction/createFunction.ts @@ -54,7 +54,7 @@ export async function createFunctionInternal(context: IActionContext, options: a let workspacePath: string | undefined = options.folderPath; if (workspacePath === undefined) { - workspaceFolder = await getRootWorkspaceFolder(); + workspaceFolder = await getRootWorkspaceFolder(context); workspacePath = workspaceFolder?.uri.fsPath; } else { workspaceFolder = getContainingWorkspace(workspacePath); diff --git a/src/commands/createFunction/durableSteps/DurableProjectConfigureStep.ts b/src/commands/createFunction/durableSteps/DurableProjectConfigureStep.ts index a85de9010..750e8a848 100644 --- a/src/commands/createFunction/durableSteps/DurableProjectConfigureStep.ts +++ b/src/commands/createFunction/durableSteps/DurableProjectConfigureStep.ts @@ -69,7 +69,7 @@ export class DurableProjectConfigureStep exten switch (context.newDurableStorageType) { case DurableBackend.Storage: - hostJson.extensions.durableTask = this.getDefaultStorageTaskConfig(); + hostJson.extensions.durableTask = DurableProjectConfigureStep.getDefaultStorageTaskConfig(); // Omit setting azureWebJobsStorage since it should already be initialized during 'createNewProject' break; case DurableBackend.Netherite: @@ -100,7 +100,7 @@ export class DurableProjectConfigureStep exten await AzExtFsExtra.writeJSON(hostJsonPath, hostJson); } - private getDefaultStorageTaskConfig(): IStorageTaskJson { + static getDefaultStorageTaskConfig(): IStorageTaskJson { return { storageProvider: { type: DurableBackend.Storage, diff --git a/src/commands/createFunctionApp/containerImage/detectDockerfile.ts b/src/commands/createFunctionApp/containerImage/detectDockerfile.ts index a3b253a17..dcbfab1ab 100644 --- a/src/commands/createFunctionApp/containerImage/detectDockerfile.ts +++ b/src/commands/createFunctionApp/containerImage/detectDockerfile.ts @@ -17,7 +17,7 @@ export async function detectDockerfile(context: ICreateFunctionAppContext): Prom return undefined; } - context.workspaceFolder ??= await getRootWorkspaceFolder() as WorkspaceFolder; + context.workspaceFolder ??= await getRootWorkspaceFolder(context) as WorkspaceFolder; context.rootPath ??= await tryGetFunctionProjectRoot(context, context.workspaceFolder, 'prompt') ?? context.workspaceFolder.uri.fsPath; const pattern: RelativePattern = new RelativePattern(context.rootPath, `**/${dockerfileGlobPattern}`); diff --git a/src/commands/createFunctionApp/createFunctionApp.ts b/src/commands/createFunctionApp/createFunctionApp.ts index 762bd83fd..342a6cfd7 100644 --- a/src/commands/createFunctionApp/createFunctionApp.ts +++ b/src/commands/createFunctionApp/createFunctionApp.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type AzExtParentTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { nonNullValueAndProp, type AzExtParentTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; import { type SlotTreeItem } from '../../tree/SlotTreeItem'; @@ -38,14 +38,13 @@ export async function createFunctionApp(context: IActionContext & Partial { +export async function createFunctionAppAdvanced(context: IActionContext, subscription?: AzExtParentTreeItem | string, nodesOrNewResourceGroupName?: string | (string | AzExtParentTreeItem)[]): Promise { return await createFunctionApp({ ...context, advancedCreation: true }, subscription, nodesOrNewResourceGroupName); } diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 029e173ae..a251230db 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -52,8 +52,8 @@ export async function deployProductionSlot(context: IActionContext, target?: vsc await deploy(context, target, undefined); } -export async function deployProductionSlotByFunctionAppId(context: IActionContext, functionAppId?: string | {}): Promise { - await deploy(context, undefined, functionAppId); +export async function deployProductionSlotByFunctionAppId(context: IActionContext, functionAppId?: string | {}, target?: vscode.Uri | string | SlotTreeItem): Promise { + await deploy(context, target, functionAppId); } export async function deploySlot(context: IActionContext, target?: vscode.Uri | string | SlotTreeItem, functionAppId?: string | {}): Promise { diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 787dad9b0..2688eb657 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -20,14 +20,14 @@ export function isMultiRootWorkspace(): boolean { * Use sparingly. Prefer storing and passing 'projectPaths' instead. * Over-reliance on this function may result in excessive prompting when a user employs a multi-root workspace. */ -export async function getRootWorkspaceFolder(): Promise { +export async function getRootWorkspaceFolder(context: IActionContext): Promise { if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { return undefined; } else if (vscode.workspace.workspaceFolders.length === 1) { return vscode.workspace.workspaceFolders[0]; } else { const placeHolder: string = localize('selectRootWorkspace', 'Select the folder containing your function project'); - const folder = await vscode.window.showWorkspaceFolderPick({ placeHolder }); + const folder = await context.ui.showWorkspaceFolderPick({ placeHolder }); if (!folder) { throw new UserCancelledError('selectRootWorkspace'); } diff --git a/test/constants.ts b/test/constants.ts new file mode 100644 index 000000000..a383f0d7e --- /dev/null +++ b/test/constants.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// #region create-new-project +// Project type picks +export const durableOrchestratorPick: RegExp = /Durable Functions Orch/i; // Sometimes this is "orchestrator" or "orchestration" depending on the template feed +export const durableAzureStoragePick: RegExp = /Azure Storage/i; + +// Default names +export const durableOrchestratorName: string = 'durableHello'; + +// Language picks +export const jsLanguagePick: RegExp = /JavaScript/i; + +// Framework picks +export const jsModelV4Pick: RegExp = /v4/i; + +// #endregion + +// ---------------------------- + +// #region create-function-app +// Default runtime picks +export const nodeRuntimePick: RegExp = /Node\.js(\s)?22/i; + +// Create resource picks +export const createNewResourceGroupPick: RegExp = /Create new resource group/i; +export const createNewStorageAccountPick: RegExp = /Create new storage account/i; +export const createNewAppInsightsPick: RegExp = /Create new application insights/i; +export const createNewAppServicePlanPick: RegExp = /Create new app service plan/i; +export const createNewUserAssignedIdentityPick: RegExp = /Create new user[- ]assigned identity/i; + +// Location picks +export const locationDefaultPick: RegExp = /West US 2/i; + +// #endregion diff --git a/test/global.test.ts b/test/global.test.ts index 304462e5d..c71dcad1e 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -3,9 +3,10 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { registerAppServiceExtensionVariables } from '@microsoft/vscode-azext-azureappservice'; +import { registerAzureUtilsExtensionVariables } from '@microsoft/vscode-azext-azureutils'; import { createTestActionContext, runWithTestActionContext, TestOutputChannel, TestUserInput } from '@microsoft/vscode-azext-dev'; -import { AzExtFsExtra } from '@microsoft/vscode-azext-utils'; -import * as assert from 'assert'; +import { AzExtFsExtra, registerUIExtensionVariables } from '@microsoft/vscode-azext-utils'; import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; @@ -56,6 +57,10 @@ suiteSetup(async function (this: Mocha.Context): Promise { ext.outputChannel = new TestOutputChannel(); + registerAppServiceExtensionVariables(ext); + registerAzureUtilsExtensionVariables(ext); + registerUIExtensionVariables(ext); + registerOnActionStartHandler(context => { // Use `TestUserInput` by default so we get an error if an unexpected call to `context.ui` occurs, rather than timing out context.ui = new TestUserInput(vscode); @@ -170,8 +175,8 @@ async function initTestWorkspaceFolders(): Promise { const folders: string[] = []; for (let i = 0; i < workspaceFolders.length; i++) { const workspacePath: string = workspaceFolders[i].uri.fsPath; - const folderName = path.basename(workspacePath); - assert.equal(folderName, String(i), `Unexpected workspace folder name "${folderName}".`); + // const folderName = path.basename(workspacePath); + // assert.equal(folderName, String(i), `Unexpected workspace folder name "${folderName}".`); await AzExtFsExtra.ensureDir(workspacePath); await AzExtFsExtra.emptyDir(workspacePath); folders.push(workspacePath); diff --git a/test/nightly/createProjectAndDeploy.test.ts b/test/nightly/createProjectAndDeploy.test.ts index 169677d80..dfddfef67 100644 --- a/test/nightly/createProjectAndDeploy.test.ts +++ b/test/nightly/createProjectAndDeploy.test.ts @@ -90,7 +90,7 @@ async function testCreateProjectAndDeploy(options: ICreateProjectAndDeployOption // TODO: investigate why our SDK calls are throwing errors when app name is over ~12 characters // https://github.com/microsoft/vscode-azurefunctions/issues/4368 const appName: string = 'f' + getRandomAlphanumericString(); - resourceGroupsToDelete.push(appName); + resourceGroupsToDelete.add(appName); await runWithTestActionContext('deploy', async context => { options.deployInputs = options.deployInputs || []; await context.ui.runWithInputs([testWorkspacePath, /create new function app/i, appName, getRotatingLocation(), ...options.deployInputs], async () => { diff --git a/test/nightly/functionAppOperations.test.ts b/test/nightly/functionAppOperations.test.ts index 49681d6c4..903308a56 100644 --- a/test/nightly/functionAppOperations.test.ts +++ b/test/nightly/functionAppOperations.test.ts @@ -33,7 +33,7 @@ suite('Function App Operations', function (this: Mocha.Suite): void { appName = getRandomHexString(); app2Name = getRandomHexString(); rgName = getRandomHexString(); - resourceGroupsToDelete.push(rgName); + resourceGroupsToDelete.add(rgName); saName = getRandomHexString().toLowerCase(); // storage account must have lower case name aiName = getRandomHexString(); location = getRotatingLocation(); diff --git a/test/nightly/global.nightly.test.ts b/test/nightly/global.nightly.test.ts index faef3621f..c1d0dcf6d 100644 --- a/test/nightly/global.nightly.test.ts +++ b/test/nightly/global.nightly.test.ts @@ -6,16 +6,18 @@ import { WebSiteManagementClient } from '@azure/arm-appservice'; import { ResourceManagementClient } from '@azure/arm-resources'; import { createTestActionContext } from '@microsoft/vscode-azext-dev'; -import { AzureAccountTreeItemWithProjects, createAzureClient, ext } from '../../extension.bundle'; +import { AzureAccountTreeItemWithProjects, createAzureClient, ext, updateGlobalSetting } from '../../extension.bundle'; import { longRunningTestsEnabled } from '../global.test'; import { createSubscriptionContext, subscriptionExperience, type ISubscriptionContext } from '@microsoft/vscode-azext-utils'; import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; +import { ScenariosTracker } from './scenarios/ScenariosTracker'; export let testClient: WebSiteManagementClient; export let subscriptionContext: ISubscriptionContext; -export const resourceGroupsToDelete: string[] = []; +export const resourceGroupsToDelete: Set = new Set(); +export const scenariosTracker = new ScenariosTracker(); // Runs before all nightly tests suiteSetup(async function (this: Mocha.Context): Promise { @@ -23,6 +25,8 @@ suiteSetup(async function (this: Mocha.Context): Promise { this.timeout(2 * 60 * 1000); await vscode.commands.executeCommand('azureResourceGroups.logIn'); + await updateGlobalSetting('groupBy', 'resourceType', 'azureResourceGroups'); + ext.azureAccountTreeItem = new AzureAccountTreeItemWithProjects(); const testContext = await createTestActionContext(); const subscription: AzureSubscription = await subscriptionExperience(testContext, ext.rgApi.appResourceTree); @@ -35,6 +39,7 @@ suiteSetup(async function (this: Mocha.Context): Promise { suiteTeardown(async function (this: Mocha.Context): Promise { if (longRunningTestsEnabled) { this.timeout(10 * 60 * 1000); + scenariosTracker.report(); await deleteResourceGroups(); ext.azureAccountTreeItem.dispose(); @@ -43,7 +48,7 @@ suiteTeardown(async function (this: Mocha.Context): Promise { async function deleteResourceGroups(): Promise { const rgClient: ResourceManagementClient = createAzureClient([await createTestActionContext(), subscriptionContext], ResourceManagementClient); - await Promise.all(resourceGroupsToDelete.map(async resourceGroup => { + await Promise.allSettled(Array.from(resourceGroupsToDelete).map(async resourceGroup => { if ((await rgClient.resourceGroups.checkExistence(resourceGroup)).body) { console.log(`Started delete of resource group "${resourceGroup}"...`); await rgClient.resourceGroups.beginDeleteAndWait(resourceGroup); diff --git a/test/nightly/scenarios/ScenariosTracker.ts b/test/nightly/scenarios/ScenariosTracker.ts new file mode 100644 index 000000000..02fe74e42 --- /dev/null +++ b/test/nightly/scenarios/ScenariosTracker.ts @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { nonNullProp, nonNullValue } from "@microsoft/vscode-azext-utils"; + +export class ScenariosTracker { + private scenarioStatuses: Map = new Map(); + + private getScenarioStatus(scenarioLabel: string): ScenarioStatus { + return nonNullValue(this.scenarioStatuses.get(scenarioLabel)); + } + + private getCreateAndDeployTest(scenarioLabel: string, id: number): CreateAndDeployTest { + const scenario = this.getScenarioStatus(scenarioLabel); + scenario.createAndDeployTests ??= []; + return nonNullValue(scenario.createAndDeployTests[id]); + } + + private updateTestStatus(testResult: TestResult, status: TestStatus, error?: string): void { + testResult.status = status; + if (error) { + testResult.error = error; + } + } + + initScenario(scenarioLabel: string): void { + if (this.scenarioStatuses.has(scenarioLabel)) { + return; + } + this.scenarioStatuses.set(scenarioLabel, { label: scenarioLabel }); + } + + startCreateNewProject(scenarioLabel: string, createNewProjectLabel: string): void { + const scenarioStatus = this.getScenarioStatus(scenarioLabel); + scenarioStatus.createNewProject = { label: createNewProjectLabel }; + } + + passCreateNewProject(scenarioLabel: string): void { + const scenarioStatus = this.getScenarioStatus(scenarioLabel); + const createNewProjectStatus = nonNullProp(scenarioStatus, 'createNewProject'); + this.updateTestStatus(createNewProjectStatus, 'pass'); + } + + failCreateNewProject(scenarioLabel: string, error: string): void { + const scenarioStatus = this.getScenarioStatus(scenarioLabel); + const createNewProjectStatus = nonNullProp(scenarioStatus, 'createNewProject'); + this.updateTestStatus(createNewProjectStatus, 'fail', error); + } + + initCreateAndDeployTest(scenarioLabel: string): number { + const scenarioStatus = this.getScenarioStatus(scenarioLabel); + scenarioStatus.createAndDeployTests ??= []; + + const id: number = scenarioStatus.createAndDeployTests.length; + scenarioStatus.createAndDeployTests.push({}); + return id; + } + + startCreateFunctionApp(scenarioLabel: string, createAndDeployTestId: number, createLabel: string): void { + const createAndDeployTest = this.getCreateAndDeployTest(scenarioLabel, createAndDeployTestId); + createAndDeployTest.createFunctionApp = { label: createLabel }; + } + + passCreateFunctionApp(scenarioLabel: string, createAndDeployTestId: number): void { + const createAndDeployTest = this.getCreateAndDeployTest(scenarioLabel, createAndDeployTestId); + const createFunctionAppTest = nonNullValue(createAndDeployTest.createFunctionApp); + this.updateTestStatus(createFunctionAppTest, 'pass'); + } + + failCreateFunctionApp(scenarioLabel: string, createAndDeployTestId: number, error: string): void { + const createAndDeployTest = this.getCreateAndDeployTest(scenarioLabel, createAndDeployTestId); + const createFunctionAppTest = nonNullValue(createAndDeployTest.createFunctionApp); + this.updateTestStatus(createFunctionAppTest, 'fail', error); + } + + startDeployFunctionApp(scenarioLabel: string, createAndDeployTestId: number, deployLabel: string): void { + const createAndDeployTest = this.getCreateAndDeployTest(scenarioLabel, createAndDeployTestId); + createAndDeployTest.deployFunctionApp = { label: deployLabel }; + } + + passDeployFunctionApp(scenarioLabel: string, createAndDeployTestId: number): void { + const createAndDeployTest = this.getCreateAndDeployTest(scenarioLabel, createAndDeployTestId); + const deployFunctionAppTest = nonNullValue(createAndDeployTest.deployFunctionApp); + this.updateTestStatus(deployFunctionAppTest, 'pass'); + } + + warnDeployFunctionApp(scenarioLabel: string, createAndDeployTestId: number, error?: string): void { + const createAndDeployTest = this.getCreateAndDeployTest(scenarioLabel, createAndDeployTestId); + const deployFunctionAppTest = nonNullValue(createAndDeployTest.deployFunctionApp); + this.updateTestStatus(deployFunctionAppTest, 'warn', error); + } + + failDeployFunctionApp(scenarioLabel: string, createAndDeployTestId: number, error: string): void { + const createAndDeployTest = this.getCreateAndDeployTest(scenarioLabel, createAndDeployTestId); + const deployFunctionAppTest = nonNullValue(createAndDeployTest.deployFunctionApp); + this.updateTestStatus(deployFunctionAppTest, 'fail', error); + } + + report(): void { + if (!this.scenarioStatuses.size) { + console.log('No test scenarios recorded.'); + return; + } + + const lines: string[] = []; + const icons: Record = { + pass: '✅', + warn: '⚠️', + fail: '❌', + undefined: '-', + }; + const maxErrorLength = 200; + + const getStatusIcon = (status: TestStatus | undefined): string => { + return icons[String(status)]; + }; + + const truncateError = (error: string | undefined): string => { + if (!error) { + return ''; + } + if (error.length <= maxErrorLength) { + return error; + } + return error.substring(0, maxErrorLength) + '...'; + }; + + lines.push('| # | Scenario | Test | Status | Error |'); + lines.push('|---|----------|------|--------|-------|'); + + let rowNum = 1; + for (const [, scenario] of this.scenarioStatuses) { + const scenarioLabel = scenario.label; + + // Report createNewProject test + if (scenario.createNewProject) { + const statusIcon = getStatusIcon(scenario.createNewProject.status); + const error = truncateError(scenario.createNewProject.error); + const errorCell = error ? ` | ${error} |` : ''; + lines.push(`| ${rowNum++} | ${scenarioLabel} | ${scenario.createNewProject.label} | ${statusIcon}${errorCell}`); + } + + // Report createAndDeployTests + if (scenario.createAndDeployTests) { + for (const test of scenario.createAndDeployTests) { + if (test.createFunctionApp) { + const statusIcon = getStatusIcon(test.createFunctionApp.status); + const error = truncateError(test.createFunctionApp.error); + const errorCell = error ? ` | ${error} |` : ''; + lines.push(`| ${rowNum++} | ${scenarioLabel} | ${test.createFunctionApp.label} | ${statusIcon}${errorCell}`); + } + if (test.deployFunctionApp) { + const statusIcon = getStatusIcon(test.deployFunctionApp.status); + const error = truncateError(test.deployFunctionApp.error); + const errorCell = error ? ` | ${error} |` : ''; + lines.push(`| ${rowNum++} | ${scenarioLabel} | ${test.deployFunctionApp.label} | ${statusIcon}${errorCell}`); + } + } + } + } + + const reportCard: string = lines.join('\n'); + console.log(reportCard); + } +} + +type TestStatus = 'pass' | 'warn' | 'fail'; + +type TestResult = { + label: string; + status?: TestStatus; + error?: string; +}; + +type ScenarioStatus = { + label: string; + createNewProject?: TestResult; + createAndDeployTests?: CreateAndDeployTest[]; +} + +type CreateAndDeployTest = { + createFunctionApp?: TestResult; + deployFunctionApp?: TestResult; +}; diff --git a/test/nightly/scenarios/parallelScenarios.ts b/test/nightly/scenarios/parallelScenarios.ts new file mode 100644 index 000000000..1300c957a --- /dev/null +++ b/test/nightly/scenarios/parallelScenarios.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { runWithTestActionContext } from '@microsoft/vscode-azext-dev'; +import { AzExtFsExtra, parseError } from '@microsoft/vscode-azext-utils'; +import * as assert from 'assert'; +import { workspace, type Uri, type WorkspaceFolder } from 'vscode'; +import { createFunctionApp, createFunctionAppAdvanced, createNewProjectInternal, deployProductionSlotByFunctionAppId } from '../../../extension.bundle'; +import { CreateMode } from '../../utils/createFunctionAppUtils'; +import { resourceGroupsToDelete, scenariosTracker } from '../global.nightly.test'; +import { type AzExtFunctionsTestScenario, type CreateAndDeployTestCase } from './testScenarios/AzExtFunctionsTestScenario'; +import { generateTestScenarios } from './testScenarios/testScenarios'; + +/** + * A wrapper for {@link AzExtFunctionsTestScenario}. Prepares a scenario for concurrent test execution. + */ +export interface AzExtFunctionsParallelTestScenario { + /** + * A descriptive title for the test scenario that will logged in the final test report. + */ + title: string; + + /** + * Holds a reference to the promise representing the running scenario. This is used so that all scenarios can be concurrently awaited. + */ + scenario?: Promise; + + /** + * Starts the concurrent test scenario based on the specified test level. + * @param testLevel - Specifies which level of tests to run: 'basic' vs. 'extended' + */ + runScenario(testLevel: TestLevel): Promise; + + /** + * Indicates this scenario should be executed exclusively. This should only be used to aid with local development. + */ + only?: boolean; +} + +export function generateParallelScenarios(): AzExtFunctionsParallelTestScenario[] { + return generateTestScenarios().map(scenario => { + return { + title: scenario.label, + runScenario: generateRunScenario(scenario), + only: scenario.only, + }; + }); +} + +function generateRunScenario(scenario: AzExtFunctionsTestScenario): AzExtFunctionsParallelTestScenario['runScenario'] { + return async function runScenario(testLevel: TestLevel) { + const workspaceFolderUri: Uri = getWorkspaceFolderUri(scenario.folderName); + const rootFolder = workspace.getWorkspaceFolder(workspaceFolderUri); + assert.ok(rootFolder, `Failed to retrieve root workspace folder for scenario ${scenario.label}.`); + + await cleanTestFolder(rootFolder); + + // 1. Create shared workspace project + scenariosTracker.initScenario(scenario.label); + scenariosTracker.startCreateNewProject(scenario.label, scenario.createNewProjectTest.label); + + await runWithTestActionContext('scenario.createNewProject', async context => { + await context.ui.runWithInputs(scenario.createNewProjectTest.inputs, async () => { + try { + await createNewProjectInternal(context, { folderPath: rootFolder.uri.fsPath }); + await scenario.createNewProjectTest.postTest?.(context); + scenariosTracker.passCreateNewProject(scenario.label); + } catch (err) { + scenariosTracker.failCreateNewProject(scenario.label, (err as Error).message ?? parseError(err).message); + throw err; + } + }); + }); + + // 2. Start all create and deploy tests for the scenario + const createAndDeployTests: CreateAndDeployTestCase[] = testLevel === TestLevel.Basic ? scenario.createAndDeployTests.basic : scenario.createAndDeployTests.extended ?? []; + const onlyTestCase: CreateAndDeployTestCase | undefined = createAndDeployTests.find(test => test.only); + + if (onlyTestCase) { + await startCreateAndDeployTest(scenario.label, rootFolder, onlyTestCase); + } else { + const createAndDeployTasks: Promise[] = createAndDeployTests.map(test => startCreateAndDeployTest(scenario.label, rootFolder, test)); + await Promise.allSettled(createAndDeployTasks); + } + + await cleanTestFolder(rootFolder); + } +} + +async function startCreateAndDeployTest(scenarioLabel: string, rootFolder: WorkspaceFolder, test: CreateAndDeployTestCase): Promise { + const testId: number = scenariosTracker.initCreateAndDeployTest(scenarioLabel); + + for (const rg of test.resourceGroupsToDelete ?? []) { + resourceGroupsToDelete.add(rg); + } + + // 3. Create function app + scenariosTracker.startCreateFunctionApp(scenarioLabel, testId, test.createFunctionApp.label); + + let functionAppId: string; + await runWithTestActionContext('scenario.createFunctionApp', async context => { + await context.ui.runWithInputs(test.createFunctionApp.inputs, async () => { + try { + if (test.createFunctionApp.mode === CreateMode.Basic) { + functionAppId = await createFunctionApp(context); + } else { + functionAppId = await createFunctionAppAdvanced(context); + } + + assert.ok(functionAppId, 'Failed to create function app.'); + scenariosTracker.passCreateFunctionApp(scenarioLabel, testId); + } catch (err) { + scenariosTracker.failCreateFunctionApp(scenarioLabel, testId, (err as Error).message ?? parseError(err).message); + throw err; + } + }); + }); + + // 4. Deploy function app + scenariosTracker.startDeployFunctionApp(scenarioLabel, testId, test.deployFunctionApp.label); + + await runWithTestActionContext('scenario.deploy', async context => { + await context.ui.runWithInputs(test.deployFunctionApp.inputs, async () => { + let deployError: unknown; + try { + await deployProductionSlotByFunctionAppId(context, functionAppId, rootFolder.uri); + } catch (err) { + deployError = err; + } + + let postTestError: unknown; + if (test.deployFunctionApp.postTest) { + try { + await test.deployFunctionApp.postTest(context, functionAppId); + } catch (err) { + postTestError = err; + } + } + + const error = deployError ?? postTestError; + if (!error) { + scenariosTracker.passDeployFunctionApp(scenarioLabel, testId); + return; + } + + const errorMessage: string = (error as Error).message ?? parseError(error).message; + + // Warning marker indicates deployment failed but verification still passed. + // Example of a known issue where this happens: https://github.com/microsoft/vscode-azurefunctions/issues/4859 + if (deployError && !postTestError) { + scenariosTracker.warnDeployFunctionApp(scenarioLabel, testId, errorMessage); + } else { + scenariosTracker.failDeployFunctionApp(scenarioLabel, testId, errorMessage); + } + + throw error; + }); + }); +} + +async function cleanTestFolder(testFolder: WorkspaceFolder) { + await AzExtFsExtra.emptyDir(testFolder.uri.fsPath); +} + +export function getWorkspaceFolderUri(folderName: string): Uri { + const workspaceFolders: readonly WorkspaceFolder[] | undefined = workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + throw new Error('No workspace folder available.'); + } else { + for (const workspaceFolder of workspaceFolders) { + if (workspaceFolder.name === folderName) { + return workspaceFolder.uri; + } + } + } + + throw new Error(`Unable to find workspace folder "${folderName}"`); +} + +export enum TestLevel { + Basic = 'basic', + Extended = 'extended', +} diff --git a/test/nightly/scenarios/scenarios.test.ts b/test/nightly/scenarios/scenarios.test.ts new file mode 100644 index 000000000..ce0f5a427 --- /dev/null +++ b/test/nightly/scenarios/scenarios.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { longRunningTestsEnabled } from '../../global.test'; +import { generateParallelScenarios, TestLevel, type AzExtFunctionsParallelTestScenario } from './parallelScenarios'; + +const testScenarios: AzExtFunctionsParallelTestScenario[] = generateParallelScenarios(); + +suite.only('Scenarios', async function (this: Mocha.Suite) { + this.timeout(40 * 60 * 1000); + + suiteSetup(async function (this: Mocha.Context) { + if (!longRunningTestsEnabled) { + this.skip(); + } + + // For quickly specifying tests to isolate: + // - Set the `AzCode_RunScenarioExtended` env var to match the scenario label you want to isolate. This will automatically run with the extended test suite. TODO: Enable setting this for manual runs in the remote pipelines. + // - Setting `only: true` on a scenario definition also allows more quickly isolating a run during local development. Note this naturally defaults to a test level of "basic". + // + // When not running isolated tests, test level will always default to "basic" to minimize the number of service requests to avoid running into 429 ratelimit errors. + const onlyTestScenario = testScenarios.find(s => { + if (process.env.AzCode_RunScenarioExtended) { + return s.title === process.env.AzCode_RunScenarioExtended; + } + return s.only; + }); + const testLevel: TestLevel = process.env.AzCode_RunScenarioExtended ? TestLevel.Extended : TestLevel.Basic; + + if (onlyTestScenario) { + onlyTestScenario.scenario = onlyTestScenario.runScenario(testLevel); + } else { + for (const s of testScenarios) { + s.scenario = s.runScenario(TestLevel.Basic); + } + } + }); + + for (const s of testScenarios) { + test(s.title, async function (this: Mocha.Context) { + if (!s.scenario) { + this.skip(); + } + await s.scenario; + }); + } +}); diff --git a/test/nightly/scenarios/testScenarios/AzExtFunctionsTestScenario.ts b/test/nightly/scenarios/testScenarios/AzExtFunctionsTestScenario.ts new file mode 100644 index 000000000..12de7a9f7 --- /dev/null +++ b/test/nightly/scenarios/testScenarios/AzExtFunctionsTestScenario.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from "../../../../extension.bundle"; +import { type CreateMode } from "../../../utils/createFunctionAppUtils"; + +/** + * Defines a test scenario for Azure Functions extension testing. + * + * Each scenario follows this execution flow: + * 1. A single workspace project is created first (`createNewProject`) + * 2. Multiple create-and-deploy test cases branch off from the shared project + * 3. Within each test case, function app creation and deployment execute in series + * + * ``` + * Test Scenario + * │ + * ┌────────┴────────┐ + * │ Create New │ + * │ Project │ + * └────────┬────────┘ + * ┌───────┬───────┼───────┬───────┐ (concurrent) + * ▼ ▼ ▼ ▼ ▼ + * ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ + * │Create│ │Create│ │Create│ │Create│ │Create│ (in series) + * │ Func │ │ Func │ │ Func │ │ Func │ │ Func │ + * │ App │ │ App │ │ App │ │ App │ │ App │ + * └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ + * ▼ ▼ ▼ ▼ ▼ + * ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ + * │Deploy│ │Deploy│ │Deploy│ │Deploy│ │Deploy│ + * │ Func │ │ Func │ │ Func │ │ Func │ │ Func │ + * │ App │ │ App │ │ App │ │ App │ │ App │ + * └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ + * ``` + */ +export interface AzExtFunctionsTestScenario { + /** + * A descriptive label for the test scenario. + */ + label: string; + + /** + * The name of the folder where the test project will be created. + * Should match the name provided under `test.code-workspace` + */ + folderName: string; + + /** + * The test case for creating a new workspace project. + * This test will always execute before creating and deploying to a function app. + */ + createNewProjectTest: CreateNewProjectTestCase; + + /** + * Test cases for creating and deploying function apps. + * These tests execute after project creation completes successfully. + */ + createAndDeployTests: { + /** + * Basic test cases representing high-value smoke/regression tests that run as part of the + * nightly test suite. + * + * Note: Keep this set small (~1-2) and focused on representative samples that we expect to pass. + */ + basic: CreateAndDeployTestCase[]; + + /** + * Extended number of test cases providing much deeper inspection of the scenario deployment health. Intended to be + * triggered manually for on-demand, deep-dive inspection rather than nightly runs + * due to their more comprehensive nature. + * + * Note: Initially this may be a more exploratory set of tests whose outcome will eventually need to be more explicitly confirmed by Function App PMs. + * Upon final determination, we should pare this down to only those tests that we expect to pass. + */ + extended?: CreateAndDeployTestCase[]; + }; + + /** + * Indicates this scenario should be executed exclusively. This should only be used to aid with local development. + */ + only?: boolean; +} + +export interface CreateNewProjectTestCase { + /** + * A descriptive label for the test case. + */ + label: string; + + /** + * The sequence of inputs to provide during workspace project creation. + */ + inputs: (string | RegExp)[]; + + /** + * An optional callback to run after the deployment test completes. + * Great for use as a post test verification step. + */ + postTest?: (context: IActionContext) => void | Promise; + + /** + * Indicates this test case should be executed exclusively. This should only be used to aid with local development. + */ + only?: boolean; +} + +export interface CreateAndDeployTestCase { + /** + * Configuration for creating a function app. + * This step executes first before deployment. + */ + createFunctionApp: { + /** + * A descriptive label for the create function app test. + */ + label: string; + + /** + * The mode used to create the function app (e.g., basic, advanced). + */ + mode: CreateMode; + + /** + * The sequence of inputs to provide during function app creation. + */ + inputs: (string | RegExp)[]; + }; + + /** + * Configuration for deploying a function app. + * This step executes in series after the workspace project and function app have been created. + */ + deployFunctionApp: { + /** + * A descriptive label for the deploy function app test. + */ + label: string; + + /** + * The sequence of inputs to provide during function app deployment. + */ + inputs: (string | RegExp)[]; + + /** + * An optional callback to run after the deployment test completes. + * Note: Highly recommend implementing this as a verification post step as deployment may succeed initially, but fail at runtime. + */ + postTest?: (context: IActionContext, functionAppId: string) => void | Promise; + }; + + /** + * Resource groups to delete after the test completes for cleanup. + */ + resourceGroupsToDelete?: string[]; + + /** + * Indicates this test case should be executed exclusively. This should only be used to aid with local development. + */ + only?: boolean; +} diff --git a/test/nightly/scenarios/testScenarios/durable/azureStorage/azureStorageJSNodeScenario.ts b/test/nightly/scenarios/testScenarios/durable/azureStorage/azureStorageJSNodeScenario.ts new file mode 100644 index 000000000..6ad8c3b47 --- /dev/null +++ b/test/nightly/scenarios/testScenarios/durable/azureStorage/azureStorageJSNodeScenario.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzExtFsExtra } from "@microsoft/vscode-azext-utils"; +import * as assert from "assert"; +import * as path from "path"; +import { DurableBackend, DurableProjectConfigureStep, hostFileName, type IActionContext, type IHostJsonV2 } from "../../../../../../extension.bundle"; +import { durableAzureStoragePick, durableOrchestratorName, durableOrchestratorPick, jsLanguagePick, jsModelV4Pick } from "../../../../../constants"; +import { ConnectionType, CreateMode, OperatingSystem, PlanType, Runtime } from "../../../../../utils/createFunctionAppUtils"; +import { type AzExtFunctionsTestScenario } from "../../AzExtFunctionsTestScenario"; +import { generateCreateAndDeployTest } from "../generateCreateAndDeployTest"; + +export function generateJSNodeScenario(): AzExtFunctionsTestScenario { + const folderName: string = 'scenario-durable-azurestorage-jsnode'; + + return { + label: 'durable-azurestorage-jsnode', + folderName, + createNewProjectTest: { + label: 'create-new-project', + inputs: [ + jsLanguagePick, + jsModelV4Pick, + durableOrchestratorPick, + durableAzureStoragePick, + durableOrchestratorName, + ], + postTest: verifyCreateNewProject, + }, + createAndDeployTests: { + basic: [ + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.LegacyConsumption, OperatingSystem.Linux), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.AppService, OperatingSystem.Windows), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.Premium, OperatingSystem.Linux), + ], + extended: [ + // Placeholder... + // Todo: Will need future discussion & final approval + generateCreateAndDeployTest(folderName, CreateMode.Basic, Runtime.Node, ConnectionType.ManagedIdentity, PlanType.FlexConsumption, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.ManagedIdentity, PlanType.Premium, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.ManagedIdentity, PlanType.Premium, OperatingSystem.Windows, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.ManagedIdentity, PlanType.LegacyConsumption, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.ManagedIdentity, PlanType.LegacyConsumption, OperatingSystem.Windows, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.ManagedIdentity, PlanType.AppService, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.ManagedIdentity, PlanType.AppService, OperatingSystem.Windows, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.FlexConsumption, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.Premium, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.Premium, OperatingSystem.Windows, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.LegacyConsumption, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.LegacyConsumption, OperatingSystem.Windows, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.AppService, OperatingSystem.Linux, DurableBackend.Storage), + generateCreateAndDeployTest(folderName, CreateMode.Advanced, Runtime.Node, ConnectionType.Secrets, PlanType.AppService, OperatingSystem.Windows, DurableBackend.Storage), + ], + } + }; +} + +async function verifyCreateNewProject(context: IActionContext & { projectPath?: string }): Promise { + if (!context.projectPath) { + throw new Error('Internal Error: Test context is missing the requisite project path.'); + } + + const hostJsonPath: string = path.join(context.projectPath, hostFileName); + const hostJson: IHostJsonV2 = await AzExtFsExtra.readJSON(hostJsonPath) as IHostJsonV2; + hostJson.extensions ??= {}; + + assert.deepStrictEqual(hostJson.extensions.durableTask, DurableProjectConfigureStep.getDefaultStorageTaskConfig(), ''); +} diff --git a/test/nightly/scenarios/testScenarios/durable/azureStorage/azureStorageScenarios.ts b/test/nightly/scenarios/testScenarios/durable/azureStorage/azureStorageScenarios.ts new file mode 100644 index 000000000..cec6ee775 --- /dev/null +++ b/test/nightly/scenarios/testScenarios/durable/azureStorage/azureStorageScenarios.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzExtFunctionsTestScenario } from '../../AzExtFunctionsTestScenario'; +import { generateJSNodeScenario } from './azureStorageJSNodeScenario'; + +export function generateDurableAzureStorageScenarios(): AzExtFunctionsTestScenario[] { + return [ + generateJSNodeScenario(), + // generatePythonScenario(), + // generateDotNetIsolatedScenario(), + ]; +} diff --git a/test/nightly/scenarios/testScenarios/durable/generateCreateAndDeployTest.ts b/test/nightly/scenarios/testScenarios/durable/generateCreateAndDeployTest.ts new file mode 100644 index 000000000..6cdc3b08a --- /dev/null +++ b/test/nightly/scenarios/testScenarios/durable/generateCreateAndDeployTest.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Site, type WebSiteManagementClient } from "@azure/arm-appservice"; +import { createWebSiteClient } from "@microsoft/vscode-azext-azureappservice"; +import { parseAzureResourceId, type ParsedAzureResourceId } from "@microsoft/vscode-azext-azureutils"; +import { getRandomAlphanumericString, type DurableBackend, type IActionContext } from "../../../../../extension.bundle"; +import { durableOrchestratorName } from "../../../../constants"; +import { createFunctionAppUtils, CreateMode, Runtime, type ConnectionType, type OperatingSystem, type PlanType } from "../../../../utils/createFunctionAppUtils"; +import { deployFunctionAppUtils } from "../../../../utils/deployFunctionAppUtils"; +import { subscriptionContext } from "../../../global.nightly.test"; +import { type CreateAndDeployTestCase } from "../AzExtFunctionsTestScenario"; + +export function generateCreateAndDeployTest(folderName: string, createMode: CreateMode, runtime: Runtime, storageConnection: ConnectionType, plan: PlanType, os?: OperatingSystem, storageType?: DurableBackend): CreateAndDeployTestCase { + const appName: string = getRandomAlphanumericString(); + const osDescription: string = os ? `-${os}` : ''; + const description: string = `${createMode}-${storageConnection}${osDescription}-${plan}`; + + return { + createFunctionApp: { + label: `create-function-app | ${description}`, + mode: createMode, + inputs: createMode === CreateMode.Basic ? + createFunctionAppUtils.generateBasicCreateInputs(appName, folderName, runtime, storageConnection) : + createFunctionAppUtils.generateAdvancedCreateInputs(appName, folderName, runtime, storageConnection, plan, os), + }, + deployFunctionApp: { + label: `deploy-function-app | ${description}`, + inputs: deployFunctionAppUtils.generateDurableDeployInputs(appName, storageType), + postTest: generateVerifyDeployment(runtime), + }, + resourceGroupsToDelete: [appName], + }; +} + +function generateVerifyDeployment(runtime: Runtime) { + return async function verifyDeployment(context: IActionContext, functionAppId: string): Promise { + const client: WebSiteManagementClient = await createWebSiteClient({ ...context, ...subscriptionContext }); + const parsedResource: ParsedAzureResourceId = parseAzureResourceId(functionAppId); + const functionApp: Site = await client.webApps.get(parsedResource.resourceGroup, parsedResource.resourceName); + + let url = `https://${functionApp.defaultHostName}`; + switch (runtime) { + // case Runtime.Python: + case Runtime.Node: + url += `/api/orchestrators/${durableOrchestratorName}Orchestrator`; + break; + // case Runtime.DotNetIsolated: + default: + throw new Error('Durable verify deployment not yet implemented for this runtime type.'); + } + + const response = await fetch(url); + if (response.status !== 202) { + throw new Error(`Verify Deployment: Orchestrator endpoint responded with ${response.status}.`); + } + }; +} diff --git a/test/nightly/scenarios/testScenarios/testScenarios.ts b/test/nightly/scenarios/testScenarios/testScenarios.ts new file mode 100644 index 000000000..c1c5de737 --- /dev/null +++ b/test/nightly/scenarios/testScenarios/testScenarios.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzExtFunctionsTestScenario } from "./AzExtFunctionsTestScenario"; +import { generateDurableAzureStorageScenarios } from "./durable/azureStorage/azureStorageScenarios"; + +export function generateTestScenarios(): AzExtFunctionsTestScenario[] { + const testScenarios: AzExtFunctionsTestScenario[] = [ + // Trigger scenarios + // ...generateHttpTriggerScenarios(), + + // Durable scenarios + ...generateDurableAzureStorageScenarios(), + ]; + return testScenarios; +} diff --git a/test/test.code-workspace b/test/test.code-workspace index 26a370e25..886ebd6c2 100644 --- a/test/test.code-workspace +++ b/test/test.code-workspace @@ -29,7 +29,11 @@ }, { "path": "../testWorkspace/9" - } + }, + { + "name": "scenario-durable-azurestorage-jsnode", + "path": "../testWorkspace/scenarios/durable-azurestorage/jsnode" + }, ], "settings": { "debug.internalConsoleOptions": "neverOpen", diff --git a/test/utils/createFunctionAppUtils.ts b/test/utils/createFunctionAppUtils.ts new file mode 100644 index 000000000..a4306f44c --- /dev/null +++ b/test/utils/createFunctionAppUtils.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createNewAppInsightsPick, createNewAppServicePlanPick, createNewResourceGroupPick, createNewStorageAccountPick, createNewUserAssignedIdentityPick, locationDefaultPick, nodeRuntimePick } from "../constants"; + +export namespace createFunctionAppUtils { + export function generateBasicCreateInputs(appName: string, folderName: string, runtime: Runtime, storageConnection: ConnectionType): (string | RegExp)[] { + return [ + folderName, + appName, + locationDefaultPick, + getRuntimePick(runtime), + new RegExp(storageConnection, 'i'), + ]; + } + + export function generateAdvancedCreateInputs(appName: string, folderName: string, runtime: Runtime, storageConnection: ConnectionType, plan: PlanType, os?: OperatingSystem): (string | RegExp)[] { + switch (plan) { + case PlanType.FlexConsumption: + return [ + folderName, + appName, + new RegExp(plan, 'i'), + locationDefaultPick, + getRuntimePick(runtime), + '2048', + '100', + createNewResourceGroupPick, + appName, + ...getConnectionTypeInputs(storageConnection), + createNewStorageAccountPick, + appName, + createNewAppInsightsPick, + appName, + ]; + case PlanType.Premium: + return [ + folderName, + appName, + new RegExp(plan, 'i'), + locationDefaultPick, + getRuntimePick(runtime), + ...(os ? [new RegExp(os, 'i')] : []), + createNewAppServicePlanPick, + appName, + /EP1/i, + createNewResourceGroupPick, + appName, + ...getConnectionTypeInputs(storageConnection), + createNewStorageAccountPick, + appName, + createNewAppInsightsPick, + appName, + ]; + case PlanType.LegacyConsumption: + return [ + folderName, + appName, + new RegExp(plan, 'i'), + locationDefaultPick, + getRuntimePick(runtime), + ...(os ? [new RegExp(os, 'i')] : []), + createNewResourceGroupPick, + appName, + ...getConnectionTypeInputs(storageConnection), + createNewStorageAccountPick, + appName, + createNewAppInsightsPick, + appName, + ]; + case PlanType.AppService: + return [ + folderName, + appName, + new RegExp(plan, 'i'), + locationDefaultPick, + getRuntimePick(runtime), + ...(os ? [new RegExp(os, 'i')] : []), + createNewAppServicePlanPick, + appName, + /S1/i, + createNewResourceGroupPick, + appName, + ...getConnectionTypeInputs(storageConnection), + createNewStorageAccountPick, + appName, + createNewAppInsightsPick, + appName, + ]; + } + } + + function getRuntimePick(runtime: Runtime): RegExp | string { + switch (runtime) { + // case Runtime.Python: + case Runtime.Node: + return nodeRuntimePick; + // case Runtime.DotNetIsolated: + // case Runtime.DotNetInProc: + default: + throw new Error(`Runtime "${runtime}" not yet supported in "createFunctionAppUtils.generateBasicCreateInputs".`); + } + } + + function getConnectionTypeInputs(connection: ConnectionType): (string | RegExp)[] { + return connection === ConnectionType.ManagedIdentity ? + [new RegExp(connection, 'i'), createNewUserAssignedIdentityPick] : + [new RegExp(connection, 'i')]; + } +} + +export enum ConnectionType { + Secrets = 'Secrets', + ManagedIdentity = 'Managed Identity', +} + +export enum OperatingSystem { + Linux = 'Linux', + Windows = 'Windows', +} + +export enum PlanType { + FlexConsumption = 'Flex Consumption', + Premium = 'Premium', + LegacyConsumption = 'Legacy', + AppService = 'App Service Plan', +} + +export enum CreateMode { + Basic = 'Basic', + Advanced = 'Advanced', +} + +export enum Runtime { + Node = 'Node', + Python = 'Python', + DotNetIsolated = '.NET Isolated', + DotNetInProc = '.NET In-Proc' +} diff --git a/test/utils/deployFunctionAppUtils.ts b/test/utils/deployFunctionAppUtils.ts new file mode 100644 index 000000000..0010b7d3c --- /dev/null +++ b/test/utils/deployFunctionAppUtils.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DurableBackend } from "../../extension.bundle"; + +export namespace deployFunctionAppUtils { + export function generateDurableDeployInputs(_appName: string, storageType?: DurableBackend): (string | RegExp)[] { + switch (storageType) { + // case DurableBackend.DTS: + // case DurableBackend.Netherite: + // case DurableBackend.SQL: + case DurableBackend.Storage: + default: + return [ + 'Deploy', + ]; + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 02d9a91c8..4b9d94ad0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "node_modules", ".vscode-test", - "gulpfile.ts" + "gulpfile.ts", + "testWorkspace" ] }