diff --git a/src/cli/tui/components/DeployStatus.tsx b/src/cli/tui/components/DeployStatus.tsx index 837109a46..277168a64 100644 --- a/src/cli/tui/components/DeployStatus.tsx +++ b/src/cli/tui/components/DeployStatus.tsx @@ -66,7 +66,13 @@ type ResourceStatus = | 'UPDATE_FAILED' | 'DELETE_IN_PROGRESS' | 'DELETE_COMPLETE' - | 'DELETE_FAILED'; + | 'DELETE_FAILED' + | 'ROLLBACK_IN_PROGRESS' + | 'ROLLBACK_COMPLETE' + | 'ROLLBACK_FAILED' + | 'UPDATE_ROLLBACK_IN_PROGRESS' + | 'UPDATE_ROLLBACK_COMPLETE' + | 'UPDATE_ROLLBACK_FAILED'; interface ParsedResource { resourceType: string; @@ -75,8 +81,16 @@ interface ParsedResource { /** * Get color for a resource status. + * + * Rollback states are checked first: in CloudFormation a ROLLBACK indicates a + * failed deploy whose changes are being reverted, so even a "clean" finish + * (ROLLBACK_COMPLETE / UPDATE_ROLLBACK_COMPLETE) is a failure, not a success. + * Coloring those green would mislead the user (see #1610). A failed rollback is + * the worst case (red); an in-progress or completed rollback signals + * "recovering from failure" (yellow). */ function getStatusColor(status: ResourceStatus): string | undefined { + if (status.includes('ROLLBACK')) return status.endsWith('_FAILED') ? 'red' : 'yellow'; if (status.endsWith('_COMPLETE')) return 'green'; if (status.endsWith('_FAILED')) return 'red'; if (status.endsWith('_IN_PROGRESS')) return 'cyan'; @@ -103,7 +117,7 @@ function parseResourceMessage(msg: DeployMessage): ParsedResource | null { // Format: "StackName | STATUS | AWS::Service::Resource | LogicalId" const resourceMatch = /(AWS::\S+)/.exec(text); const statusMatch = - /(CREATE_IN_PROGRESS|CREATE_COMPLETE|CREATE_FAILED|UPDATE_IN_PROGRESS|UPDATE_COMPLETE|UPDATE_FAILED|DELETE_IN_PROGRESS|DELETE_COMPLETE|DELETE_FAILED)/.exec( + /(UPDATE_ROLLBACK_IN_PROGRESS|UPDATE_ROLLBACK_COMPLETE|UPDATE_ROLLBACK_FAILED|ROLLBACK_IN_PROGRESS|ROLLBACK_COMPLETE|ROLLBACK_FAILED|CREATE_IN_PROGRESS|CREATE_COMPLETE|CREATE_FAILED|UPDATE_IN_PROGRESS|UPDATE_COMPLETE|UPDATE_FAILED|DELETE_IN_PROGRESS|DELETE_COMPLETE|DELETE_FAILED)/.exec( text ); diff --git a/src/cli/tui/components/__tests__/DeployStatus.test.tsx b/src/cli/tui/components/__tests__/DeployStatus.test.tsx index 63aaa26e2..5b18b2e14 100644 --- a/src/cli/tui/components/__tests__/DeployStatus.test.tsx +++ b/src/cli/tui/components/__tests__/DeployStatus.test.tsx @@ -3,7 +3,17 @@ import { DeployStatus } from '../DeployStatus.js'; import { render } from 'ink-testing-library'; import React from 'react'; import stripAnsi from 'strip-ansi'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +// Force ink/chalk to emit ANSI color codes so the status color-coding tests are +// deterministic regardless of TTY/CI. vi.hoisted is lifted above the ink import +// by vitest, so FORCE_COLOR is set before ink evaluates its color support. +vi.hoisted(() => { + process.env.FORCE_COLOR = '1'; +}); + +// ink/chalk ANSI foreground color codes. +const ANSI = { green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m' } as const; function makeMsg( message: string, @@ -95,6 +105,21 @@ describe('DeployStatus', () => { expect(lastFrame()).not.toContain('Some general info'); }); + it('renders ROLLBACK statuses instead of dropping the events', () => { + const messages = [ + makeResourceMsg('CloudFormation::Stack', 'ROLLBACK_IN_PROGRESS'), + makeResourceMsg('BedrockAgentCore::Gateway', 'UPDATE_ROLLBACK_IN_PROGRESS'), + makeResourceMsg('CloudFormation::Stack', 'ROLLBACK_FAILED'), + ]; + + const { lastFrame } = render(); + const frame = lastFrame()!; + + expect(frame).toContain('ROLLBACK_IN_PROGRESS'); + expect(frame).toContain('UPDATE_ROLLBACK_IN_PROGRESS'); + expect(frame).toContain('ROLLBACK_FAILED'); + }); + it('shows only last 8 resource events', () => { const messages = Array.from({ length: 12 }, (_, i) => makeResourceMsg(`Service::Resource${i}`, 'CREATE_COMPLETE') @@ -112,6 +137,60 @@ describe('DeployStatus', () => { }); }); + describe('status color coding', () => { + // Returns the ANSI color code wrapping the line that contains the status, e.g. + // for "...\x1b[33mService::Resource ROLLBACK_COMPLETE\x1b[39m" -> "\x1b[33m". + // A single resource line is rendered as one colored Text node, so the opening + // code is the last ANSI escape before the status word on that line. + function colorOf(frame: string, status: string): string | undefined { + const line = frame.split('\n').find(l => l.includes(status)); + if (!line) return undefined; + const before = line.slice(0, line.indexOf(status)); + // eslint-disable-next-line no-control-regex + const codes = before.match(/\x1b\[\d+m/g); + return codes?.[codes.length - 1]; + } + + it('renders CREATE_COMPLETE green (sanity check)', () => { + const messages = [makeResourceMsg('Lambda::Function', 'CREATE_COMPLETE')]; + const { lastFrame } = render(); + + expect(colorOf(lastFrame()!, 'CREATE_COMPLETE')).toBe(ANSI.green); + }); + + it('does NOT render ROLLBACK_COMPLETE green — it is a failed deploy, not a success (#1610)', () => { + const messages = [makeResourceMsg('CloudFormation::Stack', 'ROLLBACK_COMPLETE')]; + const { lastFrame } = render(); + + const color = colorOf(lastFrame()!, 'ROLLBACK_COMPLETE'); + expect(color).not.toBe(ANSI.green); + expect(color).toBe(ANSI.yellow); + }); + + it('does NOT render UPDATE_ROLLBACK_COMPLETE green', () => { + const messages = [makeResourceMsg('BedrockAgentCore::Gateway', 'UPDATE_ROLLBACK_COMPLETE')]; + const { lastFrame } = render(); + + const color = colorOf(lastFrame()!, 'UPDATE_ROLLBACK_COMPLETE'); + expect(color).not.toBe(ANSI.green); + expect(color).toBe(ANSI.yellow); + }); + + it('renders rollback in-progress states yellow (recovering from failure)', () => { + const messages = [makeResourceMsg('CloudFormation::Stack', 'ROLLBACK_IN_PROGRESS')]; + const { lastFrame } = render(); + + expect(colorOf(lastFrame()!, 'ROLLBACK_IN_PROGRESS')).toBe(ANSI.yellow); + }); + + it('renders ROLLBACK_FAILED red (worst case)', () => { + const messages = [makeResourceMsg('CloudFormation::Stack', 'ROLLBACK_FAILED')]; + const { lastFrame } = render(); + + expect(colorOf(lastFrame()!, 'ROLLBACK_FAILED')).toBe(ANSI.red); + }); + }); + describe('progress bar', () => { it('renders progress bar with completed/total count', () => { const messages = [makeMsg('deploying', 'CDK_TOOLKIT_I5502', { completed: 3, total: 10 })];