diff --git a/packages/core/src/awsService/cloudformation/commands/cfnCommands.ts b/packages/core/src/awsService/cloudformation/commands/cfnCommands.ts index ee7c829cf1c..b2f2ac875cc 100644 --- a/packages/core/src/awsService/cloudformation/commands/cfnCommands.ts +++ b/packages/core/src/awsService/cloudformation/commands/cfnCommands.ts @@ -135,7 +135,8 @@ export function viewChangeSetCommand(client: LanguageClient, diffProvider: DiffW params.changeSetName, true, [], - describeChangeSetResult.deploymentMode + describeChangeSetResult.deploymentMode, + describeChangeSetResult.status ) void commands.executeCommand(commandKey('diff.focus')) } catch (error) { @@ -483,7 +484,7 @@ async function changeSetSteps( try { environmentFile = await environmentManager.selectEnvironmentFile(templateUri, paramDefinition) } catch (error) { - getLogger().warn(`Failed to select environment file:: ${extractErrorMessage(error)}`) + getLogger().warn(`Failed to select environment file: ${extractErrorMessage(error)}`) } if (paramDefinition.length > 0) { diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/validationWorkflow.ts b/packages/core/src/awsService/cloudformation/stacks/actions/validationWorkflow.ts index 1586f2a53ed..70f0565678a 100644 --- a/packages/core/src/awsService/cloudformation/stacks/actions/validationWorkflow.ts +++ b/packages/core/src/awsService/cloudformation/stacks/actions/validationWorkflow.ts @@ -4,7 +4,7 @@ */ import { v4 as uuidv4 } from 'uuid' -import { Parameter, Capability } from '@aws-sdk/client-cloudformation' +import { Parameter, Capability, ChangeSetStatus } from '@aws-sdk/client-cloudformation' import { StackActionPhase, StackChange, @@ -163,7 +163,8 @@ export class Validation { this.changeSetName, this.shouldEnableDeployment, validationDetail, - deploymentMode + deploymentMode, + ChangeSetStatus.CREATE_COMPLETE ) void commands.executeCommand(commandKey('diff.focus')) } diff --git a/packages/core/src/awsService/cloudformation/ui/diffWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/diffWebviewProvider.ts index 3a70650eccd..f7c06f5f6f8 100644 --- a/packages/core/src/awsService/cloudformation/ui/diffWebviewProvider.ts +++ b/packages/core/src/awsService/cloudformation/ui/diffWebviewProvider.ts @@ -9,6 +9,7 @@ import { DiffViewHelper } from './diffViewHelper' import { commandKey } from '../utils' import { StackViewCoordinator } from './stackViewCoordinator' import { showWarningConfirmation } from './message' +import { ChangeSetStatus } from '@aws-sdk/client-cloudformation' const webviewCommandOpenDiff = 'openDiff' @@ -24,6 +25,7 @@ export class DiffWebviewProvider implements WebviewViewProvider, Disposable { private readonly disposables: Disposable[] = [] private validationDetail: ValidationDetail[] = [] private deploymentMode?: DeploymentMode + private changeSetStatus?: string constructor(private readonly coordinator: StackViewCoordinator) { this.disposables.push( @@ -46,7 +48,8 @@ export class DiffWebviewProvider implements WebviewViewProvider, Disposable { changeSetName?: string, enableDeployments = false, validationDetail?: ValidationDetail[], - deploymentMode?: DeploymentMode + deploymentMode?: DeploymentMode, + changeSetStatus?: string ) { this.stackName = stackName this.changes = changes @@ -58,6 +61,7 @@ export class DiffWebviewProvider implements WebviewViewProvider, Disposable { this.validationDetail = validationDetail } this.deploymentMode = deploymentMode + this.changeSetStatus = changeSetStatus await this.coordinator.setChangeSetMode(stackName, true) if (this._view) { @@ -120,6 +124,23 @@ export class DiffWebviewProvider implements WebviewViewProvider, Disposable { const displayedChanges = changes.slice(startIndex, endIndex) const hasNext = this.currentPage < this.totalPages - 1 const hasPrev = this.currentPage > 0 + const terminalChangeSetStatuses: string[] = [ + ChangeSetStatus.CREATE_COMPLETE, + ChangeSetStatus.FAILED, + ChangeSetStatus.DELETE_FAILED, + ] + + const deletionButton = ` + + ` if (!changes || changes.length === 0) { return ` @@ -137,6 +158,23 @@ export class DiffWebviewProvider implements WebviewViewProvider, Disposable {

No changes detected for stack: ${this.stackName}

+ ${ + this.changeSetName && + this.changeSetStatus && + terminalChangeSetStatuses.includes(this.changeSetStatus) + ? ` +
+ ${deletionButton} +
+ + ` + : '' + } ` @@ -351,9 +389,15 @@ export class DiffWebviewProvider implements WebviewViewProvider, Disposable { ` const deploymentButtons = - this.changeSetName && this.enableDeployments + this.changeSetName && + this.enableDeployments && + this.changeSetStatus && + terminalChangeSetStatuses.includes(this.changeSetStatus) ? `
+ ${ + this.changeSetStatus === ChangeSetStatus.CREATE_COMPLETE + ? ` - + ">Deploy Changes` + : '' + } + ${deletionButton}
` : '' diff --git a/packages/core/src/test/awsService/cloudformation/ui/diffWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/diffWebviewProvider.test.ts index 8213b955393..ff87dbf72d3 100644 --- a/packages/core/src/test/awsService/cloudformation/ui/diffWebviewProvider.test.ts +++ b/packages/core/src/test/awsService/cloudformation/ui/diffWebviewProvider.test.ts @@ -10,6 +10,7 @@ import { DeploymentMode, StackChange, } from '../../../../awsService/cloudformation/stacks/actions/stackActionRequestType' +import { ChangeSetStatus } from '@aws-sdk/client-cloudformation' describe('DiffWebviewProvider', function () { let sandbox: sinon.SinonSandbox @@ -310,4 +311,246 @@ describe('DiffWebviewProvider', function () { assert.ok(html.includes('Drift Status')) }) }) + + describe('deployment button conditional rendering', function () { + it('should show deploy button when changeset is CREATE_COMPLETE and deployments enabled', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'TestResource', + }, + }, + ] + + void provider.updateData( + 'test-stack', + changes, + 'test-changeset', + true, + undefined, + undefined, + 'CREATE_COMPLETE' + ) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(mockWebview.webview.html.includes('Deploy Changes')) + assert.ok(mockWebview.webview.html.includes('Delete Changeset')) + }) + + it('should not show deploy button when changeset is not CREATE_COMPLETE', function () { + // changes are not available if a changeset is not created + const changes: StackChange[] = [] + + void provider.updateData('test-stack', changes, 'test-changeset', true, undefined, undefined, 'FAILED') + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(!mockWebview.webview.html.includes('Deploy Changes')) + assert.ok(mockWebview.webview.html.includes('Delete Changeset')) + }) + + it('should not show deployment buttons when deployments not enabled', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'TestResource', + }, + }, + ] + + void provider.updateData( + 'test-stack', + changes, + 'test-changeset', + false, + undefined, + undefined, + 'CREATE_COMPLETE' + ) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(!mockWebview.webview.html.includes('Deploy Changes')) + assert.ok(!mockWebview.webview.html.includes('deployment-actions')) + }) + + it('should not show deployment buttons when no changeset name', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'TestResource', + }, + }, + ] + + void provider.updateData('test-stack', changes, undefined, true, undefined, undefined, 'CREATE_COMPLETE') + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(!mockWebview.webview.html.includes('Deploy Changes')) + assert.ok(!mockWebview.webview.html.includes('deployment-actions')) + }) + + it('should not show deployment buttons when changeset status is DELETE_PENDING', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'TestResource', + }, + }, + ] + + void provider.updateData( + 'test-stack', + changes, + 'test-changeset', + true, + undefined, + undefined, + 'DELETE_PENDING' + ) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(!mockWebview.webview.html.includes('Deploy Changes')) + assert.ok(!mockWebview.webview.html.includes('Delete Changeset')) + assert.ok(!mockWebview.webview.html.includes('deployment-actions')) + }) + + it('should not show deployment buttons when changeset status is CREATE_PENDING', function () { + const changes: StackChange[] = [] + + void provider.updateData( + 'test-stack', + changes, + 'test-changeset', + true, + undefined, + undefined, + 'CREATE_PENDING' + ) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(!mockWebview.webview.html.includes('Deploy Changes')) + assert.ok(!mockWebview.webview.html.includes('Delete Changeset')) + assert.ok(!mockWebview.webview.html.includes('deployment-actions')) + }) + }) + + describe('pagination', function () { + it('should show pagination controls when changes exceed page size', function () { + // Create 60 changes (exceeds default pageSize of 50) + const changes: StackChange[] = Array.from({ length: 60 }, (_, i) => ({ + resourceChange: { + action: 'Add', + logicalResourceId: `Resource${i}`, + resourceType: 'AWS::S3::Bucket', + }, + })) + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(html.includes('Page 1 of 2')) + assert.ok(html.includes('nextPage()')) + assert.ok(html.includes('prevPage()')) + assert.ok(html.includes('pagination-controls')) + }) + + it('should not show pagination for small change sets', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'SingleResource', + resourceType: 'AWS::S3::Bucket', + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(!html.includes('pagination-controls')) + assert.ok(!html.includes('Page 1 of')) + }) + + it('should display correct page numbers and navigation state', function () { + const changes: StackChange[] = Array.from({ length: 150 }, (_, i) => ({ + resourceChange: { + action: 'Add', + logicalResourceId: `Resource${i}`, + }, + })) + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(html.includes('Page 1 of 3')) + // Previous button should be disabled on first page + assert.ok(html.includes('opacity: 0.5')) + assert.ok(html.includes('cursor: not-allowed')) + }) + }) + + describe('empty changes handling', function () { + it('should show no changes message when changes is undefined', function () { + void provider.updateData( + 'test-stack', + undefined as any, + 'test-changeset', + undefined, + undefined, + undefined, + ChangeSetStatus.FAILED + ) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(mockWebview.webview.html.includes('No changes detected')) + assert.ok(mockWebview.webview.html.includes('test-stack')) + assert.ok(mockWebview.webview.html.includes('Delete Changeset')) + }) + + it('should show no changes message when changes array is empty', function () { + void provider.updateData( + 'empty-stack', + [], + 'test-changeset', + undefined, + undefined, + undefined, + ChangeSetStatus.FAILED + ) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + const html = mockWebview.webview.html + + assert.ok(html.includes('No changes detected')) + assert.ok(html.includes('empty-stack')) + assert.ok(html.includes('Delete Changeset')) + }) + + it('should not show delete button when no changeset status', function () { + void provider.updateData('empty-stack', [], 'test-changeset', undefined, undefined, undefined, undefined) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + const html = mockWebview.webview.html + + assert.ok(html.includes('No changes detected')) + assert.ok(html.includes('empty-stack')) + assert.ok(!html.includes('Delete Changeset')) + }) + + it('should not show table when no changes', function () { + const html = setupProviderWithChanges('empty-stack', []) + + assert.ok(!html.includes('