From 85a46ceab411334214379af35688e6300027679e Mon Sep 17 00:00:00 2001 From: vicheey Date: Wed, 20 Nov 2024 12:04:14 -0500 Subject: [PATCH] refactor(lambda): decompose methods for SAM sync/deploy/build (#6014) ## Problem The `sync.ts` are huge consisting of many helper functions that being reused for deploy and build. ## Solution - split shared methods into separate files for easier testing and maintenance - split sync.test.ts file into smaller file - consolidate `paramsSourcePrompter` for both sync and deploy to avoid duplicate code - update PrompterTester helper class to clean up VS Code UI element for early exit from consumer callback - rename paramsSource prompter title --- License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: Roger Zhang --- .../appBuilder/explorer/openTemplate.ts | 6 +- packages/core/src/shared/sam/build.ts | 11 +- packages/core/src/shared/sam/deploy.ts | 162 +- packages/core/src/shared/sam/sync.ts | 268 +-- packages/core/src/shared/sam/utils.ts | 29 +- .../core/src/shared/ui/sam/bucketPrompter.ts | 83 + .../core/src/shared/ui/sam/ecrPrompter.ts | 55 + .../src/shared/ui/sam/paramsSourcePrompter.ts | 68 + .../core/src/shared/ui/sam/stackPrompter.ts | 65 + .../src/shared/ui/sam/templatePrompter.ts | 73 + .../wizards/deployTypeWizard.test.ts | 4 +- .../core/src/test/shared/sam/build.test.ts | 589 +++++-- .../core/src/test/shared/sam/deploy.test.ts | 45 +- .../core/src/test/shared/sam/sync.test.ts | 1464 +++++++---------- .../core/src/test/shared/sam/utils.test.ts | 125 +- .../shared/ui/prompters/regionSubmenu.test.ts | 2 +- .../test/shared/ui/sam/bucketPrompter.test.ts | 78 + .../test/shared/ui/sam/ecrPrompter.test.ts | 127 ++ .../ui/sam/paramsSourcePrompter.test.ts | 100 ++ .../test/shared/ui/sam/stackPrompter.test.ts | 132 ++ .../shared/ui/sam/templatePrompter.test.ts | 52 + .../src/test/shared/wizards/prompterTester.ts | 48 +- packages/core/src/testInteg/sam.test.ts | 2 +- ...-6d959757-2d07-40a7-af18-f1b52473ec2d.json | 4 + ...-a60cf459-c957-4a38-9d77-2fc512fbac4a.json | 4 + 25 files changed, 2215 insertions(+), 1381 deletions(-) create mode 100644 packages/core/src/shared/ui/sam/bucketPrompter.ts create mode 100644 packages/core/src/shared/ui/sam/ecrPrompter.ts create mode 100644 packages/core/src/shared/ui/sam/paramsSourcePrompter.ts create mode 100644 packages/core/src/shared/ui/sam/stackPrompter.ts create mode 100644 packages/core/src/shared/ui/sam/templatePrompter.ts create mode 100644 packages/core/src/test/shared/ui/sam/bucketPrompter.test.ts create mode 100644 packages/core/src/test/shared/ui/sam/ecrPrompter.test.ts create mode 100644 packages/core/src/test/shared/ui/sam/paramsSourcePrompter.test.ts create mode 100644 packages/core/src/test/shared/ui/sam/stackPrompter.test.ts create mode 100644 packages/core/src/test/shared/ui/sam/templatePrompter.test.ts create mode 100644 packages/toolkit/.changes/next-release/Bug Fix-6d959757-2d07-40a7-af18-f1b52473ec2d.json create mode 100644 packages/toolkit/.changes/next-release/Bug Fix-a60cf459-c957-4a38-9d77-2fc512fbac4a.json diff --git a/packages/core/src/awsService/appBuilder/explorer/openTemplate.ts b/packages/core/src/awsService/appBuilder/explorer/openTemplate.ts index 686340719e3..4bc9b5e5b0c 100644 --- a/packages/core/src/awsService/appBuilder/explorer/openTemplate.ts +++ b/packages/core/src/awsService/appBuilder/explorer/openTemplate.ts @@ -4,8 +4,10 @@ */ import { CloudFormationTemplateRegistry } from '../../../shared/fs/templateRegistry' -import { createTemplatePrompter, TemplateItem } from '../../../shared/sam/sync' +import { syncMementoRootKey } from '../../../shared/sam/sync' + import { createExitPrompter } from '../../../shared/ui/common/exitPrompter' +import { createTemplatePrompter, TemplateItem } from '../../../shared/ui/sam/templatePrompter' import { Wizard } from '../../../shared/wizards/wizard' export interface OpenTemplateParams { @@ -15,6 +17,6 @@ export interface OpenTemplateParams { export class OpenTemplateWizard extends Wizard { public constructor(state: Partial, registry: CloudFormationTemplateRegistry) { super({ initState: state, exitPrompterProvider: createExitPrompter }) - this.form.template.bindPrompter(() => createTemplatePrompter(registry)) + this.form.template.bindPrompter(() => createTemplatePrompter(registry, syncMementoRootKey)) } } diff --git a/packages/core/src/shared/sam/build.ts b/packages/core/src/shared/sam/build.ts index ee75912c5cd..ad2bfabbf3c 100644 --- a/packages/core/src/shared/sam/build.ts +++ b/packages/core/src/shared/sam/build.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' -import { TemplateItem, createTemplatePrompter } from './sync' +import { TemplateItem, createTemplatePrompter } from '../ui/sam/templatePrompter' import { ChildProcess } from '../utilities/processUtils' import { addTelemetryEnvVar } from './cli/samCliInvokerUtils' import { Wizard } from '../wizards/wizard' @@ -19,10 +19,11 @@ import globals from '../extensionGlobals' import { TreeNode } from '../treeview/resourceTreeDataProvider' import { telemetry } from '../telemetry/telemetry' import { getSpawnEnv } from '../env/resolveEnv' -import { getErrorCode, getProjectRoot, getSamCliPathAndVersion, isDotnetRuntime } from './utils' +import { getErrorCode, getProjectRoot, getSamCliPathAndVersion, isDotnetRuntime, updateRecentResponse } from './utils' import { getConfigFileUri, validateSamBuildConfig } from './config' import { runInTerminal } from './processTerminal' +const buildMementoRootKey = 'samcli.build.params' export interface BuildParams { readonly template: TemplateItem readonly projectRoot: vscode.Uri @@ -58,7 +59,7 @@ export function createParamsSourcePrompter(existValidSamconfig: boolean) { ) return createQuickPick(items, { - title: 'Specify parameters for build', + title: 'Specify parameter source for build', placeholder: 'Select configuration options for sam build', buttons: createCommonButtons(samBuildUrl), }) @@ -128,7 +129,7 @@ export class BuildWizard extends Wizard { this.arg = arg if (this.arg === undefined) { // "Build" command was invoked on the command palette. - this.form.template.bindPrompter(() => createTemplatePrompter(this.registry)) + this.form.template.bindPrompter(() => createTemplatePrompter(this.registry, buildMementoRootKey)) this.form.projectRoot.setDefault(({ template }) => getProjectRoot(template)) this.form.paramsSource.bindPrompter(async ({ projectRoot }) => { const existValidSamConfig: boolean | undefined = await validateSamBuildConfig(projectRoot) @@ -216,6 +217,8 @@ export async function runBuild(arg?: TreeNode): Promise { const templatePath = params.template.uri.fsPath buildFlags.push('--template', `${templatePath}`) + await updateRecentResponse(buildMementoRootKey, 'global', 'templatePath', templatePath) + try { const { path: samCliPath } = await getSamCliPathAndVersion() diff --git a/packages/core/src/shared/sam/deploy.ts b/packages/core/src/shared/sam/deploy.ts index 8821faee3af..f36f125a460 100644 --- a/packages/core/src/shared/sam/deploy.ts +++ b/packages/core/src/shared/sam/deploy.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' -import { ToolkitError, getLogger, globals } from '../../shared' +import { ToolkitError, globals } from '../../shared' import * as CloudFormation from '../../shared/cloudformation/cloudformation' import { getParameters } from '../../lambda/config/parameterUtils' import { DefaultCloudFormationClient } from '../clients/cloudFormationClient' @@ -17,14 +17,23 @@ import { createCommonButtons } from '../ui/buttons' import { createExitPrompter } from '../ui/common/exitPrompter' import { createRegionPrompter } from '../ui/common/region' import { createInputBox } from '../ui/inputPrompter' -import { DataQuickPickItem, createQuickPick } from '../ui/pickerPrompter' import { ChildProcess } from '../utilities/processUtils' import { CancellationError } from '../utilities/timeoutUtils' import { Wizard } from '../wizards/wizard' import { addTelemetryEnvVar } from './cli/samCliInvokerUtils' import { validateSamDeployConfig, SamConfig, writeSamconfigGlobal } from './config' -import { TemplateItem, createStackPrompter, createBucketPrompter, createTemplatePrompter } from './sync' -import { getErrorCode, getProjectRoot, getSamCliPathAndVersion, getSource } from './utils' +import { BucketSource, createBucketSourcePrompter, createBucketNamePrompter } from '../ui/sam/bucketPrompter' +import { createStackPrompter } from '../ui/sam/stackPrompter' +import { TemplateItem, createTemplatePrompter } from '../ui/sam/templatePrompter' +import { createDeployParamsSourcePrompter, ParamsSource } from '../ui/sam/paramsSourcePrompter' +import { + getErrorCode, + getProjectRoot, + getSamCliPathAndVersion, + getSource, + getRecentResponse, + updateRecentResponse, +} from './utils' import { runInTerminal } from './processTerminal' export interface DeployParams { @@ -39,89 +48,24 @@ export interface DeployParams { [key: string]: any } -const mementoRootKey = 'samcli.deploy.params' -export function getRecentParams(identifier: string, key: string): string | undefined { - const root = globals.context.workspaceState.get(mementoRootKey, {} as Record>) +const deployMementoRootKey = 'samcli.deploy.params' - return root[identifier]?.[key] -} - -export async function updateRecentParams(identifier: string, key: string, value: string | undefined) { - try { - const root = globals.context.workspaceState.get(mementoRootKey, {} as Record>) - await globals.context.workspaceState.update(mementoRootKey, { - ...root, - [identifier]: { ...root[identifier], [key]: value }, - }) - } catch (err) { - getLogger().warn(`sam: unable to save response at key "${key}": %s`, err) - } +function getRecentDeployParams(identifier: string, key: string): string | undefined { + return getRecentResponse(deployMementoRootKey, identifier, key) } function createParamPromptProvider(name: string, defaultValue: string | undefined, templateFsPath: string = 'default') { return createInputBox({ title: `Specify SAM parameter value for ${name}`, buttons: createCommonButtons(samDeployUrl), - value: getRecentParams(templateFsPath, name) ?? defaultValue, + value: getRecentDeployParams(templateFsPath, name) ?? defaultValue, }) } -function bucketSourcePrompter() { - const items: DataQuickPickItem[] = [ - { - label: 'Create a SAM CLI managed S3 bucket', - data: BucketSource.SamCliManaged, - }, - { - label: 'Specify an S3 bucket', - data: BucketSource.UserProvided, - }, - ] - return createQuickPick(items, { - title: 'Specify S3 bucket for deployment artifacts', - placeholder: 'Press enter to proceed with highlighted option', - buttons: createCommonButtons(samDeployUrl), - }) -} -function paramsSourcePrompter(existValidSamconfig: boolean | undefined) { - const items: DataQuickPickItem[] = [ - { - label: 'Specify required parameters and save as defaults', - data: ParamsSource.SpecifyAndSave, - }, - { - label: 'Specify required parameters', - data: ParamsSource.Specify, - }, - ] - - if (existValidSamconfig) { - items.push({ - label: 'Use default values from samconfig', - data: ParamsSource.SamConfig, - }) - } - - return createQuickPick(items, { - title: 'Specify parameters for deploy', - placeholder: 'Press enter to proceed with highlighted option', - buttons: createCommonButtons(samDeployUrl), - }) -} type DeployResult = { isSuccess: boolean } -export enum BucketSource { - SamCliManaged, - UserProvided, -} -export enum ParamsSource { - SpecifyAndSave, - Specify, - SamConfig, -} - export class DeployWizard extends Wizard { registry: CloudFormationTemplateRegistry state: Partial @@ -153,7 +97,7 @@ export class DeployWizard extends Wizard { this.form.template.setDefault(templateItem) this.form.projectRoot.setDefault(() => projectRootFolder) this.form.paramsSource.bindPrompter(async () => - paramsSourcePrompter(await validateSamDeployConfig(projectRootFolder)) + createDeployParamsSourcePrompter(await validateSamDeployConfig(projectRootFolder)) ) this.form.region.bindPrompter(() => createRegionPrompter().transform((r) => r.id), { @@ -161,42 +105,50 @@ export class DeployWizard extends Wizard { paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) this.form.stackName.bindPrompter( - ({ region }) => createStackPrompter(new DefaultCloudFormationClient(region!)), + ({ region }) => + createStackPrompter(new DefaultCloudFormationClient(region!), deployMementoRootKey, samDeployUrl), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, } ) - this.form.bucketSource.bindPrompter(() => bucketSourcePrompter(), { + this.form.bucketSource.bindPrompter(() => createBucketSourcePrompter(), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) - this.form.bucketName.bindPrompter(({ region }) => createBucketPrompter(new DefaultS3Client(region!)), { - showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, - }) + this.form.bucketName.bindPrompter( + ({ region }) => createBucketNamePrompter(new DefaultS3Client(region!), deployMementoRootKey), + { + showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, + } + ) } else if (this.arg && this.arg.regionCode) { // "Deploy" command was invoked on a regionNode. - this.form.template.bindPrompter(() => createTemplatePrompter(this.registry)) + this.form.template.bindPrompter(() => createTemplatePrompter(this.registry, deployMementoRootKey)) this.form.projectRoot.setDefault(({ template }) => getProjectRoot(template)) this.form.paramsSource.bindPrompter(async ({ projectRoot }) => { const existValidSamConfig: boolean | undefined = await validateSamDeployConfig(projectRoot) - return paramsSourcePrompter(existValidSamConfig) + return createDeployParamsSourcePrompter(existValidSamConfig) }) this.form.region.setDefault(() => this.arg.regionCode) this.form.stackName.bindPrompter( - ({ region }) => createStackPrompter(new DefaultCloudFormationClient(region!)), + ({ region }) => + createStackPrompter(new DefaultCloudFormationClient(region!), deployMementoRootKey, samDeployUrl), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, } ) - this.form.bucketSource.bindPrompter(() => bucketSourcePrompter(), { + this.form.bucketSource.bindPrompter(() => createBucketSourcePrompter(), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) - this.form.bucketName.bindPrompter(({ region }) => createBucketPrompter(new DefaultS3Client(region!)), { - showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, - }) + this.form.bucketName.bindPrompter( + ({ region }) => createBucketNamePrompter(new DefaultS3Client(region!), deployMementoRootKey), + { + showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, + } + ) } else if (this.arg && this.arg.getTreeItem().resourceUri) { // "Deploy" command was invoked on a TreeNode on the AppBuilder. const templateUri = this.arg.getTreeItem().resourceUri as vscode.Uri @@ -206,7 +158,7 @@ export class DeployWizard extends Wizard { this.addParameterPromptersIfApplicable(templateUri) this.form.template.setDefault(templateItem) this.form.paramsSource.bindPrompter(async () => - paramsSourcePrompter(await validateSamDeployConfig(projectRootFolder)) + createDeployParamsSourcePrompter(await validateSamDeployConfig(projectRootFolder)) ) this.form.region.bindPrompter(() => createRegionPrompter().transform((r) => r.id), { @@ -214,46 +166,54 @@ export class DeployWizard extends Wizard { paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) this.form.stackName.bindPrompter( - ({ region }) => createStackPrompter(new DefaultCloudFormationClient(region!)), + ({ region }) => + createStackPrompter(new DefaultCloudFormationClient(region!), deployMementoRootKey, samDeployUrl), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, } ) - this.form.bucketSource.bindPrompter(() => bucketSourcePrompter(), { + this.form.bucketSource.bindPrompter(() => createBucketSourcePrompter(), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) - this.form.bucketName.bindPrompter(({ region }) => createBucketPrompter(new DefaultS3Client(region!)), { - showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, - }) + this.form.bucketName.bindPrompter( + ({ region }) => createBucketNamePrompter(new DefaultS3Client(region!), deployMementoRootKey), + { + showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, + } + ) this.form.projectRoot.setDefault(() => getProjectRoot(templateItem)) } else { // "Deploy" command was invoked on the command palette. - this.form.template.bindPrompter(() => createTemplatePrompter(this.registry)) + this.form.template.bindPrompter(() => createTemplatePrompter(this.registry, deployMementoRootKey)) this.form.projectRoot.setDefault(({ template }) => getProjectRoot(template)) this.form.paramsSource.bindPrompter(async ({ projectRoot }) => { const existValidSamConfig: boolean | undefined = await validateSamDeployConfig(projectRoot) - return paramsSourcePrompter(existValidSamConfig) + return createDeployParamsSourcePrompter(existValidSamConfig) }) this.form.region.bindPrompter(() => createRegionPrompter().transform((r) => r.id), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) this.form.stackName.bindPrompter( - ({ region }) => createStackPrompter(new DefaultCloudFormationClient(region!)), + ({ region }) => + createStackPrompter(new DefaultCloudFormationClient(region!), deployMementoRootKey, samDeployUrl), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, } ) - this.form.bucketSource.bindPrompter(() => bucketSourcePrompter(), { + this.form.bucketSource.bindPrompter(() => createBucketSourcePrompter(), { showWhen: ({ paramsSource }) => paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) - this.form.bucketName.bindPrompter(({ region }) => createBucketPrompter(new DefaultS3Client(region!)), { - showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, - }) + this.form.bucketName.bindPrompter( + ({ region }) => createBucketNamePrompter(new DefaultS3Client(region!), deployMementoRootKey), + { + showWhen: ({ bucketSource }) => bucketSource === BucketSource.UserProvided, + } + ) } return this @@ -346,13 +306,15 @@ export async function runDeploy(arg: any, wizardParams?: DeployParams): Promise< const paramsToSet: string[] = [] for (const name of parameterNames) { if (params[name]) { - await updateRecentParams(params.template.uri.fsPath, name, params[name]) + await updateRecentResponse(deployMementoRootKey, params.template.uri.fsPath, name, params[name]) paramsToSet.push(`ParameterKey=${name},ParameterValue=${params[name]}`) } } paramsToSet.length > 0 && deployFlags.push('--parameter-overrides', paramsToSet.join(' ')) } + await updateRecentResponse(deployMementoRootKey, 'global', 'templatePath', params.template.uri.fsPath) + try { const { path: samCliPath } = await getSamCliPathAndVersion() diff --git a/packages/core/src/shared/sam/sync.ts b/packages/core/src/shared/sam/sync.ts index c3e61aee89b..813ed77887b 100644 --- a/packages/core/src/shared/sam/sync.ts +++ b/packages/core/src/shared/sam/sync.ts @@ -7,7 +7,6 @@ import globals from '../extensionGlobals' import * as vscode from 'vscode' import * as path from 'path' -import * as nls from 'vscode-nls' import * as localizedText from '../localizedText' import { DefaultS3Client } from '../clients/s3Client' import { Wizard } from '../wizards/wizard' @@ -25,27 +24,35 @@ import { telemetry } from '../telemetry/telemetry' import { createCommonButtons } from '../ui/buttons' import { ToolkitPromptSettings } from '../settings' import { getLogger } from '../logger' -import { getSamInitDocUrl } from '../extensionUtilities' import { createExitPrompter } from '../ui/common/exitPrompter' -import { StackSummary } from 'aws-sdk/clients/cloudformation' import { getConfigFileUri, SamConfig, validateSamSyncConfig, writeSamconfigGlobal } from './config' import { cast, Optional } from '../utilities/typeConstructors' import { pushIf, toRecord } from '../utilities/collectionUtils' import { getOverriddenParameters } from '../../lambda/config/parameterUtils' import { addTelemetryEnvVar } from './cli/samCliInvokerUtils' import { samSyncParamUrl, samSyncUrl, samUpgradeUrl } from '../constants' -import { getAwsConsoleUrl } from '../awsConsole' import { openUrl } from '../utilities/vsCodeUtils' import { showOnce } from '../utilities/messages' import { IamConnection } from '../../auth/connection' import { CloudFormationTemplateRegistry } from '../fs/templateRegistry' import { TreeNode } from '../treeview/resourceTreeDataProvider' import { getSpawnEnv } from '../env/resolveEnv' -import { getErrorCode, getProjectRoot, getProjectRootUri, getSamCliPathAndVersion, getSource } from './utils' +import { + getProjectRoot, + getProjectRootUri, + getRecentResponse, + getSamCliPathAndVersion, + getSource, + getErrorCode, + updateRecentResponse, +} from './utils' +import { TemplateItem, createTemplatePrompter } from '../ui/sam/templatePrompter' +import { createStackPrompter } from '../ui/sam/stackPrompter' +import { ParamsSource, createSyncParamsSourcePrompter } from '../ui/sam/paramsSourcePrompter' +import { createEcrPrompter } from '../ui/sam/ecrPrompter' +import { BucketSource, createBucketNamePrompter } from '../ui/sam/bucketPrompter' import { runInTerminal } from './processTerminal' -const localize = nls.loadMessageBundle() - export interface SyncParams { readonly paramsSource: ParamsSource readonly region: string @@ -61,148 +68,11 @@ export interface SyncParams { readonly syncFlags?: string } -export enum ParamsSource { - SpecifyAndSave, - SamConfig, - Flags, -} -enum BucketSource { - SamCliManaged, - UserProvided, -} - -export function paramsSourcePrompter(existValidSamconfig: boolean | undefined) { - const items: DataQuickPickItem[] = [ - { - label: 'Specify required parameters and save as defaults', - data: ParamsSource.SpecifyAndSave, - }, - { - label: 'Specify required parameters', - data: ParamsSource.Flags, - }, - ] - - if (existValidSamconfig) { - items.push({ - label: 'Use default values from samconfig', - data: ParamsSource.SamConfig, - }) - } - - return createQuickPick(items, { - title: 'Specify parameters for deploy', - placeholder: 'Press enter to proceed with highlighted option', - buttons: createCommonButtons(samSyncUrl), - }) -} - -export const prefixNewBucketName = (name: string) => `newbucket:${name}` -export const prefixNewRepoName = (name: string) => `newrepo:${name}` - -export function createBucketPrompter(client: DefaultS3Client) { - const recentBucket = getRecentResponse(client.regionCode, 'bucketName') - const items = client.listBucketsIterable().map((b) => [ - { - label: b.Name, - data: b.Name as SyncParams['bucketName'], - recentlyUsed: b.Name === recentBucket, - }, - ]) - - return createQuickPick(items, { - title: 'Select an S3 Bucket', - placeholder: 'Select a bucket (or enter a name to create one)', - buttons: createCommonButtons(samSyncUrl), - filterBoxInputSettings: { - label: 'Create a New Bucket', - // This is basically a hack. I need to refactor `createQuickPick` a bit. - transform: (v) => prefixNewBucketName(v), - }, - noItemsFoundItem: { - label: localize( - 'aws.cfn.noStacks', - 'No S3 buckets for region "{0}". Enter a name to create a new one.', - client.regionCode - ), - data: undefined, - onClick: undefined, - }, - }) -} - -const canPickStack = (s: StackSummary) => s.StackStatus.endsWith('_COMPLETE') -const canShowStack = (s: StackSummary) => - (s.StackStatus.endsWith('_COMPLETE') || s.StackStatus.endsWith('_IN_PROGRESS')) && !s.StackStatus.includes('DELETE') - -export function createStackPrompter(client: DefaultCloudFormationClient) { - const recentStack = getRecentResponse(client.regionCode, 'stackName') - const consoleUrl = getAwsConsoleUrl('cloudformation', client.regionCode) - const items = client.listAllStacks().map((stacks) => - stacks.filter(canShowStack).map((s) => ({ - label: s.StackName, - data: s.StackName, - invalidSelection: !canPickStack(s), - recentlyUsed: s.StackName === recentStack, - description: !canPickStack(s) ? 'stack create/update already in progress' : undefined, - })) - ) - - return createQuickPick(items, { - title: 'Select a CloudFormation Stack', - placeholder: 'Select a stack (or enter a name to create one)', - filterBoxInputSettings: { - label: 'Create a New Stack', - transform: (v) => v, - }, - buttons: createCommonButtons(samSyncUrl, consoleUrl), - noItemsFoundItem: { - label: localize( - 'aws.cfn.noStacks', - 'No stacks in region "{0}". Enter a name to create a new one.', - client.regionCode - ), - data: undefined, - onClick: undefined, - }, - }) -} - -export function createEcrPrompter(client: DefaultEcrClient) { - const recentEcrRepo = getRecentResponse(client.regionCode, 'ecrRepoUri') - const consoleUrl = getAwsConsoleUrl('ecr', client.regionCode) - const items = client.listAllRepositories().map((list) => - list.map((repo) => ({ - label: repo.repositoryName, - data: repo.repositoryUri, - detail: repo.repositoryArn, - recentlyUsed: repo.repositoryUri === recentEcrRepo, - })) - ) - - return createQuickPick(items, { - title: 'Select an ECR Repository', - placeholder: 'Select a repository (or enter a name to create one)', - buttons: createCommonButtons(samSyncUrl, consoleUrl), - filterBoxInputSettings: { - label: 'Create a New Repository', - transform: (v) => prefixNewRepoName(v), - }, - noItemsFoundItem: { - label: localize( - 'aws.ecr.noRepos', - 'No ECR repositories in region "{0}". Enter a name to create a new one.', - client.regionCode - ), - data: undefined, - onClick: undefined, - }, - }) -} +export const syncMementoRootKey = 'samcli.sync.params' // TODO: hook this up so it prompts the user when more than 1 environment is present in `samconfig.toml` export function createEnvironmentPrompter(config: SamConfig, environments = config.listEnvironments()) { - const recentEnvironmentName = getRecentResponse(config.location.fsPath, 'environmentName') + const recentEnvironmentName = getRecentResponse(syncMementoRootKey, config.location.fsPath, 'environmentName') const items = environments.map((env) => ({ label: env.name, data: env, @@ -216,45 +86,6 @@ export function createEnvironmentPrompter(config: SamConfig, environments = conf }) } -export interface TemplateItem { - readonly uri: vscode.Uri - readonly data: CloudFormation.Template -} - -export function createTemplatePrompter(registry: CloudFormationTemplateRegistry, projectRoot?: vscode.Uri) { - const folders = new Set() - const recentTemplatePath = getRecentResponse('global', 'templatePath') - const filterTemplates = projectRoot - ? registry.items.filter(({ path: filePath }) => !path.relative(projectRoot.fsPath, filePath).startsWith('..')) - : registry.items - - const items = filterTemplates.map(({ item, path: filePath }) => { - const uri = vscode.Uri.file(filePath) - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) - const label = workspaceFolder ? path.relative(workspaceFolder.uri.fsPath, uri.fsPath) : uri.fsPath - folders.add(workspaceFolder?.name ?? '') - - return { - label, - data: { uri, data: item }, - description: workspaceFolder?.name, - recentlyUsed: recentTemplatePath === uri.fsPath, - } - }) - - const trimmedItems = folders.size === 1 ? items.map((item) => ({ ...item, description: undefined })) : items - return createQuickPick(trimmedItems, { - title: 'Select a SAM/CloudFormation Template', - placeholder: 'Select a SAM/CloudFormation Template', - buttons: createCommonButtons(samSyncUrl), - noItemsFoundItem: { - label: localize('aws.sam.noWorkspace', 'No SAM template.yaml file(s) found. Select for help'), - data: undefined, - onClick: () => openUrl(getSamInitDocUrl()), - }, - }) -} - function hasImageBasedResources(template: CloudFormation.Template) { const resources = template.Resources @@ -325,36 +156,43 @@ export class SyncWizard extends Wizard { ) { super({ initState: state, exitPrompterProvider: shouldPromptExit ? createExitPrompter : undefined }) this.registry = registry - this.form.template.bindPrompter(() => createTemplatePrompter(this.registry)) + this.form.template.bindPrompter(() => createTemplatePrompter(this.registry, syncMementoRootKey)) this.form.projectRoot.setDefault(({ template }) => getProjectRoot(template)) this.form.paramsSource.bindPrompter(async ({ projectRoot }) => { const existValidSamConfig: boolean | undefined = await validateSamSyncConfig(projectRoot) - return paramsSourcePrompter(existValidSamConfig) + return createSyncParamsSourcePrompter(existValidSamConfig) }) this.form.region.bindPrompter(() => createRegionPrompter().transform((r) => r.id), { showWhen: ({ paramsSource }) => - paramsSource === ParamsSource.Flags || paramsSource === ParamsSource.SpecifyAndSave, + paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, }) this.form.stackName.bindPrompter( - ({ region }) => createStackPrompter(new DefaultCloudFormationClient(region!)), + ({ region }) => + createStackPrompter(new DefaultCloudFormationClient(region!), syncMementoRootKey, samSyncUrl), { showWhen: ({ paramsSource }) => - paramsSource === ParamsSource.Flags || paramsSource === ParamsSource.SpecifyAndSave, + paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, } ) - this.form.bucketName.bindPrompter(({ region }) => createBucketPrompter(new DefaultS3Client(region!)), { - showWhen: ({ paramsSource }) => - paramsSource === ParamsSource.Flags || paramsSource === ParamsSource.SpecifyAndSave, - }) + this.form.bucketName.bindPrompter( + ({ region }) => createBucketNamePrompter(new DefaultS3Client(region!), syncMementoRootKey), + { + showWhen: ({ paramsSource }) => + paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, + } + ) - this.form.ecrRepoUri.bindPrompter(({ region }) => createEcrPrompter(new DefaultEcrClient(region!)), { - showWhen: ({ template, paramsSource }) => - !!template && - hasImageBasedResources(template.data) && - (paramsSource === ParamsSource.Flags || paramsSource === ParamsSource.SpecifyAndSave), - }) + this.form.ecrRepoUri.bindPrompter( + ({ region }) => createEcrPrompter(new DefaultEcrClient(region!), syncMementoRootKey), + { + showWhen: ({ template, paramsSource }) => + !!template && + hasImageBasedResources(template.data) && + (paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave), + } + ) // todo wrap with localize this.form.syncFlags.bindPrompter( @@ -366,7 +204,7 @@ export class SyncWizard extends Wizard { }), { showWhen: ({ paramsSource }) => - paramsSource === ParamsSource.Flags || paramsSource === ParamsSource.SpecifyAndSave, + paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave, } ) } @@ -427,10 +265,10 @@ export async function saveAndBindArgs(args: SyncParams): Promise<{ readonly boun } await Promise.all([ - updateRecentResponse(args.region, 'stackName', data.stackName), - updateRecentResponse(args.region, 'bucketName', data.bucketName), - updateRecentResponse(args.region, 'ecrRepoUri', data.ecrRepoUri), - updateRecentResponse('global', 'templatePath', data.templatePath), + updateSyncRecentResponse(args.region, 'stackName', data.stackName), + updateSyncRecentResponse(args.region, 'bucketName', data.bucketName), + updateSyncRecentResponse(args.region, 'ecrRepoUri', data.ecrRepoUri), + updateSyncRecentResponse('global', 'templatePath', data.templatePath), ]) const boundArgs = bindDataToParams(data, { @@ -534,7 +372,6 @@ export async function getSyncWizard( return wizard } -export const getWorkspaceUri = (template: TemplateItem) => vscode.workspace.getWorkspaceFolder(template.uri)?.uri const getStringParam = (config: SamConfig, key: string) => { try { return cast(config.getCommandParam('sync', key), Optional(String)) @@ -669,23 +506,8 @@ export async function runSync( }) } -const mementoRootKey = 'samcli.sync.params' -export function getRecentResponse(region: string, key: string): string | undefined { - const root = globals.context.workspaceState.get(mementoRootKey, {} as Record>) - - return root[region]?.[key] -} - -export async function updateRecentResponse(region: string, key: string, value: string | undefined) { - try { - const root = globals.context.workspaceState.get(mementoRootKey, {} as Record>) - await globals.context.workspaceState.update(mementoRootKey, { - ...root, - [region]: { ...root[region], [key]: value }, - }) - } catch (err) { - getLogger().warn(`sam: unable to save response at key "${key}": %s`, err) - } +async function updateSyncRecentResponse(region: string, key: string, value: string | undefined) { + return await updateRecentResponse(syncMementoRootKey, region, key, value) } export async function confirmDevStack() { diff --git a/packages/core/src/shared/sam/utils.ts b/packages/core/src/shared/sam/utils.ts index 6847d8d200b..336f1b44742 100644 --- a/packages/core/src/shared/sam/utils.ts +++ b/packages/core/src/shared/sam/utils.ts @@ -8,13 +8,15 @@ import * as path from 'path' import { AWSTreeNodeBase } from '../treeview/nodes/awsTreeNodeBase' import { TreeNode, isTreeNode } from '../treeview/resourceTreeDataProvider' import * as CloudFormation from '../cloudformation/cloudformation' -import { TemplateItem } from './sync' +import { TemplateItem } from '../ui/sam/templatePrompter' import { RuntimeFamily, getFamily } from '../../lambda/models/samLambdaRuntime' -import { telemetry } from '../telemetry' -import { ToolkitError } from '../errors' import { SamCliSettings } from './cli/samCliSettings' +import { ToolkitError } from '../errors' import { SamCliInfoInvocation } from './cli/samCliInfo' import { parse } from 'semver' +import { telemetry } from '../telemetry/telemetry' +import globals from '../extensionGlobals' +import { getLogger } from '../logger/logger' import { ChildProcessResult } from '../utilities/processUtils' /** @@ -85,6 +87,27 @@ export async function getSamCliPathAndVersion() { return { path: samCliPath, parsedVersion } } +export function getRecentResponse(mementoRootKey: string, identifier: string, key: string): string | undefined { + const root = globals.context.workspaceState.get(mementoRootKey, {} as Record>) + return root[identifier]?.[key] +} + +export async function updateRecentResponse( + mementoRootKey: string, + identifier: string, + key: string, + value: string | undefined +) { + try { + const root = globals.context.workspaceState.get(mementoRootKey, {} as Record>) + await globals.context.workspaceState.update(mementoRootKey, { + ...root, + [identifier]: { ...root[identifier], [key]: value }, + }) + } catch (err) { + getLogger().warn(`sam: unable to save response at key "${key}": %s`, err) + } +} export function getSamCliErrorMessage(stderr: string): string { // Split the stderr string by newline, filter out empty lines, and get the last line const lines = stderr diff --git a/packages/core/src/shared/ui/sam/bucketPrompter.ts b/packages/core/src/shared/ui/sam/bucketPrompter.ts new file mode 100644 index 00000000000..b418117bca8 --- /dev/null +++ b/packages/core/src/shared/ui/sam/bucketPrompter.ts @@ -0,0 +1,83 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { DefaultS3Client } from '../../clients/s3Client' +import { samDeployUrl, samSyncUrl } from '../../constants' +import { createCommonButtons } from '../buttons' +import { createQuickPick, DataQuickPickItem } from '../pickerPrompter' +import type { SyncParams } from '../../sam/sync' +import * as nls from 'vscode-nls' +import { getRecentResponse } from '../../sam/utils' + +const localize = nls.loadMessageBundle() +export const prefixNewBucketName = (name: string) => `newbucket:${name}` + +export enum BucketSource { + SamCliManaged, + UserProvided, +} + +/** + * Creates a quick pick prompter for configuring S3 bucket used for sync or deploy application artifact + * Provides two options: + * 1. Create a SAM CLI managed bucket + * 2. Specify an existing bucket + * @returns A QuickPick prompter configured with bucket source options + */ +export function createBucketSourcePrompter() { + const items: DataQuickPickItem[] = [ + { + label: 'Create a SAM CLI managed S3 bucket', + data: BucketSource.SamCliManaged, + }, + { + label: 'Specify an S3 bucket', + data: BucketSource.UserProvided, + }, + ] + + return createQuickPick(items, { + title: 'Specify S3 bucket for deployment artifacts', + placeholder: 'Press enter to proceed with highlighted option', + buttons: createCommonButtons(samDeployUrl), + }) +} + +/** + * Creates a quick pick prompter for configuring S3 bucket name used for sync or deploy application artifact + * The prompter supports choosing from existing s3 bucket name or creating a new one + * @param client S3 client + * @param mementoRootKey Memento key to store recent bucket name (e.g 'samcli.deploy.params') + * @returns A quick pick prompter configured with bucket name options + */ +export function createBucketNamePrompter(client: DefaultS3Client, mementoRootKey: string) { + const recentBucket = getRecentResponse(mementoRootKey, client.regionCode, 'bucketName') + const items = client.listBucketsIterable().map((b) => [ + { + label: b.Name, + data: b.Name as SyncParams['bucketName'], + recentlyUsed: b.Name === recentBucket, + }, + ]) + + return createQuickPick(items, { + title: 'Select an S3 Bucket', + placeholder: 'Select a bucket (or enter a name to create one)', + buttons: createCommonButtons(samSyncUrl), + filterBoxInputSettings: { + label: 'Create a New Bucket', + // This is basically a hack. I need to refactor `createQuickPick` a bit. + transform: (v) => prefixNewBucketName(v), + }, + noItemsFoundItem: { + label: localize( + 'aws.cfn.noStacks', + 'No S3 buckets for region "{0}". Enter a name to create a new one.', + client.regionCode + ), + data: undefined, + onClick: undefined, + }, + }) +} diff --git a/packages/core/src/shared/ui/sam/ecrPrompter.ts b/packages/core/src/shared/ui/sam/ecrPrompter.ts new file mode 100644 index 00000000000..3c8a642919c --- /dev/null +++ b/packages/core/src/shared/ui/sam/ecrPrompter.ts @@ -0,0 +1,55 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { getAwsConsoleUrl } from '../../awsConsole' +import { DefaultEcrClient } from '../../clients/ecrClient' +import { samSyncUrl } from '../../constants' +import { createCommonButtons } from '../buttons' +import { createQuickPick } from '../pickerPrompter' + +import * as nls from 'vscode-nls' +import { getRecentResponse } from '../../sam/utils' + +export const localize = nls.loadMessageBundle() +export const prefixNewRepoName = (name: string) => `newrepo:${name}` + +/** + * Creates a quick pick prompter for ECR repositories + * The prompter supports choosing from existing option and new repositories by entering a name + * + * @param client ECR client used to list repositories + * @param mementoRootKey Key used to store/retrieve recently used repository (e.g 'samcli.deploy.params') + * @returns A quick pick prompter configured for ECR repository + */ +export function createEcrPrompter(client: DefaultEcrClient, mementoRootKey: string) { + const recentEcrRepo = getRecentResponse(mementoRootKey, client.regionCode, 'ecrRepoUri') + const consoleUrl = getAwsConsoleUrl('ecr', client.regionCode) + const items = client.listAllRepositories().map((list) => + list.map((repo) => ({ + label: repo.repositoryName, + data: repo.repositoryUri, + detail: repo.repositoryArn, + recentlyUsed: repo.repositoryUri === recentEcrRepo, + })) + ) + + return createQuickPick(items, { + title: 'Select an ECR Repository', + placeholder: 'Select a repository (or enter a name to create one)', + buttons: createCommonButtons(samSyncUrl, consoleUrl), + filterBoxInputSettings: { + label: 'Create a New Repository', + transform: (v) => prefixNewRepoName(v), + }, + noItemsFoundItem: { + label: localize( + 'aws.ecr.noRepos', + 'No ECR repositories in region "{0}". Enter a name to create a new one.', + client.regionCode + ), + data: undefined, + onClick: undefined, + }, + }) +} diff --git a/packages/core/src/shared/ui/sam/paramsSourcePrompter.ts b/packages/core/src/shared/ui/sam/paramsSourcePrompter.ts new file mode 100644 index 00000000000..332cdc650fd --- /dev/null +++ b/packages/core/src/shared/ui/sam/paramsSourcePrompter.ts @@ -0,0 +1,68 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { samDeployUrl, samSyncUrl } from '../../constants' +import { createCommonButtons } from '../buttons' +import { DataQuickPickItem, createQuickPick } from '../pickerPrompter' + +export enum ParamsSource { + SpecifyAndSave, + Specify, + SamConfig, +} + +function loadParamsSourcePrompterItems(existValidSamconfig: boolean | undefined) { + const items: DataQuickPickItem[] = [ + { + label: 'Specify required parameters and save as defaults', + data: ParamsSource.SpecifyAndSave, + }, + { + label: 'Specify required parameters', + data: ParamsSource.Specify, + }, + ] + + if (existValidSamconfig) { + items.push({ + label: 'Use default values from samconfig', + data: ParamsSource.SamConfig, + }) + } + + return items +} + +/** + * Creates a quick pick prompter for SAM deploy parameter source selection + * + * @param existValidSamconfig Whether a valid samconfig.toml file exist and contain necessary flag for SAM sync operation + * @returns A quick pick prompter + */ +export function createDeployParamsSourcePrompter(existValidSamconfig: boolean | undefined) { + const items = loadParamsSourcePrompterItems(existValidSamconfig) + + return createQuickPick(items, { + title: 'Specify parameter source for deploy', + placeholder: 'Press enter to proceed with highlighted option', + buttons: createCommonButtons(samDeployUrl), + }) +} + +/** + * Creates a quick pick prompter for SAM sync parameter source selection + * + * @param existValidSamconfig Whether a valid samconfig.toml file exist and contain necessary flag for SAM sync operation + * @returns A quick pick prompter + */ + +export function createSyncParamsSourcePrompter(existValidSamconfig: boolean | undefined) { + const items = loadParamsSourcePrompterItems(existValidSamconfig) + + return createQuickPick(items, { + title: 'Specify parameter source for sync', + placeholder: 'Press enter to proceed with highlighted option', + buttons: createCommonButtons(samSyncUrl), + }) +} diff --git a/packages/core/src/shared/ui/sam/stackPrompter.ts b/packages/core/src/shared/ui/sam/stackPrompter.ts new file mode 100644 index 00000000000..12eeec6ca72 --- /dev/null +++ b/packages/core/src/shared/ui/sam/stackPrompter.ts @@ -0,0 +1,65 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { StackSummary } from 'aws-sdk/clients/cloudformation' +import { getAwsConsoleUrl } from '../../awsConsole' +import { DefaultCloudFormationClient } from '../../clients/cloudFormationClient' +import * as vscode from 'vscode' +import { createCommonButtons } from '../buttons' +import { createQuickPick } from '../pickerPrompter' +import * as nls from 'vscode-nls' +import { getRecentResponse } from '../../sam/utils' + +export const localize = nls.loadMessageBundle() + +const canPickStack = (s: StackSummary) => s.StackStatus.endsWith('_COMPLETE') +const canShowStack = (s: StackSummary) => + (s.StackStatus.endsWith('_COMPLETE') || s.StackStatus.endsWith('_IN_PROGRESS')) && !s.StackStatus.includes('DELETE') + +/** + * Creates a quick pick prompter for choosing a CloudFormation stack + * The promper supports selecting from existing options or creating a new stack by entering a name + * + * @param client - CloudFormation client to use for listing stacks + * @param mementoRootKey - Key used to store/retrieve recently used stack (e.g 'samcli.deploy.params') + * @param samCommandUrl - URI for sam command wizard webpage + * @returns A quick pick prompter configured for stack selection + * + */ +export function createStackPrompter( + client: DefaultCloudFormationClient, + mementoRootKey: string, + samCommandUrl: vscode.Uri +) { + const recentStack = getRecentResponse(mementoRootKey, client.regionCode, 'stackName') + const consoleUrl = getAwsConsoleUrl('cloudformation', client.regionCode) + const items = client.listAllStacks().map((stacks) => + stacks.filter(canShowStack).map((s) => ({ + label: s.StackName, + data: s.StackName, + invalidSelection: !canPickStack(s), + recentlyUsed: s.StackName === recentStack, + description: !canPickStack(s) ? 'stack create/update already in progress' : undefined, + })) + ) + + return createQuickPick(items, { + title: 'Select a CloudFormation Stack', + placeholder: 'Select a stack (or enter a name to create one)', + filterBoxInputSettings: { + label: 'Create a New Stack', + transform: (v) => v, + }, + buttons: createCommonButtons(samCommandUrl, consoleUrl), + noItemsFoundItem: { + label: localize( + 'aws.cfn.noStacks', + 'No stacks in region "{0}". Enter a name to create a new one.', + client.regionCode + ), + data: undefined, + onClick: undefined, + }, + }) +} diff --git a/packages/core/src/shared/ui/sam/templatePrompter.ts b/packages/core/src/shared/ui/sam/templatePrompter.ts new file mode 100644 index 00000000000..4677ef5ab50 --- /dev/null +++ b/packages/core/src/shared/ui/sam/templatePrompter.ts @@ -0,0 +1,73 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'path' +import * as vscode from 'vscode' +import { getSamInitDocUrl } from '../..' +import * as CloudFormation from '../../cloudformation/cloudformation' +import { samSyncUrl } from '../../constants' +import { CloudFormationTemplateRegistry } from '../../fs/templateRegistry' +import { createCommonButtons } from '../buttons' +import { createQuickPick } from '../pickerPrompter' +import { openUrl } from '../../utilities/vsCodeUtils' +import * as nls from 'vscode-nls' +import { getRecentResponse } from '../../sam/utils' + +export const localize = nls.loadMessageBundle() + +export interface TemplateItem { + readonly uri: vscode.Uri + readonly data: CloudFormation.Template +} + +/** + * Creates a quick pick prompter for choosing SAM/CloudFormation templates + * + * @param registry - Registry containing CloudFormation templates + * @param mementoRootKey - Root key for storing recent template selections (e.g 'samcli.deploy.params') + * @param projectRoot - Optional URI of the project root to filter templates + * @returns A QuickPick prompter configured for template selection + * + * The prompter displays a list of SAM/CloudFormation templates found in the workspace. + * Templates are shown with relative paths when possible, and workspace folder names when multiple folders exist. + * Recently used templates are marked. If no templates are found, provides a help link. + */ +export function createTemplatePrompter( + registry: CloudFormationTemplateRegistry, + mementoRootKey: string, + projectRoot?: vscode.Uri +) { + const folders = new Set() + const recentTemplatePath = getRecentResponse(mementoRootKey, 'global', 'templatePath') + const filterTemplates = projectRoot + ? registry.items.filter(({ path: filePath }) => !path.relative(projectRoot.fsPath, filePath).startsWith('..')) + : registry.items + + const items = filterTemplates.map(({ item, path: filePath }) => { + const uri = vscode.Uri.file(filePath) + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) + const label = workspaceFolder ? path.relative(workspaceFolder.uri.fsPath, uri.fsPath) : uri.fsPath + folders.add(workspaceFolder?.name ?? '') + + return { + label, + data: { uri, data: item }, + description: workspaceFolder?.name, + recentlyUsed: recentTemplatePath === uri.fsPath, + } + }) + + const trimmedItems = folders.size === 1 ? items.map((item) => ({ ...item, description: undefined })) : items + return createQuickPick(trimmedItems, { + title: 'Select a SAM/CloudFormation Template', + placeholder: 'Select a SAM/CloudFormation Template', + buttons: createCommonButtons(samSyncUrl), + noItemsFoundItem: { + label: localize('aws.sam.noWorkspace', 'No SAM template.yaml file(s) found. Select for help'), + data: undefined, + onClick: () => openUrl(getSamInitDocUrl()), + }, + }) +} diff --git a/packages/core/src/test/awsService/appBuilder/wizards/deployTypeWizard.test.ts b/packages/core/src/test/awsService/appBuilder/wizards/deployTypeWizard.test.ts index 86292ebe856..a75c7c8b9a2 100644 --- a/packages/core/src/test/awsService/appBuilder/wizards/deployTypeWizard.test.ts +++ b/packages/core/src/test/awsService/appBuilder/wizards/deployTypeWizard.test.ts @@ -69,7 +69,7 @@ describe('DeployTypeWizard', function () { .handleInputBox('Specify SAM parameter value for DestinationBucketName', (inputBox) => { inputBox.acceptValue('my-destination-bucket-name') }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() assert.strictEqual(quickPick.items[2].label, 'Use default values from samconfig') @@ -98,7 +98,7 @@ describe('DeployTypeWizard', function () { assert.strictEqual(picker.items.length, 2) picker.acceptItem(picker.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for sync', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() assert.strictEqual(quickPick.items[2].label, 'Use default values from samconfig') diff --git a/packages/core/src/test/shared/sam/build.test.ts b/packages/core/src/test/shared/sam/build.test.ts index a8323bea890..8698691be5b 100644 --- a/packages/core/src/test/shared/sam/build.test.ts +++ b/packages/core/src/test/shared/sam/build.test.ts @@ -4,7 +4,11 @@ */ import * as vscode from 'vscode' -import { globals } from '../../../shared' +import { globals, ToolkitError } from '../../../shared' +import * as SamUtilsModule from '../../../shared/sam/utils' +import * as ProcessTerminalUtils from '../../../shared/sam/processTerminal' +import * as ResolveEnvModule from '../../../shared/env/resolveEnv' +import * as ProcessUtilsModule from '../../../shared/utilities/processUtils' import { AppNode } from '../../../awsService/appBuilder/explorer/nodes/appNode' import { BuildParams, @@ -12,6 +16,7 @@ import { createParamsSourcePrompter, getBuildFlags, ParamsSource, + runBuild, } from '../../../shared/sam/build' import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' import { createWizardTester } from '../wizards/wizardTestUtils' @@ -21,9 +26,14 @@ import { getProjectRootUri } from '../../../shared/sam/utils' import sinon from 'sinon' import { createMultiPick, DataQuickPickItem } from '../../../shared/ui/pickerPrompter' import * as config from '../../../shared/sam/config' +import { PrompterTester } from '../wizards/prompterTester' +import { getWorkspaceFolder, TestFolder } from '../../testUtil' +import { samconfigCompleteData, validTemplateData } from './samTestUtils' +import { CloudFormationTemplateRegistry } from '../../../shared/fs/templateRegistry' import { getTestWindow } from '../vscode/window' +import { CancellationError } from '../../../shared/utilities/timeoutUtils' -describe('BuildWizard', async function () { +describe('SAM BuildWizard', async function () { const createTester = async (params?: Partial, arg?: TreeNode | undefined) => createWizardTester(new BuildWizard({ ...params }, await globals.templateRegistry, arg)) @@ -72,147 +82,470 @@ describe('BuildWizard', async function () { }) }) -describe('getBuildFlags', () => { +describe('SAM build helper functions', () => { + describe('getBuildFlags', () => { + let sandbox: sinon.SinonSandbox + let projectRoot: vscode.Uri + const defaultFlags: string[] = ['--cached', '--parallel', '--save-params', '--use-container'] + let quickPickItems: DataQuickPickItem[] + + beforeEach(() => { + sandbox = sinon.createSandbox() + projectRoot = vscode.Uri.parse('file:///path/to/project') + quickPickItems = [ + { + label: 'Beta features', + data: '--beta-features', + description: 'Enable beta features', + }, + { + label: 'Build in source', + data: '--build-in-source', + description: 'Opts in to build project in the source folder', + }, + { + label: 'Cached', + data: '--cached', + description: 'Reuse build artifacts that have not changed from previous builds', + }, + { + label: 'Debug', + data: '--debug', + description: 'Turn on debug logging to print debug messages and display timestamps', + }, + { + label: 'Parallel', + data: '--parallel', + description: 'Enable parallel builds for AWS SAM template functions and layers', + }, + { + label: 'Skip prepare infra', + data: '--skip-prepare-infra', + description: 'Skip preparation stage when there are no infrastructure changes', + }, + { + label: 'Skip pull image', + data: '--skip-pull-image', + description: 'Skip pulling down the latest Docker image for Lambda runtime', + }, + { + label: 'Use container', + data: '--use-container', + description: 'Build functions with an AWS Lambda-like container', + }, + { + label: 'Save parameters', + data: '--save-params', + description: 'Save to samconfig.toml as default parameters', + }, + ] + }) + + afterEach(() => { + sandbox.restore() // Restore all stubs after each test + }) + + it('should return flags from buildFlagsPrompter when paramsSource is Specify', async () => { + PrompterTester.init() + .handleQuickPick('Select build flags', async (picker) => { + await picker.untilReady() + assert.strictEqual(picker.items.length, 9) + assert.strictEqual(picker.title, 'Select build flags') + assert.deepStrictEqual(picker.items, quickPickItems) + const betaFeatures = picker.items[0] + const buildInSource = picker.items[1] + const cached = picker.items[2] + assert.strictEqual(betaFeatures.data, '--beta-features') + assert.strictEqual(buildInSource.data, '--build-in-source') + assert.strictEqual(cached.data, '--cached') + const acceptedItems = [betaFeatures, buildInSource, cached] + picker.acceptItems(...acceptedItems) + }) + .build() + + const flags = await createMultiPick(quickPickItems, { + title: 'Select build flags', + ignoreFocusOut: true, + }).prompt() + + assert.deepStrictEqual(flags, JSON.stringify(['--beta-features', '--build-in-source', '--cached'])) + }) + + it('should return config file flag when paramsSource is SamConfig', async () => { + const mockConfigFileUri = vscode.Uri.parse('file:///path/to/samconfig.toml') + const getConfigFileUriStub = sandbox.stub().resolves(mockConfigFileUri) + sandbox.stub(config, 'getConfigFileUri').callsFake(getConfigFileUriStub) + + const flags = await getBuildFlags(ParamsSource.SamConfig, projectRoot, defaultFlags) + assert.deepStrictEqual(flags, ['--config-file', mockConfigFileUri.fsPath]) + }) + + it('should return default flags if getConfigFileUri throws an error', async () => { + const getConfigFileUriStub = sinon.stub().rejects(new Error('Config file not found')) + sandbox.stub(config, 'getConfigFileUri').callsFake(getConfigFileUriStub) + + const flags = await getBuildFlags(ParamsSource.SamConfig, projectRoot, defaultFlags) + assert.deepStrictEqual(flags, defaultFlags) + }) + }) + + describe('createParamsSourcePrompter', () => { + it('should return a prompter with the correct items with no valid samconfig', () => { + const expectedItems: DataQuickPickItem[] = [ + { + label: 'Specify build flags', + data: ParamsSource.Specify, + }, + { + label: 'Use default values', + data: ParamsSource.DefaultValues, + description: 'cached = true, parallel = true, use_container = true', + }, + ] + const prompter = createParamsSourcePrompter(false) + const quickPick = prompter.quickPick + assert.strictEqual(quickPick.title, 'Specify parameter source for build') + assert.strictEqual(quickPick.placeholder, 'Select configuration options for sam build') + assert.strictEqual(quickPick.items.length, 2) + assert.deepStrictEqual(quickPick.items, expectedItems) + }) + + it('should return a prompter with the correct items with valid samconfig', () => { + const expectedItems: DataQuickPickItem[] = [ + { + label: 'Specify build flags', + data: ParamsSource.Specify, + }, + { + label: 'Use default values from samconfig', + data: ParamsSource.SamConfig, + }, + ] + const prompter = createParamsSourcePrompter(true) + const quickPick = prompter.quickPick + assert.strictEqual(quickPick.title, 'Specify parameter source for build') + assert.strictEqual(quickPick.placeholder, 'Select configuration options for sam build') + assert.strictEqual(quickPick.items.length, 2) + assert.deepStrictEqual(quickPick.items, expectedItems) + }) + }) +}) + +describe('SAM runBuild', () => { let sandbox: sinon.SinonSandbox + let testFolder: TestFolder let projectRoot: vscode.Uri - const defaultFlags: string[] = ['--cached', '--parallel', '--save-params', '--use-container'] - let quickPickItems: DataQuickPickItem[] + let workspaceFolder: vscode.WorkspaceFolder + let templateFile: vscode.Uri + + let mockGetSpawnEnv: sinon.SinonStub + let mockGetSamCliPath: sinon.SinonStub + let mockChildProcessClass: sinon.SinonStub + let mockSamBuildChildProcess: sinon.SinonStub + + let spyRunInterminal: sinon.SinonSpy - beforeEach(() => { + let registry: CloudFormationTemplateRegistry + + // Dependency clients + beforeEach(async function () { + testFolder = await TestFolder.create() + projectRoot = vscode.Uri.file(testFolder.path) + workspaceFolder = getWorkspaceFolder(testFolder.path) sandbox = sinon.createSandbox() - projectRoot = vscode.Uri.parse('file:///path/to/project') - quickPickItems = [ - { - label: 'Beta features', - data: '--beta-features', - description: 'Enable beta features', - }, - { - label: 'Build in source', - data: '--build-in-source', - description: 'Opts in to build project in the source folder', - }, - { - label: 'Cached', - data: '--cached', - description: 'Reuse build artifacts that have not changed from previous builds', - }, - { - label: 'Debug', - data: '--debug', - description: 'Turn on debug logging to print debug messages and display timestamps', - }, - { - label: 'Parallel', - data: '--parallel', - description: 'Enable parallel builds for AWS SAM template functions and layers', - }, - { - label: 'Skip prepare infra', - data: '--skip-prepare-infra', - description: 'Skip preparation stage when there are no infrastructure changes', - }, - { - label: 'Skip pull image', - data: '--skip-pull-image', - description: 'Skip pulling down the latest Docker image for Lambda runtime', - }, - { - label: 'Use container', - data: '--use-container', - description: 'Build functions with an AWS Lambda-like container', - }, - { - label: 'Save parameters', - data: '--save-params', - description: 'Save to samconfig.toml as default parameters', - }, - ] + registry = await globals.templateRegistry + + // Create template.yaml in temporary test folder and add to registery + templateFile = vscode.Uri.file(await testFolder.write('template.yaml', validTemplateData)) + await registry.addItem(templateFile) + + spyRunInterminal = sandbox.spy(ProcessTerminalUtils, 'runInTerminal') + + mockGetSpawnEnv = sandbox.stub(ResolveEnvModule, 'getSpawnEnv').callsFake( + sandbox.stub().resolves({ + AWS_TOOLING_USER_AGENT: 'AWS-Toolkit-For-VSCode/testPluginVersion', + SAM_CLI_TELEMETRY: '0', + }) + ) }) afterEach(() => { - sandbox.restore() // Restore all stubs after each test + sandbox.restore() + registry.reset() }) - it('should return flags from buildFlagsPrompter when paramsSource is Specify', async () => { - getTestWindow().onDidShowQuickPick(async (picker) => { - await picker.untilReady() - assert.strictEqual(picker.items.length, 9) - assert.strictEqual(picker.title, 'Select build flags') - assert.deepStrictEqual(picker.items, quickPickItems) - const betaFeatures = picker.items[0] - const buildInSource = picker.items[1] - const cached = picker.items[2] - assert.strictEqual(betaFeatures.data, '--beta-features') - assert.strictEqual(buildInSource.data, '--build-in-source') - assert.strictEqual(cached.data, '--cached') - const acceptedItems = [betaFeatures, buildInSource, cached] - picker.acceptItems(...acceptedItems) - }) - - const flags = await createMultiPick(quickPickItems, { - title: 'Select build flags', - ignoreFocusOut: true, - }).prompt() - - assert.deepStrictEqual(flags, JSON.stringify(['--beta-features', '--build-in-source', '--cached'])) - }) + describe(':) path', () => { + beforeEach(() => { + mockGetSamCliPath = sandbox + .stub(SamUtilsModule, 'getSamCliPathAndVersion') + .callsFake(sandbox.stub().resolves({ path: 'sam-cli-path' })) - it('should return config file flag when paramsSource is SamConfig', async () => { - const mockConfigFileUri = vscode.Uri.parse('file:///path/to/samconfig.toml') - const getConfigFileUriStub = sandbox.stub().resolves(mockConfigFileUri) - sandbox.stub(config, 'getConfigFileUri').callsFake(getConfigFileUriStub) + // Mock child process with required properties that get called in ProcessTerminal + mockSamBuildChildProcess = Object.create(ProcessUtilsModule.ChildProcess.prototype, { + stopped: { get: sandbox.stub().returns(false) }, + stop: { value: sandbox.stub().resolves({}) }, + run: { + value: sandbox.stub().resolves({ + exitCode: 0, + stdout: 'Mock successful build command execution ', + stderr: '', + }), + }, + }) + mockChildProcessClass = sandbox.stub(ProcessUtilsModule, 'ChildProcess').returns(mockSamBuildChildProcess) + }) - const flags = await getBuildFlags(ParamsSource.SamConfig, projectRoot, defaultFlags) - assert.deepStrictEqual(flags, ['--config-file', mockConfigFileUri.fsPath]) - }) + afterEach(() => { + sandbox.restore() + }) - it('should return default flags if getConfigFileUri throws an error', async () => { - const getConfigFileUriStub = sinon.stub().rejects(new Error('Config file not found')) - sandbox.stub(config, 'getConfigFileUri').callsFake(getConfigFileUriStub) + const verifyCorrectDependencyCall = () => { + assert(mockGetSamCliPath.calledOnce) + assert(mockChildProcessClass.calledOnce) + assert(mockGetSpawnEnv.calledOnce) + assert(spyRunInterminal.calledOnce) + assert.deepEqual(spyRunInterminal.getCall(0).args, [mockSamBuildChildProcess, 'build']) + } - const flags = await getBuildFlags(ParamsSource.SamConfig, projectRoot, defaultFlags) - assert.deepStrictEqual(flags, defaultFlags) - }) -}) + it('[entry: command palette] with specify flags should instantiate correct process in terminal', async () => { + const prompterTester = PrompterTester.init() + .handleQuickPick('Select a SAM/CloudFormation Template', async (quickPick) => { + await quickPick.untilReady() + assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick('Specify parameter source for build', async (quickPick) => { + // Need sometime to wait for the template to search for template file + await quickPick.untilReady() + assert.strictEqual(quickPick.items.length, 2) + const items = quickPick.items + assert.deepStrictEqual(items[0], { data: ParamsSource.Specify, label: 'Specify build flags' }) + assert.deepStrictEqual(items[1], { + label: 'Use default values', + data: ParamsSource.DefaultValues, + description: 'cached = true, parallel = true, use_container = true', + }) + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick('Select build flags', async (quickPick) => { + await quickPick.untilReady() + + assert.strictEqual(quickPick.items.length, 9) + const item1 = quickPick.items[2] as DataQuickPickItem + const item2 = quickPick.items[3] as DataQuickPickItem + const item3 = quickPick.items[7] as DataQuickPickItem + const item4 = quickPick.items[8] as DataQuickPickItem + + assert.deepStrictEqual(item1, { + label: 'Cached', + data: '--cached', + description: 'Reuse build artifacts that have not changed from previous builds', + }) + assert.deepStrictEqual(item2, { + label: 'Debug', + data: '--debug', + description: 'Turn on debug logging to print debug messages and display timestamps', + }) + assert.deepStrictEqual(item3, { + label: 'Use container', + data: '--use-container', + description: 'Build functions with an AWS Lambda-like container', + }) + assert.deepStrictEqual(item4, { + label: 'Save parameters', + data: '--save-params', + description: 'Save to samconfig.toml as default parameters', + }) + quickPick.acceptItems(item1, item2, item3, item4) + }) + .build() + + // Invoke sync command from command palette + await runBuild() + + assert.deepEqual(mockChildProcessClass.getCall(0).args, [ + 'sam-cli-path', + [ + 'build', + '--cached', + '--debug', + '--use-container', + '--save-params', + '--template', + `${templateFile.fsPath}`, + ], + { + spawnOptions: { + cwd: projectRoot?.fsPath, + env: { + AWS_TOOLING_USER_AGENT: 'AWS-Toolkit-For-VSCode/testPluginVersion', + SAM_CLI_TELEMETRY: '0', + }, + }, + }, + ]) + prompterTester.assertCallAll() + verifyCorrectDependencyCall() + }) + + it('[entry: appbuilder node] with default flags should instantiate correct process in terminal', async () => { + const prompterTester = PrompterTester.init() + .handleQuickPick('Specify parameter source for build', async (quickPick) => { + // Need sometime to wait for the template to search for template file + await quickPick.untilReady() + + assert.strictEqual(quickPick.items.length, 2) + const items = quickPick.items + assert.strictEqual(quickPick.items.length, 2) + assert.deepStrictEqual(items[0], { data: ParamsSource.Specify, label: 'Specify build flags' }) + assert.deepStrictEqual(items[1].label, 'Use default values') + quickPick.acceptItem(quickPick.items[1]) + }) + .build() + + // Invoke sync command from command palette + const expectedSamAppLocation = { + workspaceFolder: workspaceFolder, + samTemplateUri: templateFile, + projectRoot: projectRoot, + } + + await runBuild(new AppNode(expectedSamAppLocation)) + + assert.deepEqual(mockChildProcessClass.getCall(0).args, [ + 'sam-cli-path', + [ + 'build', + '--cached', + '--parallel', + '--save-params', + '--use-container', + '--template', + `${templateFile.fsPath}`, + ], + { + spawnOptions: { + cwd: projectRoot?.fsPath, + env: { + AWS_TOOLING_USER_AGENT: 'AWS-Toolkit-For-VSCode/testPluginVersion', + SAM_CLI_TELEMETRY: '0', + }, + }, + }, + ]) + verifyCorrectDependencyCall() + prompterTester.assertCallAll() + }) + + it('[entry: command palette] use samconfig should instantiate correct process in terminal', async () => { + const samconfigFile = vscode.Uri.file(await testFolder.write('samconfig.toml', samconfigCompleteData)) + + const prompterTester = PrompterTester.init() + .handleQuickPick('Select a SAM/CloudFormation Template', async (quickPick) => { + await quickPick.untilReady() + assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick('Specify parameter source for build', async (quickPick) => { + // Need sometime to wait for the template to search for template file + await quickPick.untilReady() -describe('createParamsSourcePrompter', () => { - it('should return a prompter with the correct items with no valid samconfig', () => { - const expectedItems: DataQuickPickItem[] = [ - { - label: 'Specify build flags', - data: ParamsSource.Specify, - }, - { - label: 'Use default values', - data: ParamsSource.DefaultValues, - description: 'cached = true, parallel = true, use_container = true', - }, - ] - const prompter = createParamsSourcePrompter(false) - const quickPick = prompter.quickPick - assert.strictEqual(quickPick.title, 'Specify parameters for build') - assert.strictEqual(quickPick.placeholder, 'Select configuration options for sam build') - assert.strictEqual(quickPick.items.length, 2) - assert.deepStrictEqual(quickPick.items, expectedItems) + assert.strictEqual(quickPick.items.length, 2) + const items = quickPick.items + + assert.deepStrictEqual(items[1], { + label: 'Use default values from samconfig', + data: ParamsSource.SamConfig, + }) + quickPick.acceptItem(quickPick.items[1]) + }) + .build() + + await runBuild() + + assert.deepEqual(mockChildProcessClass.getCall(0).args, [ + 'sam-cli-path', + ['build', '--config-file', `${samconfigFile.fsPath}`, '--template', `${templateFile.fsPath}`], + { + spawnOptions: { + cwd: projectRoot?.fsPath, + env: { + AWS_TOOLING_USER_AGENT: 'AWS-Toolkit-For-VSCode/testPluginVersion', + SAM_CLI_TELEMETRY: '0', + }, + }, + }, + ]) + verifyCorrectDependencyCall() + prompterTester.assertCallAll() + }) }) - it('should return a prompter with the correct items with valid samconfig', () => { - const expectedItems: DataQuickPickItem[] = [ - { - label: 'Specify build flags', - data: ParamsSource.Specify, - }, - { - label: 'Use default values from samconfig', - data: ParamsSource.SamConfig, - }, - ] - const prompter = createParamsSourcePrompter(true) - const quickPick = prompter.quickPick - assert.strictEqual(quickPick.title, 'Specify parameters for build') - assert.strictEqual(quickPick.placeholder, 'Select configuration options for sam build') - assert.strictEqual(quickPick.items.length, 2) - assert.deepStrictEqual(quickPick.items, expectedItems) + describe(':( path', () => { + let appNode: AppNode + beforeEach(async () => { + mockGetSamCliPath = sandbox + .stub(SamUtilsModule, 'getSamCliPathAndVersion') + .callsFake(sandbox.stub().resolves({ path: 'sam-cli-path' })) + + appNode = new AppNode({ + workspaceFolder: workspaceFolder, + samTemplateUri: templateFile, + projectRoot: projectRoot, + }) + await testFolder.write('samconfig.toml', samconfigCompleteData) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should abort when customer cancel build wizard', async () => { + getTestWindow().onDidShowQuickPick(async (picker) => { + await picker.untilReady() + picker.dispose() + }) + + try { + await runBuild(appNode) + assert.fail('should have thrown CancellationError') + } catch (error: any) { + assert(error instanceof CancellationError) + assert.strictEqual(error.agent, 'user') + } + }) + + it('should throw ToolkitError when sync command fail', async () => { + const prompterTester = PrompterTester.init() + .handleQuickPick('Specify parameter source for build', async (quickPick) => { + await quickPick.untilReady() + assert.deepStrictEqual(quickPick.items[1].label, 'Use default values from samconfig') + quickPick.acceptItem(quickPick.items[1]) + }) + .build() + + // Mock child process with required properties that get called in ProcessTerminal + mockSamBuildChildProcess = Object.create(ProcessUtilsModule.ChildProcess.prototype, { + stopped: { get: sandbox.stub().returns(false) }, + stop: { value: sandbox.stub().resolves({}) }, + run: { + value: sandbox.stub().resolves({ + exitCode: -1, + stdout: 'Mock build command execution failure', + stderr: '', + }), + }, + }) + mockChildProcessClass = sandbox.stub(ProcessUtilsModule, 'ChildProcess').returns(mockSamBuildChildProcess) + + try { + await runBuild(appNode) + assert.fail('should have thrown ToolkitError') + } catch (error: any) { + assert(error instanceof ToolkitError) + assert.strictEqual(error.message, 'Failed to build SAM template') + } + prompterTester.assertCallAll() + }) }) }) diff --git a/packages/core/src/test/shared/sam/deploy.test.ts b/packages/core/src/test/shared/sam/deploy.test.ts index 01eb6df3423..5e1d22297a7 100644 --- a/packages/core/src/test/shared/sam/deploy.test.ts +++ b/packages/core/src/test/shared/sam/deploy.test.ts @@ -6,19 +6,11 @@ import * as vscode from 'vscode' import { CloudFormation, S3 } from 'aws-sdk' import { AppNode } from '../../../awsService/appBuilder/explorer/nodes/appNode' import { assertTelemetry, getWorkspaceFolder, TestFolder } from '../../testUtil' -import { - BucketSource, - DeployParams, - DeployWizard, - ParamsSource, - getDeployWizard, - runDeploy, -} from '../../../shared/sam/deploy' -import * as UtilsModule from '../../../shared/sam/utils' +import { DeployParams, DeployWizard, getDeployWizard, runDeploy } from '../../../shared/sam/deploy' import { globals, ToolkitError } from '../../../shared' import sinon from 'sinon' import { samconfigCompleteData, samconfigInvalidData, validTemplateData } from './samTestUtils' - +import * as SamUtilsModule from '../../../shared/sam/utils' import assert from 'assert' import { getTestWindow } from '../vscode/window' import { DefaultCloudFormationClient } from '../../../shared/clients/cloudFormationClient' @@ -35,11 +27,14 @@ import * as ResolveEnvModule from '../../../shared/env/resolveEnv' import * as SamConfiModule from '../../../shared/sam/config' import { RequiredProps } from '../../../shared/utilities/tsUtils' import { UserAgent as __UserAgent } from '@smithy/types' -import { TemplateItem } from '../../../shared/sam/sync' + import { SamAppLocation } from '../../../awsService/appBuilder/explorer/samProject' import { CancellationError } from '../../../shared/utilities/timeoutUtils' +import { TemplateItem } from '../../../shared/ui/sam/templatePrompter' +import { ParamsSource } from '../../../shared/ui/sam/paramsSourcePrompter' +import { BucketSource } from '../../../shared/ui/sam/bucketPrompter' -describe('DeployWizard', async function () { +describe('SAM DeployWizard', async function () { let sandbox: sinon.SinonSandbox let testFolder: TestFolder let projectRoot: vscode.Uri @@ -100,7 +95,7 @@ describe('DeployWizard', async function () { .handleInputBox('Specify SAM parameter value for DestinationBucketName', (inputBox) => { inputBox.acceptValue('my-destination-bucket-name') }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() @@ -171,7 +166,7 @@ describe('DeployWizard', async function () { .handleInputBox('Specify SAM parameter value for DestinationBucketName', (inputBox) => { inputBox.acceptValue('my-destination-bucket-name') }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() @@ -229,7 +224,7 @@ describe('DeployWizard', async function () { // provide testWindow so that we can call other api const testWindow = getTestWindow() - PrompterTester.init(testWindow) + PrompterTester.init({ testWindow }) .handleQuickPick('Select a SAM/CloudFormation Template', async (quickPick) => { // Need sometime to wait for the template to search for template file await quickPick.untilReady() @@ -237,7 +232,7 @@ describe('DeployWizard', async function () { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() @@ -311,7 +306,7 @@ describe('DeployWizard', async function () { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() @@ -372,7 +367,7 @@ describe('DeployWizard', async function () { .handleInputBox('Specify SAM parameter value for DestinationBucketName', (inputBox) => { inputBox.acceptValue('my-destination-bucket-name') }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() @@ -443,7 +438,7 @@ describe('DeployWizard', async function () { .handleInputBox('Specify SAM parameter value for DestinationBucketName', (inputBox) => { inputBox.acceptValue('my-destination-bucket-name') }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() @@ -500,7 +495,7 @@ describe('DeployWizard', async function () { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() assert.strictEqual(quickPick.items.length, 2) @@ -584,7 +579,7 @@ describe('DeployWizard', async function () { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for deploy', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() assert.strictEqual(quickPick.items.length, 3) @@ -655,7 +650,7 @@ describe('SAM Deploy', () => { describe(':) path', () => { beforeEach(() => { mockGetSamCliPath = sandbox.stub().resolves({ path: 'sam-cli-path' }) - sandbox.stub(UtilsModule, 'getSamCliPathAndVersion').callsFake(mockGetSamCliPath) + sandbox.stub(SamUtilsModule, 'getSamCliPathAndVersion').callsFake(mockGetSamCliPath) mockChildProcess = sandbox.stub().resolves({}) sandbox.stub(ProcessUtilsModule, 'ChildProcess').callsFake(mockChildProcess) @@ -935,7 +930,7 @@ describe('SAM Deploy', () => { // Break point mockGetSamCliPath = sandbox - .stub(UtilsModule, 'getSamCliPathAndVersion') + .stub(SamUtilsModule, 'getSamCliPathAndVersion') .rejects( new ToolkitError('SAM CLI version 1.53.0 or higher is required', { code: 'VersionTooLow' }) ) @@ -963,7 +958,7 @@ describe('SAM Deploy', () => { // Happy Stub sandbox.stub(DeployWizard.prototype, 'run').resolves(mockDeployParams) mockGetSamCliPath = sandbox.stub().resolves({ path: 'sam-cli-path' }) - sandbox.stub(UtilsModule, 'getSamCliPathAndVersion').callsFake(mockGetSamCliPath) + sandbox.stub(SamUtilsModule, 'getSamCliPathAndVersion').callsFake(mockGetSamCliPath) mockChildProcess = sandbox.stub().resolves({}) sandbox.stub(ProcessUtilsModule, 'ChildProcess').callsFake(mockChildProcess) @@ -989,7 +984,7 @@ describe('SAM Deploy', () => { // Happy Stub sandbox.stub(DeployWizard.prototype, 'run').resolves(mockDeployParams) mockGetSamCliPath = sandbox.stub().resolves({ path: 'sam-cli-path' }) - sandbox.stub(UtilsModule, 'getSamCliPathAndVersion').callsFake(mockGetSamCliPath) + sandbox.stub(SamUtilsModule, 'getSamCliPathAndVersion').callsFake(mockGetSamCliPath) mockChildProcess = sandbox.stub().resolves({}) sandbox.stub(ProcessUtilsModule, 'ChildProcess').callsFake(mockChildProcess) diff --git a/packages/core/src/test/shared/sam/sync.test.ts b/packages/core/src/test/shared/sam/sync.test.ts index 10bff92a0f9..5ccf195fb5b 100644 --- a/packages/core/src/test/shared/sam/sync.test.ts +++ b/packages/core/src/test/shared/sam/sync.test.ts @@ -4,38 +4,29 @@ */ import * as vscode from 'vscode' -import * as SyncModule from '../../../shared/sam/sync' -import * as UtilsModule from '../../../shared/sam/utils' +import * as SamUtilsModule from '../../../shared/sam/utils' import * as ProcessTerminalUtils from '../../../shared/sam/processTerminal' -import * as awsConsole from '../../../shared/awsConsole' import * as S3ClientModule from '../../../shared/clients/s3Client' import * as SamConfigModule from '../../../shared/sam/config' import * as ResolveEnvModule from '../../../shared/env/resolveEnv' import * as ProcessUtilsModule from '../../../shared/utilities/processUtils' import { CancellationError } from '../../../shared/utilities/timeoutUtils' import * as CloudFormationClientModule from '../../../shared/clients/cloudFormationClient' -import * as buttons from '../../../shared/ui/buttons' -import assert, { fail } from 'assert' import { - createBucketPrompter, - createEcrPrompter, createEnvironmentPrompter, - createStackPrompter, - createTemplatePrompter, ensureBucket, + ensureRepo, getSyncParamsFromConfig, getSyncWizard, - ParamsSource, - paramsSourcePrompter, prepareSyncParams, runSync, saveAndBindArgs, syncFlagItems, SyncParams, SyncWizard, - TemplateItem, } from '../../../shared/sam/sync' + import { createBaseImageTemplate, createBaseTemplate, @@ -51,13 +42,11 @@ import { createMultiPick, DataQuickPickItem } from '../../../shared/ui/pickerPro import sinon from 'sinon' import { getTestWindow } from '../vscode/window' import { DefaultS3Client } from '../../../shared/clients/s3Client' -import { AsyncCollection } from '../../../shared/utilities/asyncCollection' import { RequiredProps } from '../../../shared/utilities/tsUtils' import S3 from 'aws-sdk/clients/s3' import { DefaultCloudFormationClient } from '../../../shared/clients/cloudFormationClient' import CloudFormation from 'aws-sdk/clients/cloudformation' import { intoCollection } from '../../../shared/utilities/collectionUtils' -import { DefaultEcrClient, EcrRepository } from '../../../shared/clients/ecrClient' import { SamConfig, Environment, parseConfig } from '../../../shared/sam/config' import { RegionProvider } from '../../../shared/regions/regionProvider' import { Region } from '../../../shared/regions/endpoints' @@ -65,23 +54,20 @@ import { RegionNode } from '../../../awsexplorer/regionNode' import { getProjectRootUri } from '../../../shared/sam/utils' import { AppNode } from '../../../awsService/appBuilder/explorer/nodes/appNode' import * as Cfn from '../../../shared/cloudformation/cloudformation' +import { getWorkspaceFolder, TestFolder } from '../../testUtil' +import { TemplateItem } from '../../../shared/ui/sam/templatePrompter' +import { ParamsSource } from '../../../shared/ui/sam/paramsSourcePrompter' import { CloudFormationTemplateRegistry } from '../../../shared/fs/templateRegistry' -import { WatchedItem } from '../../../shared/fs/watchedFiles' + import { samconfigCompleteData, samconfigInvalidData, validTemplateData } from '../../shared/sam/samTestUtils' -//import { beforeEach } from 'mocha' -import { - assertEqualPaths, - assertTelemetry, - assertTelemetryCurried, - getWorkspaceFolder, - TestFolder, -} from '../../testUtil' -import { samSyncUrl } from '../../../shared/constants' +import { assertTelemetry, assertTelemetryCurried } from '../../testUtil' import { PrompterTester } from '../wizards/prompterTester' import { createTestRegionProvider } from '../regions/testUtil' import { ToolkitPromptSettings } from '../../../shared/settings' +import { DefaultEcrClient } from '../../../shared/clients/ecrClient' +import assert from 'assert' -describe('SyncWizard', async function () { +describe('SAM SyncWizard', async function () { const createTester = async (params?: Partial) => createWizardTester(new SyncWizard({ deployType: 'code', ...params }, await globals.templateRegistry)) @@ -127,7 +113,7 @@ describe('SyncWizard', async function () { const template = { uri: templateUri, data: createBaseImageTemplate() } const tester = await createTester({ template, - paramsSource: ParamsSource.Flags, + paramsSource: ParamsSource.Specify, }) tester.ecrRepoUri.assertShow() }) @@ -157,740 +143,7 @@ describe('SyncWizard', async function () { }) }) -describe('prepareSyncParams', function () { - let tempDir: vscode.Uri - - beforeEach(async function () { - tempDir = vscode.Uri.file(await makeTemporaryToolkitFolder()) - }) - - afterEach(async function () { - await fs.delete(tempDir, { recursive: true }) - }) - - it('uses region if given a tree node', async function () { - const params = await prepareSyncParams( - new (class extends AWSTreeNodeBase { - public override readonly regionCode = 'foo' - })('') - ) - - assert.strictEqual(params.region, 'foo') - }) - - async function makeTemplateItem(dir: vscode.Uri) { - const uri = vscode.Uri.joinPath(dir, 'template.yaml') - const data = makeSampleSamTemplateYaml(true) - await fs.writeFile(uri, JSON.stringify(data)) - - return { uri, data } - } - - it('loads template if given a URI', async function () { - const template = await makeTemplateItem(tempDir) - - const params = await prepareSyncParams(template.uri) - assert.strictEqual(params.template?.uri.fsPath, template.uri.fsPath) - assert.deepStrictEqual(params.template?.data, template.data) - }) - - it('skips dependency layers by default', async function () { - const template = await makeTemplateItem(tempDir) - - const params = await prepareSyncParams(template.uri) - assert.strictEqual(params.skipDependencyLayer, true) - }) - - describe('samconfig.toml', function () { - async function makeDefaultConfig(dir: vscode.Uri, body: string) { - const uri = vscode.Uri.joinPath(dir, 'samconfig.toml') - const data = ` - [default.sync.parameters] - ${body} -` - await fs.writeFile(uri, data) - - return uri - } - - async function getParams(body: string, dir = tempDir) { - const config = await makeDefaultConfig(dir, body) - - return prepareSyncParams(config) - } - - it('throws on non-string values', async function () { - await assert.rejects(() => getParams(`region = 0`), ToolkitError) - }) - - it('does not fail on missing values', async function () { - const params = await getParams(`region = "bar"`) - assert.strictEqual(params.region, 'bar') - }) - - it('sets the project root as the parent directory', async function () { - const params = await getParams(`region = "bar"`, tempDir) - assert.strictEqual(params.projectRoot?.fsPath, tempDir.fsPath) - }) - - it('uses the depdency layer option if provided', async function () { - const params = await getParams(`dependency_layer = true`, tempDir) - assert.strictEqual(params.skipDependencyLayer, false) - }) - - it('can load a relative template param', async function () { - const template = await makeTemplateItem(tempDir) - const params = await getParams(`template = "./template.yaml"`) - assert.deepStrictEqual(params.template?.data, template.data) - }) - - it('can load an absolute template param', async function () { - const template = await makeTemplateItem(tempDir) - const params = await getParams(`template = '${template.uri.fsPath}'`) - assert.deepStrictEqual(params.template?.data, template.data) - }) - - it('can load a relative template param without a path seperator', async function () { - const template = await makeTemplateItem(tempDir) - const params = await getParams(`template = "template.yaml"`) - assert.deepStrictEqual(params.template?.data, template.data) - }) - - it('can load a template param using an alternate key', async function () { - const template = await makeTemplateItem(tempDir) - const params = await getParams(`template_file = "template.yaml"`) - assert.deepStrictEqual(params.template?.data, template.data) - }) - - it('can use global params', async function () { - const params = await getParams(` - region = "bar" - [default.global.parameters] - stack_name = "my-app" - `) - assert.strictEqual(params.stackName, 'my-app') - }) - - it('prefers using the sync section over globals', async function () { - const params = await getParams(` - stack_name = "my-sync-app" - [default.global.parameters] - stack_name = "my-app" - `) - assert.strictEqual(params.stackName, 'my-sync-app') - }) - - it('loads all values if found', async function () { - const params = await getParams(` - region = "bar" - stack_name = "my-app" - s3_bucket = "my-bucket" - image_repository = "12345679010.dkr.ecr.bar.amazonaws.com/repo" - `) - assert.strictEqual(params.region, 'bar') - assert.strictEqual(params.stackName, 'my-app') - assert.strictEqual(params.bucketName, 'my-bucket') - assert.strictEqual(params.ecrRepoUri, '12345679010.dkr.ecr.bar.amazonaws.com/repo') - }) - }) -}) - -describe('paramsSourcePrompter', () => { - it('should return a prompter with the correct items with no valid samconfig', () => { - const expectedItems: DataQuickPickItem[] = [ - { - label: 'Specify required parameters and save as defaults', - data: ParamsSource.SpecifyAndSave, - }, - { - label: 'Specify required parameters', - data: ParamsSource.Flags, - }, - ] - const prompter = paramsSourcePrompter(false) - const quickPick = prompter.quickPick - assert.strictEqual(quickPick.title, 'Specify parameters for deploy') - assert.strictEqual(quickPick.placeholder, 'Press enter to proceed with highlighted option') - assert.strictEqual(quickPick.items.length, 2) - assert.deepStrictEqual(quickPick.items, expectedItems) - }) - - it('should return a prompter with the correct items with valid samconfig', () => { - const expectedItems: DataQuickPickItem[] = [ - { - label: 'Specify required parameters and save as defaults', - data: ParamsSource.SpecifyAndSave, - }, - { - label: 'Specify required parameters', - data: ParamsSource.Flags, - }, - { - label: 'Use default values from samconfig', - data: ParamsSource.SamConfig, - }, - ] - const prompter = paramsSourcePrompter(true) - const quickPick = prompter.quickPick - assert.strictEqual(quickPick.title, 'Specify parameters for deploy') - assert.strictEqual(quickPick.placeholder, 'Press enter to proceed with highlighted option') - assert.strictEqual(quickPick.items.length, 3) - assert.deepStrictEqual(quickPick.items, expectedItems) - }) -}) - -describe('syncFlagsPrompter', () => { - let sandbox: sinon.SinonSandbox - let acceptedItems: DataQuickPickItem[] - - beforeEach(() => { - sandbox = sinon.createSandbox() - }) - - afterEach(() => { - sandbox.restore() // Restore all stubs after each test - }) - - it('should return selected flags from buildFlagsPrompter', async () => { - getTestWindow().onDidShowQuickPick(async (picker) => { - await picker.untilReady() - assert.strictEqual(picker.items.length, 9) - assert.strictEqual(picker.title, 'Specify parameters for sync') - assert.deepStrictEqual(picker.items, syncFlagItems) - const buildInSource = picker.items[0] - const code = picker.items[1] - const dependencyLayer = picker.items[2] - assert.strictEqual(buildInSource.data, '--build-in-source') - assert.strictEqual(code.data, '--code') - assert.strictEqual(dependencyLayer.data, '--dependency-layer') - acceptedItems = [buildInSource, code, dependencyLayer] - picker.acceptItems(...acceptedItems) - }) - - const flags = await createMultiPick(syncFlagItems, { - title: 'Specify parameters for sync', - placeholder: 'Press enter to proceed with highlighted option', - }).prompt() - - assert.deepStrictEqual(flags, JSON.stringify(acceptedItems.map((i) => i.data))) - }) -}) - -describe('createBucketPrompter', () => { - let sandbox: sinon.SinonSandbox - const s3Client = new DefaultS3Client('us-east-1', 'aws') - - beforeEach(() => { - sandbox = sinon.createSandbox() - }) - - afterEach(() => { - sandbox.restore() - }) - - it('should create a prompter with existing buckets', () => { - // Arrange - const buckets = [ - { Name: 'bucket1', region: 'us-east-1' }, - { Name: 'bucket2', region: 'us-east-1' }, - { Name: 'bucket3', region: 'us-east-1' }, - ] as unknown as AsyncCollection & { readonly region: string }> - - const stub = sandbox.stub(s3Client, 'listBucketsIterable').callsFake(() => { - return buckets - }) - sandbox.stub(SyncModule, 'getRecentResponse').returns(undefined) // Mock recent bucket - - // Act - const prompter = createBucketPrompter(s3Client) - - // Assert - assert.ok(stub.calledOnce) - const expectedItems = buckets.map((b) => [ - { - label: b.Name, - data: b.Name, - recentlyUsed: false, - }, - ]) - assert.strictEqual(prompter.quickPick.title, 'Select an S3 Bucket') - assert.strictEqual(prompter.quickPick.placeholder, 'Select a bucket (or enter a name to create one)') - assert.strictEqual(prompter.quickPick.items.length, 3) - assert.deepStrictEqual(prompter.quickPick.items, expectedItems) - }) - - it('should include no items found message if no stacks exist', () => { - const stub = sandbox.stub(s3Client, 'listBucketsIterable').callsFake(() => { - return [] as unknown as AsyncCollection & { readonly region: string }> - }) - sandbox.stub(SyncModule, 'getRecentResponse').returns(undefined) // Mock recent bucket - - // Act - const prompter = createBucketPrompter(s3Client) - - // Assert - assert.ok(stub.calledOnce) - assert.strictEqual(prompter.quickPick.title, 'Select an S3 Bucket') - assert.strictEqual(prompter.quickPick.placeholder, 'Select a bucket (or enter a name to create one)') - assert.strictEqual(prompter.quickPick.items.length, 1) - assert.strictEqual( - prompter.quickPick.items[0].label, - 'No S3 buckets for region "us-east-1". Enter a name to create a new one.' - ) - }) -}) - -describe('createStackPrompter', () => { - let sandbox: sinon.SinonSandbox - const cfnClient = new DefaultCloudFormationClient('us-east-1') - - beforeEach(() => { - sandbox = sinon.createSandbox() - }) - - afterEach(() => { - sandbox.restore() - }) - - it('should create a prompter with existing stacks', async () => { - // Arrange - const stackSummaries: CloudFormation.StackSummary[][] = [ - [ - { - StackName: 'stack1', - StackStatus: 'CREATE_COMPLETE', - CreationTime: new Date(), - } as CloudFormation.StackSummary, - { - StackName: 'stack2', - StackStatus: 'CREATE_COMPLETE', - CreationTime: new Date(), - } as CloudFormation.StackSummary, - { - StackName: 'stack3', - StackStatus: 'CREATE_COMPLETE', - CreationTime: new Date(), - } as CloudFormation.StackSummary, - ], - ] - const expectedItems = [ - { - label: 'stack1', - data: 'stack1', - description: undefined, - invalidSelection: false, - recentlyUsed: false, - }, - { - label: 'stack2', - data: 'stack2', - description: undefined, - invalidSelection: false, - recentlyUsed: false, - }, - { - label: 'stack3', - data: 'stack3', - description: undefined, - invalidSelection: false, - recentlyUsed: false, - }, - ] - const listAllStacksStub = sandbox.stub(cfnClient, 'listAllStacks').returns(intoCollection(stackSummaries)) - sandbox.stub(SyncModule, 'getRecentResponse').returns(undefined) - const createCommonButtonsStub = sandbox.stub(buttons, 'createCommonButtons') - sandbox - .stub(awsConsole, 'getAwsConsoleUrl') - .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) - - // Act - const prompter = createStackPrompter(cfnClient) - await new Promise((f) => setTimeout(f, 50)) - - // Assert - assert.ok(createCommonButtonsStub.calledOnce) - assert.ok( - createCommonButtonsStub.calledWithExactly( - samSyncUrl, - vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) - ) - ) - assert.ok(listAllStacksStub.calledOnce) - assert.strictEqual(prompter.quickPick.title, 'Select a CloudFormation Stack') - assert.strictEqual(prompter.quickPick.placeholder, 'Select a stack (or enter a name to create one)') - assert.strictEqual(prompter.quickPick.items.length, 3) - assert.deepStrictEqual(prompter.quickPick.items, expectedItems) - }) - - it('should include no items found message if no stacks exist', async () => { - const listAllStacksStub = sandbox.stub(cfnClient, 'listAllStacks').returns(intoCollection([])) - sandbox.stub(SyncModule, 'getRecentResponse').returns(undefined) - const createCommonButtonsStub = sandbox.stub(buttons, 'createCommonButtons') - sandbox - .stub(awsConsole, 'getAwsConsoleUrl') - .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) - - // Act - const prompter = createStackPrompter(cfnClient) - await new Promise((f) => setTimeout(f, 50)) - - // Assert - assert.ok(createCommonButtonsStub.calledOnce) - assert.ok( - createCommonButtonsStub.calledWithExactly( - samSyncUrl, - vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) - ) - ) - assert.ok(listAllStacksStub.calledOnce) - assert.strictEqual(prompter.quickPick.title, 'Select a CloudFormation Stack') - assert.strictEqual(prompter.quickPick.placeholder, 'Select a stack (or enter a name to create one)') - assert.strictEqual(prompter.quickPick.items.length, 1) - assert.deepStrictEqual( - prompter.quickPick.items[0].label, - 'No stacks in region "us-east-1". Enter a name to create a new one.' - ) - assert.deepStrictEqual(prompter.quickPick.items[0].data, undefined) - }) -}) - -describe('createEcrPrompter', () => { - let sandbox: sinon.SinonSandbox - const ecrClient = new DefaultEcrClient('us-east-1') - - beforeEach(() => { - sandbox = sinon.createSandbox() - }) - - afterEach(() => { - sandbox.restore() - }) - - it('should create a prompter with existing repos', async () => { - // Arrange - const ecrRepos: EcrRepository[][] = [ - [ - { - repositoryName: 'repo1', - repositoryUri: 'repoUri1', - repositoryArn: 'repoArn1', - } as EcrRepository, - { - repositoryName: 'repo2', - repositoryUri: 'repoUri2', - repositoryArn: 'repoArn2', - } as EcrRepository, - { - repositoryName: 'repo3', - repositoryUri: 'repoUri3', - repositoryArn: 'repoArn3', - } as EcrRepository, - ], - ] - const expectedItems = [ - { - label: 'repo1', - data: 'repoUri1', - detail: 'repoArn1', - recentlyUsed: false, - }, - { - label: 'repo2', - data: 'repoUri2', - detail: 'repoArn2', - recentlyUsed: false, - }, - { - label: 'repo3', - data: 'repoUri3', - detail: 'repoArn3', - recentlyUsed: false, - }, - ] - const listAllRepositoriesStub = sandbox.stub(ecrClient, 'listAllRepositories').returns(intoCollection(ecrRepos)) - sandbox.stub(SyncModule, 'getRecentResponse').returns(undefined) - const createCommonButtonsStub = sandbox.stub(buttons, 'createCommonButtons') - sandbox - .stub(awsConsole, 'getAwsConsoleUrl') - .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) - - // Act - const prompter = createEcrPrompter(ecrClient) - await new Promise((f) => setTimeout(f, 50)) - - // Assert - assert.ok(createCommonButtonsStub.calledOnce) - assert.ok( - createCommonButtonsStub.calledWithExactly( - samSyncUrl, - vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) - ) - ) - assert.ok(listAllRepositoriesStub.calledOnce) - assert.strictEqual(prompter.quickPick.title, 'Select an ECR Repository') - assert.strictEqual(prompter.quickPick.placeholder, 'Select a repository (or enter a name to create one)') - assert.strictEqual(prompter.quickPick.items.length, 3) - assert.deepStrictEqual(prompter.quickPick.items, expectedItems) - }) - - it('should include no items found message if no repos exist', async () => { - const listAllStacksStub = sandbox.stub(ecrClient, 'listAllRepositories').returns(intoCollection([])) - sandbox.stub(SyncModule, 'getRecentResponse').returns(undefined) - const createCommonButtonsStub = sandbox.stub(buttons, 'createCommonButtons') - sandbox - .stub(awsConsole, 'getAwsConsoleUrl') - .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) - - // Act - const prompter = createEcrPrompter(ecrClient) - await new Promise((f) => setTimeout(f, 50)) - - // Assert - assert.ok(createCommonButtonsStub.calledOnce) - assert.ok( - createCommonButtonsStub.calledWithExactly( - samSyncUrl, - vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) - ) - ) - assert.ok(listAllStacksStub.calledOnce) - assert.strictEqual(prompter.quickPick.title, 'Select an ECR Repository') - assert.strictEqual(prompter.quickPick.placeholder, 'Select a repository (or enter a name to create one)') - assert.strictEqual(prompter.quickPick.items.length, 1) - assert.deepStrictEqual( - prompter.quickPick.items[0].label, - 'No ECR repositories in region "us-east-1". Enter a name to create a new one.' - ) - assert.deepStrictEqual(prompter.quickPick.items[0].data, undefined) - }) -}) - -describe('createEnvironmentPrompter', () => { - let sandbox: sinon.SinonSandbox - let config: SamConfig - let listEnvironmentsStub: sinon.SinonStub - - beforeEach(() => { - sandbox = sinon.createSandbox() - // Create a stub for the SamConfig instance - config = new SamConfig(vscode.Uri.parse('dummy://uri')) - listEnvironmentsStub = sandbox.stub(config, 'listEnvironments') - }) - - afterEach(() => { - sandbox.restore() - }) - - it('should create a prompter with existing samconfig env', () => { - // Arrange - const defaultEnv: Environment = { - name: 'default', - commands: {}, - } - const stagingEnv: Environment = { - name: 'staging', - commands: {}, - } - const prodEnv: Environment = { - name: 'prod', - commands: {}, - } - const envs: Environment[] = [defaultEnv, stagingEnv, prodEnv] - - listEnvironmentsStub.returns(envs) - sandbox.stub(SyncModule, 'getRecentResponse').returns(undefined) - - // Act - const prompter = createEnvironmentPrompter(config) - - // Assert - assert.ok(listEnvironmentsStub.calledOnce) - assert.strictEqual(prompter.quickPick.title, 'Select an Environment to Use') - assert.strictEqual(prompter.quickPick.placeholder, 'Select an environment') - assert.strictEqual(prompter.quickPick.items.length, 3) - assert.deepStrictEqual(prompter.quickPick.items, [ - { - label: 'default', - data: defaultEnv, - recentlyUsed: false, - }, - { - label: 'staging', - data: stagingEnv, - recentlyUsed: false, - }, - { - label: 'prod', - data: prodEnv, - recentlyUsed: false, - }, - ]) - }) -}) - -describe('createTemplatePrompter', () => { - let registry: CloudFormationTemplateRegistry - let sandbox: sinon.SinonSandbox - - beforeEach(() => { - sandbox = sinon.createSandbox() - //Create a mock instance of CloudFormationTemplateRegistry - registry = { - items: [ - { path: '/path/to/template1.yaml', item: {} } as WatchedItem, - { path: '/path/to/template2.yaml', item: {} } as WatchedItem, - ], - } as CloudFormationTemplateRegistry // Typecasting to match expected type - }) - - afterEach(() => { - sandbox.restore() - }) - - it('should create quick pick items from registry items', () => { - // Arrange - const recentTemplatePathStub = sinon.stub().returns(undefined) - sandbox.replace(SyncModule, 'getRecentResponse', recentTemplatePathStub) - const workspaceFolder = vscode.workspace.workspaceFolders?.[0] - assert.ok(workspaceFolder) - - const prompter = createTemplatePrompter(registry) - - // Assert - assert.strictEqual(prompter.quickPick.items.length, 2) - assertEqualPaths(prompter.quickPick.items[0].label, '/path/to/template1.yaml') - //assert.strictEqual(prompter.quickPick.items[0].label, '/path/to/template1.yaml') - assertEqualPaths(prompter.quickPick.items[1].label, '/path/to/template2.yaml') - assert.strictEqual(prompter.quickPick.title, 'Select a SAM/CloudFormation Template') - assert.strictEqual(prompter.quickPick.placeholder, 'Select a SAM/CloudFormation Template') - }) -}) - -describe('prepareSyncParams', () => { - let sandbox: sinon.SinonSandbox - beforeEach(() => { - sandbox = sinon.createSandbox() - }) - afterEach(() => { - sandbox.restore() - }) - - it('should return correct params from region node', async () => { - const regionNode = new RegionNode({ name: 'us-east-1', id: 'IAD' } as Region, {} as RegionProvider) - const result = await prepareSyncParams(regionNode) - assert.deepStrictEqual(result, { skipDependencyLayer: true, region: 'IAD' }) - }) - - it('should return correct params from appBuilder', async () => { - // setup appNode - const workspaceFolder = vscode.workspace.workspaceFolders?.[0] - assert.ok(workspaceFolder) - const templateUri = vscode.Uri.file('file://mock/path/project/file') - const projectRootUri = getProjectRootUri(templateUri) - const samAppLocation = { - samTemplateUri: templateUri, - workspaceFolder: workspaceFolder, - projectRoot: projectRootUri, - } - const appNode = new AppNode(samAppLocation) - const tryLoadStub = sandbox.stub(Cfn, 'load') - - tryLoadStub.resolves({} as Cfn.Template) - - const templateItem = { - uri: templateUri, - data: {}, - } - - // Act - const result = await prepareSyncParams(appNode) - - // Assert - assert.deepStrictEqual(result, { - skipDependencyLayer: true, - template: templateItem, - projectRoot: projectRootUri, - }) - }) - - it('should return correct params for undefined input', async () => { - const result = await prepareSyncParams(undefined) - assert.deepStrictEqual(result, { skipDependencyLayer: true }) - }) -}) - -describe('getSyncParamsFromConfig', () => { - let sandbox: sinon.SinonSandbox - beforeEach(() => { - sandbox = sinon.createSandbox() - }) - - afterEach(() => { - sandbox.restore() - }) - - it('should return correct params from config', async () => { - const configUri = vscode.Uri.file('file://mock/path/project/file') - const contents = ` - [default] - [default.global.parameters] - stack_name = "TestApp" - [default.build.parameters] - cached = true - parallel = true - [default.deploy.parameters] - capabilities = "CAPABILITY_IAM" - confirm_changeset = true - resolve_s3 = true - [default.sync.parameters] - watch = true - template_file = "/Users/mbfreder/TestApp/JavaSamApp/serverless-patterns/s3-lambda-resizing-python/template.yaml" - s3_bucket = "aws-sam-cli-managed-default-samclisourcebucket-1o6ke33w96qag" - stack_name = "s3-lambda-resizing-java-4" - dependency_layer = false` - - const config = await parseConfig(contents) - const samconfig = new SamConfig(configUri, config) - - const result = getSyncParamsFromConfig(samconfig) - assert.strictEqual( - result['templatePath'], - '/Users/mbfreder/TestApp/JavaSamApp/serverless-patterns/s3-lambda-resizing-python/template.yaml' - ) - assert.strictEqual(result['bucketName'], 'aws-sam-cli-managed-default-samclisourcebucket-1o6ke33w96qag') - assert.strictEqual(result['stackName'], 's3-lambda-resizing-java-4') - }) - - it('should return correct params from config with no template file', async () => { - const configUri = vscode.Uri.file('file://mock/path/project/file') - const contents = ` - [default] - [default.global.parameters] - stack_name = "TestApp" - [default.build.parameters] - cached = true - parallel = true - [default.deploy.parameters] - capabilities = "CAPABILITY_IAM" - confirm_changeset = true - resolve_s3 = true - [default.sync.parameters] - watch = true - s3_bucket = "bucket-from-samconfig" - stack_name = "s3-lambda-resizing-java-4" - dependency_layer = false` - - const config = await parseConfig(contents) - const samconfig = new SamConfig(configUri, config) - - const result = getSyncParamsFromConfig(samconfig) - assert.strictEqual(result['templatePath'], undefined) - assert.strictEqual(result['bucketName'], 'bucket-from-samconfig') - assert.strictEqual(result['stackName'], 's3-lambda-resizing-java-4') - }) -}) - -describe('SyncWizard', async () => { +describe('SAM SyncWizard', async () => { let sandbox: sinon.SinonSandbox let testFolder: TestFolder let projectRoot: vscode.Uri @@ -945,7 +198,7 @@ describe('SyncWizard', async () => { await testFolder.write('samconfig.toml', samconfigInvalidData) const prompterTester = PrompterTester.init() - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -992,7 +245,7 @@ describe('SyncWizard', async () => { assert.strictEqual(parameters.template.uri.fsPath, templateFile.fsPath) assert.strictEqual(parameters.projectRoot.fsPath, projectRoot.fsPath) - assert.strictEqual(parameters.paramsSource, ParamsSource.Flags) + assert.strictEqual(parameters.paramsSource, ParamsSource.Specify) assert.strictEqual(parameters.region, 'us-west-2') assert.strictEqual(parameters.stackName, 'stack1') assert.strictEqual(parameters.deployType, 'infra') @@ -1018,7 +271,7 @@ describe('SyncWizard', async () => { await testFolder.write('samconfig.toml', samconfigCompleteData) const prompterTester = PrompterTester.init() - .handleQuickPick('Specify parameters for deploy', async (quickPick) => { + .handleQuickPick('Specify parameter source for sync', async (quickPick) => { // Need time to check samconfig.toml file and generate options await quickPick.untilReady() assert.strictEqual(quickPick.items.length, 3) @@ -1071,7 +324,7 @@ describe('SyncWizard', async () => { */ const prompterTester = PrompterTester.init() - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1115,7 +368,7 @@ describe('SyncWizard', async () => { assert.strictEqual(parameters.template.uri.path, templateFile.path) assert.strictEqual(parameters.projectRoot.path, projectRoot.path) - assert.strictEqual(parameters.paramsSource, ParamsSource.Flags) + assert.strictEqual(parameters.paramsSource, ParamsSource.Specify) assert.strictEqual(parameters.region, 'us-west-2') assert.strictEqual(parameters.stackName, 'stack2') assert.strictEqual(parameters.bucketName, 'stack-3-bucket') @@ -1141,7 +394,7 @@ describe('SyncWizard', async () => { await testFolder.write('samconfig.toml', samconfigCompleteData) const prompterTester = PrompterTester.init() - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1201,7 +454,7 @@ describe('SyncWizard', async () => { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1241,7 +494,7 @@ describe('SyncWizard', async () => { assert.strictEqual(parameters.template.uri.fsPath, templateFile.fsPath) assert.strictEqual(parameters.projectRoot.fsPath, projectRoot.fsPath) - assert.strictEqual(parameters.paramsSource, ParamsSource.Flags) + assert.strictEqual(parameters.paramsSource, ParamsSource.Specify) assert.strictEqual(parameters.region, 'us-west-2') assert.strictEqual(parameters.stackName, 'stack2') assert.strictEqual(parameters.bucketName, 'stack-2-bucket') @@ -1274,7 +527,7 @@ describe('SyncWizard', async () => { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1324,7 +577,7 @@ describe('SyncWizard', async () => { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1371,7 +624,7 @@ describe('SyncWizard', async () => { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1414,7 +667,7 @@ describe('SyncWizard', async () => { assert(parameters) assert.strictEqual(parameters.template.uri.fsPath, templateFile.fsPath) assert.strictEqual(parameters.projectRoot.fsPath, projectRoot.fsPath) - assert.strictEqual(parameters.paramsSource, ParamsSource.Flags) + assert.strictEqual(parameters.paramsSource, ParamsSource.Specify) assert.strictEqual(parameters.region, 'us-west-2') assert.strictEqual(parameters.stackName, 'stack2') assert.strictEqual(parameters.bucketName, 'stack-2-bucket') @@ -1446,7 +699,7 @@ describe('SyncWizard', async () => { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1523,7 +776,7 @@ describe('SyncWizard', async () => { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() @@ -1553,7 +806,7 @@ describe('SyncWizard', async () => { }) }) -describe('SAM Sync', () => { +describe('SAM runSync', () => { let sandbox: sinon.SinonSandbox let testFolder: TestFolder let projectRoot: vscode.Uri @@ -1619,7 +872,7 @@ describe('SAM Sync', () => { describe(':) path', () => { beforeEach(() => { mockGetSamCliPath = sandbox - .stub(UtilsModule, 'getSamCliPathAndVersion') + .stub(SamUtilsModule, 'getSamCliPathAndVersion') .callsFake(sandbox.stub().resolves({ path: 'sam-cli-path' })) // Confirm confirmDevStack message @@ -1655,7 +908,7 @@ describe('SAM Sync', () => { assert.strictEqual(quickPick.items[0].label, templateFile.fsPath) quickPick.acceptItem(quickPick.items[0]) }) - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() assert.strictEqual(picker.items[0].label, 'Specify required parameters and save as defaults') @@ -1733,7 +986,7 @@ describe('SAM Sync', () => { it('[entry: template file] specify flag should instantiate correct process in terminal', async () => { const prompterTester = PrompterTester.init() - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() assert.strictEqual(picker.items[1].label, 'Specify required parameters') @@ -1817,7 +1070,7 @@ describe('SAM Sync', () => { const samconfigFile = vscode.Uri.file(await testFolder.write('samconfig.toml', samconfigCompleteData)) const prompterTester = PrompterTester.init() - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() assert.strictEqual(picker.items[2].label, 'Use default values from samconfig') @@ -1868,7 +1121,7 @@ describe('SAM Sync', () => { let appNode: AppNode beforeEach(async () => { mockGetSamCliPath = sandbox - .stub(UtilsModule, 'getSamCliPathAndVersion') + .stub(SamUtilsModule, 'getSamCliPathAndVersion') .callsFake(sandbox.stub().resolves({ path: 'sam-cli-path' })) appNode = new AppNode({ @@ -1893,7 +1146,7 @@ describe('SAM Sync', () => { try { await runSync('infra', appNode) - fail('should have thrown CancellationError') + assert.fail('should have thrown CancellationError') } catch (error: any) { assert(error instanceof CancellationError) assert.strictEqual(error.agent, 'user') @@ -1903,11 +1156,11 @@ describe('SAM Sync', () => { it('should abort when customer cancel sync wizard', async () => { // Confirm confirmDevStack message getTestWindow().onDidShowMessage((m) => m.items.find((i) => i.title === 'OK')?.select()) - sandbox.stub(SyncModule.SyncWizard.prototype, 'run').resolves(undefined) + sandbox.stub(SyncWizard.prototype, 'run').resolves(undefined) try { await runSync('infra', appNode) - fail('should have thrown CancellationError') + assert.fail('should have thrown CancellationError') } catch (error: any) { assert(error instanceof CancellationError) assert.strictEqual(error.agent, 'user') @@ -1919,7 +1172,7 @@ describe('SAM Sync', () => { getTestWindow().onDidShowMessage((m) => m.items.find((i) => i.title === 'OK')?.select()) const prompterTester = PrompterTester.init() - .handleQuickPick('Specify parameters for deploy', async (picker) => { + .handleQuickPick('Specify parameter source for sync', async (picker) => { // Need time to check samconfig.toml file and generate options await picker.untilReady() assert.strictEqual(picker.items[2].label, 'Use default values from samconfig') @@ -1943,7 +1196,7 @@ describe('SAM Sync', () => { try { await runSync('infra', appNode) - fail('should have thrown ToolkitError') + assert.fail('should have thrown ToolkitError') } catch (error: any) { assert(error instanceof ToolkitError) assert.strictEqual(error.message, 'Failed to sync SAM application') @@ -1953,124 +1206,581 @@ describe('SAM Sync', () => { }) }) -describe('saveAndBindArgs', () => { - let sandbox: sinon.SinonSandbox - let getConfigFileUriStub: sinon.SinonStub +describe('SAM sync helper functions', () => { + describe('prepareSyncParams', function () { + let tempDir: vscode.Uri - beforeEach(() => { - sandbox = sinon.createSandbox() - getConfigFileUriStub = sandbox.stub() + beforeEach(async function () { + tempDir = vscode.Uri.file(await makeTemporaryToolkitFolder()) + }) - // Replace the real implementations with stubs - sandbox.stub(SyncModule, 'updateRecentResponse').resolves() - }) + afterEach(async function () { + await fs.delete(tempDir, { recursive: true }) + }) - afterEach(() => { - sandbox.restore() + it('uses region if given a tree node', async function () { + const params = await prepareSyncParams( + new (class extends AWSTreeNodeBase { + public override readonly regionCode = 'foo' + })('') + ) + + assert.strictEqual(params.region, 'foo') + }) + + async function makeTemplateItem(dir: vscode.Uri) { + const uri = vscode.Uri.joinPath(dir, 'template.yaml') + const data = makeSampleSamTemplateYaml(true) + await fs.writeFile(uri, JSON.stringify(data)) + + return { uri, data } + } + + it('loads template if given a URI', async function () { + const template = await makeTemplateItem(tempDir) + + const params = await prepareSyncParams(template.uri) + assert.strictEqual(params.template?.uri.fsPath, template.uri.fsPath) + assert.deepStrictEqual(params.template?.data, template.data) + }) + + it('skips dependency layers by default', async function () { + const template = await makeTemplateItem(tempDir) + + const params = await prepareSyncParams(template.uri) + assert.strictEqual(params.skipDependencyLayer, true) + }) + + describe('samconfig.toml', function () { + async function makeDefaultConfig(dir: vscode.Uri, body: string) { + const uri = vscode.Uri.joinPath(dir, 'samconfig.toml') + const data = ` + [default.sync.parameters] + ${body} +` + await fs.writeFile(uri, data) + + return uri + } + + async function getParams(body: string, dir = tempDir) { + const config = await makeDefaultConfig(dir, body) + + return prepareSyncParams(config) + } + + it('throws on non-string values', async function () { + await assert.rejects(() => getParams(`region = 0`), ToolkitError) + }) + + it('does not fail on missing values', async function () { + const params = await getParams(`region = "bar"`) + assert.strictEqual(params.region, 'bar') + }) + + it('sets the project root as the parent directory', async function () { + const params = await getParams(`region = "bar"`, tempDir) + assert.strictEqual(params.projectRoot?.fsPath, tempDir.fsPath) + }) + + it('uses the depdency layer option if provided', async function () { + const params = await getParams(`dependency_layer = true`, tempDir) + assert.strictEqual(params.skipDependencyLayer, false) + }) + + it('can load a relative template param', async function () { + const template = await makeTemplateItem(tempDir) + const params = await getParams(`template = "./template.yaml"`) + assert.deepStrictEqual(params.template?.data, template.data) + }) + + it('can load an absolute template param', async function () { + const template = await makeTemplateItem(tempDir) + const params = await getParams(`template = '${template.uri.fsPath}'`) + assert.deepStrictEqual(params.template?.data, template.data) + }) + + it('can load a relative template param without a path seperator', async function () { + const template = await makeTemplateItem(tempDir) + const params = await getParams(`template = "template.yaml"`) + assert.deepStrictEqual(params.template?.data, template.data) + }) + + it('can load a template param using an alternate key', async function () { + const template = await makeTemplateItem(tempDir) + const params = await getParams(`template_file = "template.yaml"`) + assert.deepStrictEqual(params.template?.data, template.data) + }) + + it('can use global params', async function () { + const params = await getParams(` + region = "bar" + [default.global.parameters] + stack_name = "my-app" + `) + assert.strictEqual(params.stackName, 'my-app') + }) + + it('prefers using the sync section over globals', async function () { + const params = await getParams(` + stack_name = "my-sync-app" + [default.global.parameters] + stack_name = "my-app" + `) + assert.strictEqual(params.stackName, 'my-sync-app') + }) + + it('loads all values if found', async function () { + const params = await getParams(` + region = "bar" + stack_name = "my-app" + s3_bucket = "my-bucket" + image_repository = "12345679010.dkr.ecr.bar.amazonaws.com/repo" + `) + assert.strictEqual(params.region, 'bar') + assert.strictEqual(params.stackName, 'my-app') + assert.strictEqual(params.bucketName, 'my-bucket') + assert.strictEqual(params.ecrRepoUri, '12345679010.dkr.ecr.bar.amazonaws.com/repo') + }) + }) }) - it('should bind arguments correctly for code deployment', async () => { - const testFolder = await TestFolder.create() - const templateFile = vscode.Uri.file(await testFolder.write('template.yaml', validTemplateData)) + describe('syncFlagsPrompter', () => { + let sandbox: sinon.SinonSandbox + let acceptedItems: DataQuickPickItem[] - const args = { - deployType: 'code', - template: { - uri: templateFile, - data: {}, - } as TemplateItem, - bucketName: 'myBucket', - ecrRepoUri: 'myEcrRepo', - stackName: 'myStack', - region: 'us-east-1', - skipDependencyLayer: false, - paramsSource: ParamsSource.SpecifyAndSave, - } as SyncParams - - const result = await saveAndBindArgs(args) - - assert.ok(result.boundArgs.includes('--template')) - assert.ok(result.boundArgs.includes('--s3-bucket')) - assert.ok(result.boundArgs.includes('--image-repository')) - assert.ok(result.boundArgs.includes('--stack-name')) - assert.ok(result.boundArgs.includes('--region')) - assert.ok(result.boundArgs.includes('--code')) - assert.ok(result.boundArgs.includes('--save-params')) - assert.ok(result.boundArgs.includes(templateFile.fsPath)) - assert.ok(result.boundArgs.includes('myBucket')) - assert.ok(result.boundArgs.includes('myEcrRepo')) - assert.ok(result.boundArgs.includes('myStack')) - assert.ok(result.boundArgs.includes('us-east-1')) + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() // Restore all stubs after each test + }) + + it('should return selected flags from buildFlagsPrompter', async () => { + getTestWindow().onDidShowQuickPick(async (picker) => { + await picker.untilReady() + assert.strictEqual(picker.items.length, 9) + assert.strictEqual(picker.title, 'Specify parameters for sync') + assert.deepStrictEqual(picker.items, syncFlagItems) + const buildInSource = picker.items[0] + const code = picker.items[1] + const dependencyLayer = picker.items[2] + assert.strictEqual(buildInSource.data, '--build-in-source') + assert.strictEqual(code.data, '--code') + assert.strictEqual(dependencyLayer.data, '--dependency-layer') + acceptedItems = [buildInSource, code, dependencyLayer] + picker.acceptItems(...acceptedItems) + }) + + const flags = await createMultiPick(syncFlagItems, { + title: 'Specify parameters for sync', + placeholder: 'Press enter to proceed with highlighted option', + }).prompt() + + assert.deepStrictEqual(flags, JSON.stringify(acceptedItems.map((i) => i.data))) + }) }) - it('should handle SamConfig paramsSource', async () => { - const testFolder = await TestFolder.create() - const projectRoot = vscode.Uri.file(testFolder.path) - const templateFile = vscode.Uri.file(await testFolder.write('template.yaml', validTemplateData)) - const samConfigFile = vscode.Uri.file(await testFolder.write('samconfig.toml', '[default]')) - - const args = { - deployType: 'code', - template: { uri: templateFile, data: {} } as TemplateItem, - bucketName: 'myBucket', - ecrRepoUri: 'myEcrRepo', - stackName: 'myStack', - region: 'us-east-1', - skipDependencyLayer: false, - paramsSource: ParamsSource.SamConfig, - projectRoot: projectRoot, - } as SyncParams + describe('createEnvironmentPrompter', () => { + let sandbox: sinon.SinonSandbox + let config: SamConfig + let listEnvironmentsStub: sinon.SinonStub - getConfigFileUriStub.resolves(samConfigFile) + beforeEach(() => { + sandbox = sinon.createSandbox() + // Create a stub for the SamConfig instance + config = new SamConfig(vscode.Uri.parse('dummy://uri')) + listEnvironmentsStub = sandbox.stub(config, 'listEnvironments') + }) - const result = await saveAndBindArgs(args) + afterEach(() => { + sandbox.restore() + }) + + it('should create a prompter with existing samconfig env', () => { + // Arrange + const defaultEnv: Environment = { + name: 'default', + commands: {}, + } + const stagingEnv: Environment = { + name: 'staging', + commands: {}, + } + const prodEnv: Environment = { + name: 'prod', + commands: {}, + } + const envs: Environment[] = [defaultEnv, stagingEnv, prodEnv] + + listEnvironmentsStub.returns(envs) + sandbox.stub(SamUtilsModule, 'getRecentResponse').returns(undefined) - assert.ok(result.boundArgs.includes('--config-file')) - assert.ok(result.boundArgs.includes(samConfigFile.fsPath)) + // Act + const prompter = createEnvironmentPrompter(config) + + // Assert + assert.ok(listEnvironmentsStub.calledOnce) + assert.strictEqual(prompter.quickPick.title, 'Select an Environment to Use') + assert.strictEqual(prompter.quickPick.placeholder, 'Select an environment') + assert.strictEqual(prompter.quickPick.items.length, 3) + assert.deepStrictEqual(prompter.quickPick.items, [ + { + label: 'default', + data: defaultEnv, + recentlyUsed: false, + }, + { + label: 'staging', + data: stagingEnv, + recentlyUsed: false, + }, + { + label: 'prod', + data: prodEnv, + recentlyUsed: false, + }, + ]) + }) }) -}) -describe('ensureBucket', () => { - let sandbox: sinon.SinonSandbox - let createBucketStub + describe('prepareSyncParams', () => { + let sandbox: sinon.SinonSandbox + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) - beforeEach(() => { - sandbox = sinon.createSandbox() + it('should return correct params from region node', async () => { + const regionNode = new RegionNode({ name: 'us-east-1', id: 'IAD' } as Region, {} as RegionProvider) + const result = await prepareSyncParams(regionNode) + assert.deepStrictEqual(result, { skipDependencyLayer: true, region: 'IAD' }) + }) + + it('should return correct params from appBuilder', async () => { + // setup appNode + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + assert.ok(workspaceFolder) + const templateUri = vscode.Uri.file('file://mock/path/project/file') + const projectRootUri = getProjectRootUri(templateUri) + const samAppLocation = { + samTemplateUri: templateUri, + workspaceFolder: workspaceFolder, + projectRoot: projectRootUri, + } + const appNode = new AppNode(samAppLocation) + const tryLoadStub = sandbox.stub(Cfn, 'load') + + tryLoadStub.resolves({} as Cfn.Template) + + const templateItem = { + uri: templateUri, + data: {}, + } + + // Act + const result = await prepareSyncParams(appNode) + + // Assert + assert.deepStrictEqual(result, { + skipDependencyLayer: true, + template: templateItem, + projectRoot: projectRootUri, + }) + }) + + it('should return correct params for undefined input', async () => { + const result = await prepareSyncParams(undefined) + assert.deepStrictEqual(result, { skipDependencyLayer: true }) + }) }) - afterEach(() => { - sandbox.restore() // Restore original behavior after each test + describe('getSyncParamsFromConfig', () => { + let sandbox: sinon.SinonSandbox + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should return correct params from config', async () => { + const configUri = vscode.Uri.file('file://mock/path/project/file') + const contents = ` + [default] + [default.global.parameters] + stack_name = "TestApp" + [default.build.parameters] + cached = true + parallel = true + [default.deploy.parameters] + capabilities = "CAPABILITY_IAM" + confirm_changeset = true + resolve_s3 = true + [default.sync.parameters] + watch = true + template_file = "/Users/mbfreder/TestApp/JavaSamApp/serverless-patterns/s3-lambda-resizing-python/template.yaml" + s3_bucket = "aws-sam-cli-managed-default-samclisourcebucket-1o6ke33w96qag" + stack_name = "s3-lambda-resizing-java-4" + dependency_layer = false` + + const config = await parseConfig(contents) + const samconfig = new SamConfig(configUri, config) + + const result = getSyncParamsFromConfig(samconfig) + assert.strictEqual( + result['templatePath'], + '/Users/mbfreder/TestApp/JavaSamApp/serverless-patterns/s3-lambda-resizing-python/template.yaml' + ) + assert.strictEqual(result['bucketName'], 'aws-sam-cli-managed-default-samclisourcebucket-1o6ke33w96qag') + assert.strictEqual(result['stackName'], 's3-lambda-resizing-java-4') + }) + + it('should return correct params from config with no template file', async () => { + const configUri = vscode.Uri.file('file://mock/path/project/file') + const contents = ` + [default] + [default.global.parameters] + stack_name = "TestApp" + [default.build.parameters] + cached = true + parallel = true + [default.deploy.parameters] + capabilities = "CAPABILITY_IAM" + confirm_changeset = true + resolve_s3 = true + [default.sync.parameters] + watch = true + s3_bucket = "bucket-from-samconfig" + stack_name = "s3-lambda-resizing-java-4" + dependency_layer = false` + + const config = await parseConfig(contents) + const samconfig = new SamConfig(configUri, config) + + const result = getSyncParamsFromConfig(samconfig) + assert.strictEqual(result['templatePath'], undefined) + assert.strictEqual(result['bucketName'], 'bucket-from-samconfig') + assert.strictEqual(result['stackName'], 's3-lambda-resizing-java-4') + }) }) - it('should return the bucket name when it does not match newbucket:', async () => { - const resp = { region: 'us-east-1', bucketName: 'existing-bucket' } - const result = await ensureBucket(resp) - assert.strictEqual(result, 'existing-bucket') + describe('saveAndBindArgs', () => { + let sandbox: sinon.SinonSandbox + let getConfigFileUriStub: sinon.SinonStub + + beforeEach(() => { + sandbox = sinon.createSandbox() + getConfigFileUriStub = sandbox.stub() + + // Replace the real implementations with stubs + sandbox.stub(SamUtilsModule, 'updateRecentResponse').resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should bind arguments correctly for code deployment', async () => { + const testFolder = await TestFolder.create() + const templateFile = vscode.Uri.file(await testFolder.write('template.yaml', validTemplateData)) + + const args = { + deployType: 'code', + template: { + uri: templateFile, + data: {}, + } as TemplateItem, + bucketName: 'myBucket', + ecrRepoUri: 'myEcrRepo', + stackName: 'myStack', + region: 'us-east-1', + skipDependencyLayer: false, + paramsSource: ParamsSource.SpecifyAndSave, + } as SyncParams + + const result = await saveAndBindArgs(args) + + assert.ok(result.boundArgs.includes('--template')) + assert.ok(result.boundArgs.includes('--s3-bucket')) + assert.ok(result.boundArgs.includes('--image-repository')) + assert.ok(result.boundArgs.includes('--stack-name')) + assert.ok(result.boundArgs.includes('--region')) + assert.ok(result.boundArgs.includes('--code')) + assert.ok(result.boundArgs.includes('--save-params')) + assert.ok(result.boundArgs.includes(templateFile.fsPath)) + assert.ok(result.boundArgs.includes('myBucket')) + assert.ok(result.boundArgs.includes('myEcrRepo')) + assert.ok(result.boundArgs.includes('myStack')) + assert.ok(result.boundArgs.includes('us-east-1')) + }) + + it('should handle SamConfig paramsSource', async () => { + const testFolder = await TestFolder.create() + const projectRoot = vscode.Uri.file(testFolder.path) + const templateFile = vscode.Uri.file(await testFolder.write('template.yaml', validTemplateData)) + const samConfigFile = vscode.Uri.file(await testFolder.write('samconfig.toml', '[default]')) + + const args = { + deployType: 'code', + template: { uri: templateFile, data: {} } as TemplateItem, + bucketName: 'myBucket', + ecrRepoUri: 'myEcrRepo', + stackName: 'myStack', + region: 'us-east-1', + skipDependencyLayer: false, + paramsSource: ParamsSource.SamConfig, + projectRoot: projectRoot, + } as SyncParams + + getConfigFileUriStub.resolves(samConfigFile) + + const result = await saveAndBindArgs(args) + + assert.ok(result.boundArgs.includes('--config-file')) + assert.ok(result.boundArgs.includes(samConfigFile.fsPath)) + }) }) - it('should create a new bucket and return its name when bucketName matches newbucket:', async () => { - const resp = { region: 'us-east-1', bucketName: 'newbucket:my-new-bucket' } + describe('ensureBucket', () => { + let sandbox: sinon.SinonSandbox + let createBucketStub + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) - // Stub the S3 client's createBucket method - createBucketStub = sandbox.stub(DefaultS3Client.prototype, 'createBucket').resolves() + afterEach(() => { + sandbox.restore() // Restore original behavior after each test + }) - const result = await ensureBucket(resp) - assert.ok(createBucketStub.calledOnce) - assert.strictEqual(createBucketStub.firstCall.args[0].bucketName, 'my-new-bucket') - assert.strictEqual(result, 'my-new-bucket') + it('should return the bucket name when it does not match newbucket:', async () => { + const resp = { region: 'us-east-1', bucketName: 'existing-bucket' } + const result = await ensureBucket(resp) + assert.strictEqual(result, 'existing-bucket') + }) + + it('should create a new bucket and return its name when bucketName matches newbucket:', async () => { + const resp = { region: 'us-east-1', bucketName: 'newbucket:my-new-bucket' } + + // Stub the S3 client's createBucket method + createBucketStub = sandbox.stub(DefaultS3Client.prototype, 'createBucket').resolves() + + const result = await ensureBucket(resp) + assert.ok(createBucketStub.calledOnce) + assert.strictEqual(createBucketStub.firstCall.args[0].bucketName, 'my-new-bucket') + assert.strictEqual(result, 'my-new-bucket') + }) + + it('should throw a ToolkitError when bucket creation fails', async () => { + const resp = { region: 'us-east-1', bucketName: 'newbucket:my-failing-bucket' } + + // Stub the S3 client's createBucket method to throw an error + createBucketStub = sandbox + .stub(DefaultS3Client.prototype, 'createBucket') + .rejects(new Error('Failed to create S3 bucket')) + + await assert.rejects(ensureBucket(resp)).catch((err) => { + assert.ok(err instanceof ToolkitError) + assert.ok(err.message, 'Failed to create S3 bucket') + }) + }) }) - it('should throw a ToolkitError when bucket creation fails', async () => { - const resp = { region: 'us-east-1', bucketName: 'newbucket:my-failing-bucket' } + describe('ensureRepo', () => { + let createRepositoryStub: sinon.SinonStub + let sandbox: sinon.SinonSandbox - // Stub the S3 client's createBucket method to throw an error - createBucketStub = sandbox - .stub(DefaultS3Client.prototype, 'createBucket') - .rejects(new Error('Failed to create S3 bucket')) + beforeEach(() => { + sandbox = sinon.createSandbox() + createRepositoryStub = sandbox.stub() + sandbox.stub(DefaultEcrClient.prototype, 'createRepository').callsFake(createRepositoryStub) + }) - await assert.rejects(ensureBucket(resp)).catch((err) => { - assert.ok(err instanceof ToolkitError) - assert.ok(err.message, 'Failed to create S3 bucket') + afterEach(() => { + sandbox.restore() + }) + + const createInput = (ecrRepoUri: string | undefined) => ({ + region: 'us-west-2', + ecrRepoUri, + }) + + const createNewRepoInput = (repoName: string) => createInput(`newrepo:${repoName}`) + + describe('when not creating new repository', () => { + it('should return original ecrRepoUri when not matching newrepo pattern', async () => { + const input = createInput('existing-repo:latest') + const result = await ensureRepo(input) + + assert(createRepositoryStub.notCalled) + assert.strictEqual(result, input.ecrRepoUri) + }) + + it('should return original ecrRepoUri when ecrRepoUri is undefined', async () => { + const input = createInput(undefined) + const result = await ensureRepo(input) + + assert(!result) + assert(createRepositoryStub.notCalled) + }) + }) + + describe('when creating new repository', () => { + const repoName = 'test-repo' + const input = createNewRepoInput(repoName) + + it('should create new repository successfully', async () => { + const expectedUri = 'aws.ecr.test/test-repo' + createRepositoryStub.resolves({ + repository: { + repositoryUri: expectedUri, + }, + }) + + const result = await ensureRepo(input) + + assert.strictEqual(result, expectedUri) + assert(createRepositoryStub.calledOnceWith(repoName)) + }) + + it('should handle repository creation failure', async () => { + createRepositoryStub.rejects(new Error('Repository creation failed')) + + try { + await ensureRepo(input) + assert.fail('Should have thrown an error') + } catch (err) { + assert(err instanceof ToolkitError) + assert.strictEqual(err.message, `Failed to create new ECR repository "${repoName}"`) + } + }) + + const testCases = [ + { + name: 'undefined repositoryUri', + response: { repository: { repositoryUri: undefined } }, + }, + { + name: 'empty repository response', + response: {}, + }, + ] + + testCases.forEach(({ name, response }) => { + it(`should handle ${name}`, async () => { + createRepositoryStub.resolves(response) + + const result = await ensureRepo(input) + + assert(!result) + assert(createRepositoryStub.calledOnceWith(repoName)) + }) + }) }) }) }) diff --git a/packages/core/src/test/shared/sam/utils.test.ts b/packages/core/src/test/shared/sam/utils.test.ts index 9f5741d34b8..590db5a9a81 100644 --- a/packages/core/src/test/shared/sam/utils.test.ts +++ b/packages/core/src/test/shared/sam/utils.test.ts @@ -11,14 +11,23 @@ import { getProjectRoot, getSource, isDotnetRuntime, + getSamCliPathAndVersion, + getRecentResponse, + updateRecentResponse, getSamCliErrorMessage, throwIfErrorMatches, } from '../../../shared/sam/utils' -import { TemplateItem } from '../../../shared/sam/sync' + import { RegionNode } from '../../../awsexplorer/regionNode' import { Region } from '../../../shared/regions/endpoints' import { RegionProvider, ToolkitError } from '../../../shared' import { DeployedResource, DeployedResourceNode } from '../../../awsService/appBuilder/explorer/nodes/deployedNode' +import { TemplateItem } from '../../../shared/ui/sam/templatePrompter' +import { SamCliInfoInvocation } from '../../../shared/sam/cli/samCliInfo' +import { SamCliSettings } from '../../../shared/sam/cli/samCliSettings' +import { telemetry } from '../../../shared/telemetry' +import globals from '../../../shared/extensionGlobals' +import { assertLogsContain } from '../../globalSetup.test' import { ChildProcessResult } from '../../../shared/utilities/processUtils' describe('SAM utils', async function () { @@ -35,6 +44,7 @@ describe('SAM utils', async function () { const response = getProjectRootUri(template) assert.deepStrictEqual(response, vscode.Uri.file('file://mock/path/project')) }) + describe('getSource', async function () { const testScenarios = [ { @@ -157,6 +167,119 @@ describe('SAM utils', async function () { }) }) + describe('getSamCliPathAndVersion', async function () { + let sandbox: sinon.SinonSandbox + let getOrDetectSamCliStub: sinon.SinonStub + let executeStub: sinon.SinonStub + let telemetryStub: sinon.SinonStub + + beforeEach(() => { + sandbox = sinon.createSandbox() + getOrDetectSamCliStub = sandbox.stub(SamCliSettings.instance, 'getOrDetectSamCli') + executeStub = sandbox.stub(SamCliInfoInvocation.prototype, 'execute') + telemetryStub = sandbox.stub(telemetry, 'record') + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns path and version when SAM CLI is found and version is valid', async () => { + const expectedPath = '/usr/local/bin/sam' + const expectedVersion = '1.99.0' + getOrDetectSamCliStub.resolves({ path: expectedPath }) + executeStub.resolves({ version: expectedVersion }) + + const result = await getSamCliPathAndVersion() + + assert.strictEqual(result.path, expectedPath) + assert.strictEqual(result.parsedVersion?.version, expectedVersion) + assert(telemetryStub.calledOnceWith({ version: expectedVersion })) + }) + + it('throws MissingExecutable error when SAM CLI path is undefined', async () => { + getOrDetectSamCliStub.resolves({ path: undefined }) + + try { + await getSamCliPathAndVersion() + assert.fail('Should have thrown an error') + } catch (error) { + assert(error instanceof ToolkitError) + assert.strictEqual(error.code, 'MissingExecutable') + assert.strictEqual(error.message, 'SAM CLI could not be found') + assert(telemetryStub.notCalled) + } + }) + + it('throws VersionTooLow error when SAM CLI version is below 1.53.0', async () => { + const lowVersion = '1.52.0' + getOrDetectSamCliStub.resolves({ path: '/usr/local/bin/sam' }) + executeStub.resolves({ version: lowVersion }) + + try { + await getSamCliPathAndVersion() + assert.fail('Should have thrown an error') + } catch (error) { + assert(error instanceof ToolkitError) + assert.strictEqual(error.code, 'VersionTooLow') + assert.strictEqual(error.message, 'SAM CLI version 1.53.0 or higher is required') + // records telemetry even when version is too low + assert(telemetryStub.calledOnceWith({ version: lowVersion })) + } + }) + }) + + describe('get/update recent response', () => { + // Create stub for globals.context.workspaceState.get + const mementoRootKey = 'samcli.utils.key' + const nonExistingMementoRootKey = 'samcli.utils.no.key' + const identifier = 'us-east-1' + const key1 = 'stackName' + const key2 = 'bucketName' + const value1 = 'myStackName' + const value2 = 'myBucketName' + + after(async () => { + await globals.context.workspaceState.update(mementoRootKey, {}) + }) + + it('1. getRecentResponse should return undefined when mementoRootKey does not exist', async () => { + assert(!getRecentResponse(nonExistingMementoRootKey, identifier, key1)) + }) + + it('2. updateRecentResponse should return the undefined when mementoRootKey does not exist', async () => { + try { + await updateRecentResponse(mementoRootKey, identifier, key1, value1) + } catch (err) { + assert.fail('The execution should have succeeded yet encounter unexpected exception') + } + }) + + it('3. getRecentResponse should return the correct value', async () => { + const result = getRecentResponse(mementoRootKey, identifier, key1) + assert.strictEqual(result, value1) + }) + + it('4. updateRecentResponse should only update the specified key', async () => { + await updateRecentResponse(mementoRootKey, identifier, key2, value2) + const result1 = getRecentResponse(mementoRootKey, identifier, key1) + const result2 = getRecentResponse(mementoRootKey, identifier, key2) + assert.strictEqual(result1, value1) + assert.strictEqual(result2, value2) + }) + + it('5. updateRecentResponse should log and swallow exception when fails to update value', async () => { + sinon.stub(globals.context.workspaceState, 'update').rejects(new Error('Error updating value')) + try { + await updateRecentResponse(mementoRootKey, identifier, key2, value2) + } catch (err) { + assert.fail('The target function should have handled exception internally') + } + assertLogsContain(`sam: unable to save response at key`, false, 'warn') + sinon.restore() + }) + }) + describe('gets the SAM CLI error from stderr', async function () { it('returns the error message', async function () { const stderr = diff --git a/packages/core/src/test/shared/ui/prompters/regionSubmenu.test.ts b/packages/core/src/test/shared/ui/prompters/regionSubmenu.test.ts index 10b9ceb1d90..4687ec10d5f 100644 --- a/packages/core/src/test/shared/ui/prompters/regionSubmenu.test.ts +++ b/packages/core/src/test/shared/ui/prompters/regionSubmenu.test.ts @@ -7,7 +7,7 @@ import assert from 'assert' import * as sinon from 'sinon' import { RegionSubmenu } from '../../../../shared/ui/common/regionSubmenu' import { DataQuickPickItem, QuickPickPrompter } from '../../../../shared/ui/pickerPrompter' -import { createQuickPickPrompterTester } from '../../../shared/ui/testUtils' +import { createQuickPickPrompterTester } from '../testUtils' describe('regionSubmenu', function () { let submenuPrompter: RegionSubmenu diff --git a/packages/core/src/test/shared/ui/sam/bucketPrompter.test.ts b/packages/core/src/test/shared/ui/sam/bucketPrompter.test.ts new file mode 100644 index 00000000000..d78d4e2477f --- /dev/null +++ b/packages/core/src/test/shared/ui/sam/bucketPrompter.test.ts @@ -0,0 +1,78 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { S3 } from 'aws-sdk' +import sinon from 'sinon' +import { DefaultS3Client } from '../../../../shared/clients/s3Client' +import * as SamUtilsModule from '../../../../shared/sam/utils' +import { createBucketNamePrompter } from '../../../../shared/ui/sam/bucketPrompter' +import { AsyncCollection } from '../../../../shared/utilities/asyncCollection' +import { RequiredProps } from '../../../../shared/utilities/tsUtils' + +describe('createBucketNamePrompter', () => { + let sandbox: sinon.SinonSandbox + const s3Client = new DefaultS3Client('us-east-1', 'aws') + const mementoRootKey = 'samcli.deploy.params' + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should create a prompter with existing buckets', () => { + // Arrange + const buckets = [ + { Name: 'bucket1', region: 'us-east-1' }, + { Name: 'bucket2', region: 'us-east-1' }, + { Name: 'bucket3', region: 'us-east-1' }, + ] as unknown as AsyncCollection & { readonly region: string }> + + const stub = sandbox.stub(s3Client, 'listBucketsIterable').callsFake(() => { + return buckets + }) + sandbox.stub(SamUtilsModule, 'getRecentResponse').returns(undefined) // Mock recent bucket + + // Act + const prompter = createBucketNamePrompter(s3Client, mementoRootKey) + + // Assert + assert.ok(stub.calledOnce) + const expectedItems = buckets.map((b) => [ + { + label: b.Name, + data: b.Name, + recentlyUsed: false, + }, + ]) + assert.strictEqual(prompter.quickPick.title, 'Select an S3 Bucket') + assert.strictEqual(prompter.quickPick.placeholder, 'Select a bucket (or enter a name to create one)') + assert.strictEqual(prompter.quickPick.items.length, 3) + assert.deepStrictEqual(prompter.quickPick.items, expectedItems) + }) + + it('should include no items found message if no stacks exist', () => { + const stub = sandbox.stub(s3Client, 'listBucketsIterable').callsFake(() => { + return [] as unknown as AsyncCollection & { readonly region: string }> + }) + sandbox.stub(SamUtilsModule, 'getRecentResponse').returns(undefined) // Mock recent bucket + + // Act + const prompter = createBucketNamePrompter(s3Client, mementoRootKey) + + // Assert + assert.ok(stub.calledOnce) + assert.strictEqual(prompter.quickPick.title, 'Select an S3 Bucket') + assert.strictEqual(prompter.quickPick.placeholder, 'Select a bucket (or enter a name to create one)') + assert.strictEqual(prompter.quickPick.items.length, 1) + assert.strictEqual( + prompter.quickPick.items[0].label, + 'No S3 buckets for region "us-east-1". Enter a name to create a new one.' + ) + }) +}) diff --git a/packages/core/src/test/shared/ui/sam/ecrPrompter.test.ts b/packages/core/src/test/shared/ui/sam/ecrPrompter.test.ts new file mode 100644 index 00000000000..e981bf1396e --- /dev/null +++ b/packages/core/src/test/shared/ui/sam/ecrPrompter.test.ts @@ -0,0 +1,127 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import * as AwsConsoleModule from '../../../../shared/awsConsole' +import { DefaultEcrClient, EcrRepository } from '../../../../shared/clients/ecrClient' +import { samSyncUrl } from '../../../../shared/constants' +import * as SamUtilsModule from '../../../../shared/sam/utils' +import * as ButtonsModule from '../../../../shared/ui/buttons' +import { createEcrPrompter } from '../../../../shared/ui/sam/ecrPrompter' +import { intoCollection } from '../../../../shared/utilities/collectionUtils' +import { sleep } from '../../../../shared/utilities/timeoutUtils' + +describe('createEcrPrompter', () => { + let sandbox: sinon.SinonSandbox + const ecrClient = new DefaultEcrClient('us-east-1') + const mementoRootKey = 'samcli.sync.params' + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should create a prompter with existing repos', async () => { + // Arrange + const ecrRepos: EcrRepository[][] = [ + [ + { + repositoryName: 'repo1', + repositoryUri: 'repoUri1', + repositoryArn: 'repoArn1', + } as EcrRepository, + { + repositoryName: 'repo2', + repositoryUri: 'repoUri2', + repositoryArn: 'repoArn2', + } as EcrRepository, + { + repositoryName: 'repo3', + repositoryUri: 'repoUri3', + repositoryArn: 'repoArn3', + } as EcrRepository, + ], + ] + const expectedItems = [ + { + label: 'repo1', + data: 'repoUri1', + detail: 'repoArn1', + recentlyUsed: false, + }, + { + label: 'repo2', + data: 'repoUri2', + detail: 'repoArn2', + recentlyUsed: false, + }, + { + label: 'repo3', + data: 'repoUri3', + detail: 'repoArn3', + recentlyUsed: false, + }, + ] + const listAllRepositoriesStub = sandbox.stub(ecrClient, 'listAllRepositories').returns(intoCollection(ecrRepos)) + sandbox.stub(SamUtilsModule, 'getRecentResponse').returns(undefined) + const createCommonButtonsStub = sandbox.stub(ButtonsModule, 'createCommonButtons') + sandbox + .stub(AwsConsoleModule, 'getAwsConsoleUrl') + .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) + + // Act + const prompter = createEcrPrompter(ecrClient, mementoRootKey) + await sleep(50) + + // Assert + assert.ok(createCommonButtonsStub.calledOnce) + assert.ok( + createCommonButtonsStub.calledWithExactly( + samSyncUrl, + vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) + ) + ) + assert.ok(listAllRepositoriesStub.calledOnce) + assert.strictEqual(prompter.quickPick.title, 'Select an ECR Repository') + assert.strictEqual(prompter.quickPick.placeholder, 'Select a repository (or enter a name to create one)') + assert.strictEqual(prompter.quickPick.items.length, 3) + assert.deepStrictEqual(prompter.quickPick.items, expectedItems) + }) + + it('should include no items found message if no repos exist', async () => { + const listAllStacksStub = sandbox.stub(ecrClient, 'listAllRepositories').returns(intoCollection([])) + sandbox.stub(SamUtilsModule, 'getRecentResponse').returns(undefined) + const createCommonButtonsStub = sandbox.stub(ButtonsModule, 'createCommonButtons') + sandbox + .stub(AwsConsoleModule, 'getAwsConsoleUrl') + .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) + + // Act + const prompter = createEcrPrompter(ecrClient, mementoRootKey) + await sleep(50) + + // Assert + assert.ok(createCommonButtonsStub.calledOnce) + assert.ok( + createCommonButtonsStub.calledWithExactly( + samSyncUrl, + vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) + ) + ) + assert.ok(listAllStacksStub.calledOnce) + assert.strictEqual(prompter.quickPick.title, 'Select an ECR Repository') + assert.strictEqual(prompter.quickPick.placeholder, 'Select a repository (or enter a name to create one)') + assert.strictEqual(prompter.quickPick.items.length, 1) + assert.deepStrictEqual( + prompter.quickPick.items[0].label, + 'No ECR repositories in region "us-east-1". Enter a name to create a new one.' + ) + assert.deepStrictEqual(prompter.quickPick.items[0].data, undefined) + }) +}) diff --git a/packages/core/src/test/shared/ui/sam/paramsSourcePrompter.test.ts b/packages/core/src/test/shared/ui/sam/paramsSourcePrompter.test.ts new file mode 100644 index 00000000000..e935e59acca --- /dev/null +++ b/packages/core/src/test/shared/ui/sam/paramsSourcePrompter.test.ts @@ -0,0 +1,100 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { + createDeployParamsSourcePrompter, + createSyncParamsSourcePrompter, + ParamsSource, +} from '../../../../shared/ui/sam/paramsSourcePrompter' +import { DataQuickPickItem } from '../../../../shared/ui/pickerPrompter' + +describe('createSyncParamsSourcePrompter', () => { + it('should return a prompter with the correct items with no valid samconfig', () => { + const expectedItems: DataQuickPickItem[] = [ + { + label: 'Specify required parameters and save as defaults', + data: ParamsSource.SpecifyAndSave, + }, + { + label: 'Specify required parameters', + data: ParamsSource.Specify, + }, + ] + const prompter = createSyncParamsSourcePrompter(false) + const quickPick = prompter.quickPick + assert.strictEqual(quickPick.title, 'Specify parameter source for sync') + assert.strictEqual(quickPick.placeholder, 'Press enter to proceed with highlighted option') + assert.strictEqual(quickPick.items.length, 2) + assert.deepStrictEqual(quickPick.items, expectedItems) + }) + + it('should return a prompter with the correct items with valid samconfig', () => { + const expectedItems: DataQuickPickItem[] = [ + { + label: 'Specify required parameters and save as defaults', + data: ParamsSource.SpecifyAndSave, + }, + { + label: 'Specify required parameters', + data: ParamsSource.Specify, + }, + { + label: 'Use default values from samconfig', + data: ParamsSource.SamConfig, + }, + ] + const prompter = createSyncParamsSourcePrompter(true) + const quickPick = prompter.quickPick + assert.strictEqual(quickPick.title, 'Specify parameter source for sync') + assert.strictEqual(quickPick.placeholder, 'Press enter to proceed with highlighted option') + assert.strictEqual(quickPick.items.length, 3) + assert.deepStrictEqual(quickPick.items, expectedItems) + }) +}) + +describe('createDeployParamsSourcePrompter', () => { + it('should return a prompter with the correct items with no valid samconfig', () => { + const expectedItems: DataQuickPickItem[] = [ + { + label: 'Specify required parameters and save as defaults', + data: ParamsSource.SpecifyAndSave, + }, + { + label: 'Specify required parameters', + data: ParamsSource.Specify, + }, + ] + const prompter = createDeployParamsSourcePrompter(false) + const quickPick = prompter.quickPick + assert.strictEqual(quickPick.title, 'Specify parameter source for deploy') + assert.strictEqual(quickPick.placeholder, 'Press enter to proceed with highlighted option') + assert.strictEqual(quickPick.items.length, 2) + assert.deepStrictEqual(quickPick.items, expectedItems) + }) + + it('should return a prompter with the correct items with valid samconfig', () => { + const expectedItems: DataQuickPickItem[] = [ + { + label: 'Specify required parameters and save as defaults', + data: ParamsSource.SpecifyAndSave, + }, + { + label: 'Specify required parameters', + data: ParamsSource.Specify, + }, + { + label: 'Use default values from samconfig', + data: ParamsSource.SamConfig, + }, + ] + const prompter = createDeployParamsSourcePrompter(true) + const quickPick = prompter.quickPick + assert.strictEqual(quickPick.title, 'Specify parameter source for deploy') + assert.strictEqual(quickPick.placeholder, 'Press enter to proceed with highlighted option') + assert.strictEqual(quickPick.items.length, 3) + assert.deepStrictEqual(quickPick.items, expectedItems) + }) +}) diff --git a/packages/core/src/test/shared/ui/sam/stackPrompter.test.ts b/packages/core/src/test/shared/ui/sam/stackPrompter.test.ts new file mode 100644 index 00000000000..37237030d7e --- /dev/null +++ b/packages/core/src/test/shared/ui/sam/stackPrompter.test.ts @@ -0,0 +1,132 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import CloudFormation from 'aws-sdk/clients/cloudformation' +import sinon from 'sinon' +import * as vscode from 'vscode' +import * as AwsConsoleModule from '../../../../shared/awsConsole' +import * as SamUtilsModule from '../../../../shared/sam/utils' +import * as ButtonsModule from '../../../../shared/ui/buttons' +import { DefaultCloudFormationClient } from '../../../../shared/clients/cloudFormationClient' +import { samSyncUrl } from '../../../../shared/constants' +import { createStackPrompter } from '../../../../shared/ui/sam/stackPrompter' +import { intoCollection } from '../../../../shared/utilities/collectionUtils' +import { sleep } from '../../../../shared/utilities/timeoutUtils' + +describe('createStackPrompter', () => { + let sandbox: sinon.SinonSandbox + const cfnClient = new DefaultCloudFormationClient('us-east-1') + const mementoRootKey = 'samcli.sync.params' + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should create a prompter with existing stacks', async () => { + // Arrange + const stackSummaries: CloudFormation.StackSummary[][] = [ + [ + { + StackName: 'stack1', + StackStatus: 'CREATE_COMPLETE', + CreationTime: new Date(), + } as CloudFormation.StackSummary, + { + StackName: 'stack2', + StackStatus: 'CREATE_COMPLETE', + CreationTime: new Date(), + } as CloudFormation.StackSummary, + { + StackName: 'stack3', + StackStatus: 'CREATE_COMPLETE', + CreationTime: new Date(), + } as CloudFormation.StackSummary, + ], + ] + const expectedItems = [ + { + label: 'stack1', + data: 'stack1', + description: undefined, + invalidSelection: false, + recentlyUsed: false, + }, + { + label: 'stack2', + data: 'stack2', + description: undefined, + invalidSelection: false, + recentlyUsed: false, + }, + { + label: 'stack3', + data: 'stack3', + description: undefined, + invalidSelection: false, + recentlyUsed: false, + }, + ] + const listAllStacksStub = sandbox.stub(cfnClient, 'listAllStacks').returns(intoCollection(stackSummaries)) + sandbox.stub(SamUtilsModule, 'getRecentResponse').returns(undefined) + const createCommonButtonsStub = sandbox.stub(ButtonsModule, 'createCommonButtons') + sandbox + .stub(AwsConsoleModule, 'getAwsConsoleUrl') + .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) + + // Act + const prompter = createStackPrompter(cfnClient, mementoRootKey, samSyncUrl) + await sleep(50) + + // Assert + assert.ok(createCommonButtonsStub.calledOnce) + assert.ok( + createCommonButtonsStub.calledWithExactly( + samSyncUrl, + vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) + ) + ) + assert.ok(listAllStacksStub.calledOnce) + assert.strictEqual(prompter.quickPick.title, 'Select a CloudFormation Stack') + assert.strictEqual(prompter.quickPick.placeholder, 'Select a stack (or enter a name to create one)') + assert.strictEqual(prompter.quickPick.items.length, 3) + assert.deepStrictEqual(prompter.quickPick.items, expectedItems) + }) + + it('should include no items found message if no stacks exist', async () => { + const listAllStacksStub = sandbox.stub(cfnClient, 'listAllStacks').returns(intoCollection([])) + sandbox.stub(SamUtilsModule, 'getRecentResponse').returns(undefined) + const createCommonButtonsStub = sandbox.stub(ButtonsModule, 'createCommonButtons') + sandbox + .stub(AwsConsoleModule, 'getAwsConsoleUrl') + .returns(vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`)) + + // Act + const prompter = createStackPrompter(cfnClient, mementoRootKey, samSyncUrl) + await sleep(50) + + // Assert + assert.ok(createCommonButtonsStub.calledOnce) + assert.ok( + createCommonButtonsStub.calledWithExactly( + samSyncUrl, + vscode.Uri.parse(`https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1`) + ) + ) + assert.ok(listAllStacksStub.calledOnce) + assert.strictEqual(prompter.quickPick.title, 'Select a CloudFormation Stack') + assert.strictEqual(prompter.quickPick.placeholder, 'Select a stack (or enter a name to create one)') + assert.strictEqual(prompter.quickPick.items.length, 1) + assert.deepStrictEqual( + prompter.quickPick.items[0].label, + 'No stacks in region "us-east-1". Enter a name to create a new one.' + ) + assert.deepStrictEqual(prompter.quickPick.items[0].data, undefined) + }) +}) diff --git a/packages/core/src/test/shared/ui/sam/templatePrompter.test.ts b/packages/core/src/test/shared/ui/sam/templatePrompter.test.ts new file mode 100644 index 00000000000..520e79b0522 --- /dev/null +++ b/packages/core/src/test/shared/ui/sam/templatePrompter.test.ts @@ -0,0 +1,52 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' + +import sinon from 'sinon' +import * as Cfn from '../../../../shared/cloudformation/cloudformation' +import { CloudFormationTemplateRegistry } from '../../../../shared/fs/templateRegistry' +import { WatchedItem } from '../../../../shared/fs/watchedFiles' +import * as SamUtilsModule from '../../../../shared/sam/utils' +import { createTemplatePrompter } from '../../../../shared/ui/sam/templatePrompter' +import { assertEqualPaths } from '../../../testUtil' + +describe('createTemplatePrompter', () => { + let registry: CloudFormationTemplateRegistry + let sandbox: sinon.SinonSandbox + const mementoRootKey = 'samcli.sync.params' + + beforeEach(() => { + sandbox = sinon.createSandbox() + //Create a mock instance of CloudFormationTemplateRegistry + registry = { + items: [ + { path: '/path/to/template1.yaml', item: {} } as WatchedItem, + { path: '/path/to/template2.yaml', item: {} } as WatchedItem, + ], + } as CloudFormationTemplateRegistry // Typecasting to match expected type + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should create quick pick items from registry items', () => { + // Arrange + const recentTemplatePathStub = sandbox.stub().returns(undefined) + sandbox.replace(SamUtilsModule, 'getRecentResponse', recentTemplatePathStub) + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + assert.ok(workspaceFolder) + + const prompter = createTemplatePrompter(registry, mementoRootKey) + + assert.strictEqual(prompter.quickPick.items.length, 2) + assertEqualPaths(prompter.quickPick.items[0].label, '/path/to/template1.yaml') + assertEqualPaths(prompter.quickPick.items[1].label, '/path/to/template2.yaml') + assert.strictEqual(prompter.quickPick.title, 'Select a SAM/CloudFormation Template') + assert.strictEqual(prompter.quickPick.placeholder, 'Select a SAM/CloudFormation Template') + }) +}) diff --git a/packages/core/src/test/shared/wizards/prompterTester.ts b/packages/core/src/test/shared/wizards/prompterTester.ts index 432944ce947..04169830648 100644 --- a/packages/core/src/test/shared/wizards/prompterTester.ts +++ b/packages/core/src/test/shared/wizards/prompterTester.ts @@ -6,29 +6,37 @@ import assert from 'assert' import { TestInputBox, TestQuickPick } from '../vscode/quickInput' import { getTestWindow, TestWindow } from '../vscode/window' +import { waitUntil } from '../../../shared/utilities/timeoutUtils' + +interface PrompterTesterConfig { + testWindow?: TestWindow + handlerTimeout?: number +} export class PrompterTester { private quickPickHandlers: Map void> = new Map() private inputBoxHanlder: Map void> = new Map() private testWindow: TestWindow private callLog = Array() + private handlerTimeout: number = 3000 // Default timeout to 3 seconds private callLogCount = new Map() - private constructor(testWindow?: TestWindow) { - this.testWindow = testWindow || getTestWindow() + private constructor(config?: PrompterTesterConfig) { + this.testWindow = config?.testWindow || getTestWindow() + this.handlerTimeout = config?.handlerTimeout || this.handlerTimeout } - static init(testWindow?: TestWindow): PrompterTester { - return new PrompterTester(testWindow) + static init(config?: PrompterTesterConfig): PrompterTester { + return new PrompterTester(config) } - handleQuickPick(titlePattern: string, handler: (input: TestQuickPick) => void): PrompterTester { + handleQuickPick(titlePattern: string, handler: (input: TestQuickPick) => void | Promise): PrompterTester { this.quickPickHandlers.set(titlePattern, handler) this.callLogCount.set(titlePattern, 0) return this } - handleInputBox(titlePattern: string, handler: (input: TestInputBox) => void): PrompterTester { + handleInputBox(titlePattern: string, handler: (input: TestInputBox) => void | Promise): PrompterTester { this.inputBoxHanlder.set(titlePattern, handler) this.callLogCount.set(titlePattern, 0) return this @@ -96,15 +104,27 @@ export class PrompterTester { return [...this.quickPickHandlers.keys(), ...this.inputBoxHanlder.keys()] } - private handle(input: any, handlers: any) { - for (const [pattern, handler] of handlers) { - if (input.title?.includes(pattern)) { - handler(input) - this.record(pattern) - return - } + private async handle(input: any, handlers: any) { + const handler = handlers.get(input.title) + + if (!handler) { + return this.handleUnknownPrompter(input) + } + + try { + await waitUntil( + async () => { + await handler(input) + return true + }, + { timeout: this.handlerTimeout, interval: 50, truthy: false } + ) + } catch (e) { + // clean up UI on callback function early exit (e.g assertion failure) + await input.dispose() + throw e } - this.handleUnknownPrompter(input) + this.record(input.title) } private handleUnknownPrompter(input: any) { diff --git a/packages/core/src/testInteg/sam.test.ts b/packages/core/src/testInteg/sam.test.ts index f2181e4ba77..3729b8b0607 100644 --- a/packages/core/src/testInteg/sam.test.ts +++ b/packages/core/src/testInteg/sam.test.ts @@ -706,7 +706,7 @@ describe('SAM Integration Tests', async function () { const appNode = new AppNode(samAppLocation) getTestWindow().onDidShowQuickPick((input) => { - if (input.title?.includes('Specify parameters for build')) { + if (input.title?.includes('Specify parameter source for build')) { input.acceptItem(input.items[0]) const item = input.items[0] as DataQuickPickItem assert.deepStrictEqual(item.data as ParamsSource, ParamsSource.Specify) diff --git a/packages/toolkit/.changes/next-release/Bug Fix-6d959757-2d07-40a7-af18-f1b52473ec2d.json b/packages/toolkit/.changes/next-release/Bug Fix-6d959757-2d07-40a7-af18-f1b52473ec2d.json new file mode 100644 index 00000000000..132bf3bab6b --- /dev/null +++ b/packages/toolkit/.changes/next-release/Bug Fix-6d959757-2d07-40a7-af18-f1b52473ec2d.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "SAM: Update Sync and Deploy prompter titles for parameter source" +} diff --git a/packages/toolkit/.changes/next-release/Bug Fix-a60cf459-c957-4a38-9d77-2fc512fbac4a.json b/packages/toolkit/.changes/next-release/Bug Fix-a60cf459-c957-4a38-9d77-2fc512fbac4a.json new file mode 100644 index 00000000000..7470f82d4b7 --- /dev/null +++ b/packages/toolkit/.changes/next-release/Bug Fix-a60cf459-c957-4a38-9d77-2fc512fbac4a.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "SAM: Update Sync prompter title for sync parameters" +}