From c1badd2a89321bc8106fff3127a89bc0d5bbe3ca Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed Date: Wed, 10 Dec 2025 12:37:42 -0500 Subject: [PATCH 1/8] Support DescribeEvents as LSP method --- src/handlers/StackHandler.ts | 22 ++ src/protocol/LspStackHandlers.ts | 7 + src/server/CfnLspProviders.ts | 4 + src/server/CfnServer.ts | 7 + src/stacks/StackOperationEventManager.ts | 94 ++++++++ src/stacks/StackRequestType.ts | 33 ++- src/stacks/actions/StackActionParser.ts | 24 ++ tst/unit/handlers/StackHandler.test.ts | 89 ++++++++ .../stackActions/StackActionParser.test.ts | 76 +++++++ .../stacks/StackOperationEventManager.test.ts | 212 ++++++++++++++++++ tst/utils/MockServerComponents.ts | 2 + 11 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 src/stacks/StackOperationEventManager.ts create mode 100644 tst/unit/stacks/StackOperationEventManager.test.ts diff --git a/src/handlers/StackHandler.ts b/src/handlers/StackHandler.ts index 1321af56..c779c9eb 100644 --- a/src/handlers/StackHandler.ts +++ b/src/handlers/StackHandler.ts @@ -18,6 +18,7 @@ import { parseGetStackEventsParams, parseClearStackEventsParams, parseDescribeStackParams, + parseDescribeEventsParams, } from '../stacks/actions/StackActionParser'; import { TemplateUri, @@ -48,6 +49,8 @@ import { DescribeStackResult, DescribeChangeSetParams, DescribeChangeSetResult, + DescribeEventsParams, + DescribeEventsResult, } from '../stacks/StackRequestType'; import { TelemetryService } from '../telemetry/TelemetryService'; import { EventType } from '../usageTracker/UsageTracker'; @@ -444,3 +447,22 @@ export function describeStackHandler( } }; } + +export function describeEventsHandler( + components: ServerComponents, +): RequestHandler { + return async (rawParams): Promise => { + try { + const params = parseWithPrettyError(parseDescribeEventsParams, rawParams); + + if (params.refresh) { + const result = await components.stackOperationEventManager.refresh(params.stackName ?? ''); + return { operations: result.operations, nextToken: undefined, gapDetected: result.gapDetected }; + } + + return await components.stackOperationEventManager.fetchEvents(params.stackName ?? '', params.nextToken); + } catch (error) { + handleLspError(error, 'Failed to describe events'); + } + }; +} diff --git a/src/protocol/LspStackHandlers.ts b/src/protocol/LspStackHandlers.ts index bc10020f..b93e0004 100644 --- a/src/protocol/LspStackHandlers.ts +++ b/src/protocol/LspStackHandlers.ts @@ -53,6 +53,9 @@ import { DescribeChangeSetParams, DescribeChangeSetResult, DescribeChangeSetRequest, + DescribeEventsParams, + DescribeEventsResult, + DescribeEventsRequest, } from '../stacks/StackRequestType'; import { Identifiable } from './LspTypes'; @@ -142,4 +145,8 @@ export class LspStackHandlers { onDescribeStack(handler: RequestHandler) { this.connection.onRequest(DescribeStackRequest.method, handler); } + + onDescribeEvents(handler: RequestHandler) { + this.connection.onRequest(DescribeEventsRequest.method, handler); + } } diff --git a/src/server/CfnLspProviders.ts b/src/server/CfnLspProviders.ts index e0787f6e..76aba263 100644 --- a/src/server/CfnLspProviders.ts +++ b/src/server/CfnLspProviders.ts @@ -25,6 +25,7 @@ import { StackActionWorkflow } from '../stacks/actions/StackActionWorkflowType'; import { ValidationWorkflow } from '../stacks/actions/ValidationWorkflow'; import { StackEventManager } from '../stacks/StackEventManager'; import { StackManager } from '../stacks/StackManager'; +import { StackOperationEventManager } from '../stacks/StackOperationEventManager'; import { Closeable, closeSafely } from '../utils/Closeable'; import { Configurable, Configurables } from '../utils/Configurable'; import { CfnExternal } from './CfnExternal'; @@ -38,6 +39,7 @@ export class CfnLspProviders implements Configurables, Closeable { readonly changeSetDeletionWorkflowService: StackActionWorkflow; readonly stackManager: StackManager; readonly stackEventManager: StackEventManager; + readonly stackOperationEventManager: StackOperationEventManager; readonly resourceStateManager: ResourceStateManager; readonly resourceStateImporter: ResourceStateImporter; readonly relationshipSchemaService: RelationshipSchemaService; @@ -60,6 +62,8 @@ export class CfnLspProviders implements Configurables, Closeable { overrides.stackManagementInfoProvider ?? new StackManagementInfoProvider(external.cfnService); this.stackManager = overrides.stackManager ?? new StackManager(external.cfnService); this.stackEventManager = overrides.stackEventManager ?? new StackEventManager(external.cfnService); + this.stackOperationEventManager = + overrides.stackOperationEventManager ?? new StackOperationEventManager(external.cfnService); this.validationWorkflowService = overrides.validationWorkflowService ?? ValidationWorkflow.create(core, external, core.validationManager); this.deploymentWorkflowService = diff --git a/src/server/CfnServer.ts b/src/server/CfnServer.ts index f7a2d47d..1edbb0ea 100644 --- a/src/server/CfnServer.ts +++ b/src/server/CfnServer.ts @@ -48,6 +48,7 @@ import { clearStackEventsHandler, describeStackHandler, describeChangeSetHandler, + describeEventsHandler, } from '../handlers/StackHandler'; import { LspComponents } from '../protocol/LspComponents'; import { LoggerFactory } from '../telemetry/LoggerFactory'; @@ -228,6 +229,12 @@ export class CfnServer { withOnlineGuard(this.components.onlineFeatureGuard, describeStackHandler(this.components)), ), ); + this.lsp.stackHandlers.onDescribeEvents( + withTelemetryContext( + 'Stack.Describe.Events', + withOnlineGuard(this.components.onlineFeatureGuard, describeEventsHandler(this.components)), + ), + ); this.lsp.cfnEnvironmentHandlers.onParseCfnEnvironmentFiles( withTelemetryContext('Cfn.Environment.Parse', parseCfnEnvironmentFilesHandler()), diff --git a/src/stacks/StackOperationEventManager.ts b/src/stacks/StackOperationEventManager.ts new file mode 100644 index 00000000..7ddfffbe --- /dev/null +++ b/src/stacks/StackOperationEventManager.ts @@ -0,0 +1,94 @@ +import { OperationEvent } from '@aws-sdk/client-cloudformation'; +import { CfnService } from '../services/CfnService'; +import { StackOperationGroup } from './StackRequestType'; + +export class StackOperationEventManager { + private static readonly MAX_REFRESH_PAGES = 5; + + private mostRecentEventId?: string; + private stackName?: string; + + constructor(private readonly cfnService: CfnService) {} + + async fetchEvents( + stackName: string, + nextToken?: string, + ): Promise<{ operations: StackOperationGroup[]; nextToken?: string }> { + if (this.stackName !== stackName) { + this.clear(); + this.stackName = stackName; + } + + const response = await this.cfnService.describeEvents({ StackName: stackName, NextToken: nextToken }); + const events = response.OperationEvents ?? []; + + if (!nextToken && events.length > 0) { + this.mostRecentEventId = events[0].EventId; + } + + return { operations: this.groupByOperation(events), nextToken: response.NextToken }; + } + + async refresh(stackName: string): Promise<{ operations: StackOperationGroup[]; gapDetected: boolean }> { + if (this.stackName !== stackName) { + const result = await this.fetchEvents(stackName); + return { operations: result.operations, gapDetected: false }; + } + + const newEvents: OperationEvent[] = []; + let token: string | undefined = undefined; + let pagesChecked = 0; + + while (pagesChecked < StackOperationEventManager.MAX_REFRESH_PAGES) { + const response = await this.cfnService.describeEvents({ StackName: stackName, NextToken: token }); + const events = response.OperationEvents ?? []; + + for (const event of events) { + if (event.EventId === this.mostRecentEventId) { + if (newEvents.length > 0) { + this.mostRecentEventId = newEvents[0].EventId; + } + return { operations: this.groupByOperation(newEvents), gapDetected: false }; + } + newEvents.push(event); + } + + token = response.NextToken; + pagesChecked++; + if (!token) break; + } + + if (newEvents.length > 0) { + this.mostRecentEventId = newEvents[0].EventId; + } + + return { + operations: this.groupByOperation(newEvents), + gapDetected: pagesChecked === StackOperationEventManager.MAX_REFRESH_PAGES && !!token, + }; + } + + clear(): void { + this.mostRecentEventId = undefined; + this.stackName = undefined; + } + + private groupByOperation(events: OperationEvent[]): StackOperationGroup[] { + const groups = new Map(); + + for (const event of events) { + const opId = event.OperationId ?? 'unknown'; + const existing = groups.get(opId); + if (existing) { + existing.push(event); + } else { + groups.set(opId, [event]); + } + } + + return [...groups.entries()].map(([operationId, opEvents]) => ({ + operationId, + events: opEvents.sort((a, b) => (b.Timestamp?.getTime() ?? 0) - (a.Timestamp?.getTime() ?? 0)), + })); + } +} diff --git a/src/stacks/StackRequestType.ts b/src/stacks/StackRequestType.ts index dd6d5000..b2206e3a 100644 --- a/src/stacks/StackRequestType.ts +++ b/src/stacks/StackRequestType.ts @@ -1,4 +1,11 @@ -import { StackSummary, StackStatus, StackResourceSummary, StackEvent, Stack } from '@aws-sdk/client-cloudformation'; +import { + StackSummary, + StackStatus, + StackResourceSummary, + StackEvent, + Stack, + OperationEvent, +} from '@aws-sdk/client-cloudformation'; import { RequestType } from 'vscode-languageserver-protocol'; import { ChangeSetReference, DeploymentMode, StackChange } from './actions/StackActionRequestType'; @@ -111,3 +118,27 @@ export const DescribeStackRequest = new RequestType( 'aws/cfn/stack/changeSet/describe', ); + +export type DescribeEventsParams = { + stackName?: string; + changeSetName?: string; + operationId?: string; + failedEventsOnly?: boolean; + nextToken?: string; + refresh?: boolean; +}; + +export type StackOperationGroup = { + operationId: string; + events: OperationEvent[]; +}; + +export type DescribeEventsResult = { + operations: StackOperationGroup[]; + nextToken?: string; + gapDetected?: boolean; +}; + +export const DescribeEventsRequest = new RequestType( + 'aws/cfn/stack/events/describe', +); diff --git a/src/stacks/actions/StackActionParser.ts b/src/stacks/actions/StackActionParser.ts index dfe5f839..94948a23 100644 --- a/src/stacks/actions/StackActionParser.ts +++ b/src/stacks/actions/StackActionParser.ts @@ -7,6 +7,7 @@ import { ClearStackEventsParams, DescribeStackParams, DescribeChangeSetParams, + DescribeEventsParams, } from '../StackRequestType'; import { CreateDeploymentParams, @@ -100,6 +101,25 @@ const DescribeStackParamsSchema = z.object({ stackName: CfnNameZodString, }); +const DescribeEventsParamsSchema = z + .object({ + stackName: CfnNameZodString.optional(), + changeSetName: CfnNameZodString.optional(), + operationId: z.string().optional(), + failedEventsOnly: z.boolean().optional(), + nextToken: z.string().optional(), + refresh: z.boolean().optional(), + }) + .refine((data) => data.stackName ?? data.changeSetName ?? data.operationId, { + message: 'At least one of stackName, changeSetName, or operationId must be provided', + }) + .refine((data) => !data.refresh || data.stackName, { + message: 'stackName is required when refresh is true', + }) + .refine((data) => !data.nextToken || data.stackName, { + message: 'stackName is required when nextToken is provided', + }); + export function parseCreateValidationParams(input: unknown): CreateValidationParams { return CreateValidationParamsSchema.parse(input); } @@ -135,3 +155,7 @@ export function parseDescribeStackParams(input: unknown): DescribeStackParams { export function parseDescribeChangeSetParams(input: unknown): DescribeChangeSetParams { return DescribeChangeSetParamsSchema.parse(input); } + +export function parseDescribeEventsParams(input: unknown): DescribeEventsParams { + return DescribeEventsParamsSchema.parse(input); +} diff --git a/tst/unit/handlers/StackHandler.test.ts b/tst/unit/handlers/StackHandler.test.ts index 5b53285c..cda312b0 100644 --- a/tst/unit/handlers/StackHandler.test.ts +++ b/tst/unit/handlers/StackHandler.test.ts @@ -32,6 +32,7 @@ import { getChangeSetDeletionStatusHandler, describeStackHandler, describeChangeSetHandler, + describeEventsHandler, } from '../../../src/handlers/StackHandler'; import { analyzeCapabilities } from '../../../src/stacks/actions/CapabilityAnalyzer'; import { mapChangesToStackChanges } from '../../../src/stacks/actions/StackActionOperations'; @@ -51,6 +52,8 @@ import { DescribeStackResult, DescribeChangeSetParams, DescribeChangeSetResult, + DescribeEventsParams, + DescribeEventsResult, } from '../../../src/stacks/StackRequestType'; import { createMockComponents, @@ -82,6 +85,7 @@ vi.mock('../../../src/stacks/actions/StackActionParser', () => ({ parseListStackResourcesParams: vi.fn((input) => input), parseDescribeStackParams: vi.fn((input) => input), parseDescribeChangeSetParams: vi.fn((input) => input), + parseDescribeEventsParams: vi.fn((input) => input), })); vi.mock('../../../src/utils/ZodErrorWrapper', () => ({ @@ -922,4 +926,89 @@ describe('StackActionHandler', () => { await expect(handler(params, {} as any)).rejects.toThrow('ChangeSet not found'); }); }); + + describe('describeEventsHandler', () => { + it('should fetch events for stack', async () => { + mockComponents.stackOperationEventManager.fetchEvents.resolves({ + operations: [{ operationId: 'op1', events: [] }], + nextToken: undefined, + }); + + const handler = describeEventsHandler(mockComponents); + const result = (await handler({ stackName: 'test-stack' }, CancellationToken.None)) as DescribeEventsResult; + + expect(result.operations).toHaveLength(1); + expect(result.nextToken).toBeUndefined(); + expect(mockComponents.stackOperationEventManager.fetchEvents.calledWith('test-stack', undefined)).toBe( + true, + ); + }); + + it('should refresh events when refresh flag is true', async () => { + mockComponents.stackOperationEventManager.refresh.resolves({ + operations: [{ operationId: 'op1', events: [] }], + gapDetected: false, + }); + + const handler = describeEventsHandler(mockComponents); + const result = (await handler( + { stackName: 'test-stack', refresh: true }, + CancellationToken.None, + )) as DescribeEventsResult; + + expect(result.operations).toHaveLength(1); + expect(result.gapDetected).toBe(false); + expect(mockComponents.stackOperationEventManager.refresh.calledWith('test-stack')).toBe(true); + }); + + it('should pass nextToken for pagination', async () => { + mockComponents.stackOperationEventManager.fetchEvents.resolves({ + operations: [], + nextToken: 'token123', + }); + + const handler = describeEventsHandler(mockComponents); + const result = (await handler( + { stackName: 'test-stack', nextToken: 'token123' }, + CancellationToken.None, + )) as DescribeEventsResult; + + expect(result.nextToken).toBe('token123'); + expect(mockComponents.stackOperationEventManager.fetchEvents.calledWith('test-stack', 'token123')).toBe( + true, + ); + }); + + it('should return gapDetected from refresh', async () => { + mockComponents.stackOperationEventManager.refresh.resolves({ + operations: [], + gapDetected: true, + }); + + const handler = describeEventsHandler(mockComponents); + const result = (await handler( + { stackName: 'test-stack', refresh: true }, + CancellationToken.None, + )) as DescribeEventsResult; + + expect(result.gapDetected).toBe(true); + }); + + it('should throw error when stackName is missing with refresh', async () => { + const handler = describeEventsHandler(mockComponents); + + await expect( + handler({ operationId: 'op-123', refresh: true } as DescribeEventsParams, CancellationToken.None), + ).rejects.toThrow(); + }); + + it('should handle service errors', async () => { + const serviceError = new Error('Service error'); + mockComponents.stackOperationEventManager.fetchEvents.rejects(serviceError); + + const handler = describeEventsHandler(mockComponents); + + await expect(handler({ stackName: 'test-stack' }, CancellationToken.None)).rejects.toThrow(); + }); + }); }); diff --git a/tst/unit/stackActions/StackActionParser.test.ts b/tst/unit/stackActions/StackActionParser.test.ts index 5ec3481c..023744f6 100644 --- a/tst/unit/stackActions/StackActionParser.test.ts +++ b/tst/unit/stackActions/StackActionParser.test.ts @@ -5,6 +5,7 @@ import { parseCreateValidationParams, parseTemplateUriParams, parseDescribeChangeSetParams, + parseDescribeEventsParams, } from '../../../src/stacks/actions/StackActionParser'; describe('StackActionParser', () => { @@ -259,4 +260,79 @@ describe('StackActionParser', () => { expect(() => parseDescribeChangeSetParams(undefined)).toThrow(ZodError); }); }); + + describe('parseDescribeEventsParams', () => { + it('should parse valid params with stackName', () => { + const result = parseDescribeEventsParams({ stackName: 'test-stack' }); + expect(result.stackName).toBe('test-stack'); + }); + + it('should parse valid params with changeSetName', () => { + const result = parseDescribeEventsParams({ changeSetName: 'test-changeset' }); + expect(result.changeSetName).toBe('test-changeset'); + }); + + it('should parse valid params with operationId', () => { + const result = parseDescribeEventsParams({ operationId: 'op-123' }); + expect(result.operationId).toBe('op-123'); + }); + + it('should parse all optional parameters', () => { + const result = parseDescribeEventsParams({ + stackName: 'test-stack', + changeSetName: 'test-changeset', + operationId: 'op-123', + failedEventsOnly: true, + nextToken: 'token123', + refresh: true, + }); + + expect(result.stackName).toBe('test-stack'); + expect(result.changeSetName).toBe('test-changeset'); + expect(result.operationId).toBe('op-123'); + expect(result.failedEventsOnly).toBe(true); + expect(result.nextToken).toBe('token123'); + expect(result.refresh).toBe(true); + }); + + it('should throw error when no identifier provided', () => { + expect(() => parseDescribeEventsParams({})).toThrow(); + }); + + it('should throw error when only optional params provided', () => { + expect(() => parseDescribeEventsParams({ failedEventsOnly: true })).toThrow(); + }); + + it('should throw error for invalid stackName', () => { + expect(() => parseDescribeEventsParams({ stackName: '' })).toThrow(); + expect(() => parseDescribeEventsParams({ stackName: 123 as any })).toThrow(); + }); + + it('should throw error for invalid types', () => { + expect(() => parseDescribeEventsParams({ stackName: 'test', failedEventsOnly: 'true' as any })).toThrow(); + expect(() => parseDescribeEventsParams({ stackName: 'test', refresh: 'yes' as any })).toThrow(); + }); + + it('should accept undefined optional parameters', () => { + const result = parseDescribeEventsParams({ + stackName: 'test-stack', + failedEventsOnly: undefined, + nextToken: undefined, + refresh: undefined, + }); + + expect(result.stackName).toBe('test-stack'); + expect(result.failedEventsOnly).toBeUndefined(); + expect(result.nextToken).toBeUndefined(); + expect(result.refresh).toBeUndefined(); + }); + + it('should throw error when refresh is true without stackName', () => { + expect(() => parseDescribeEventsParams({ operationId: 'op-123', refresh: true })).toThrow(); + }); + + it('should throw error when nextToken provided without stackName', () => { + expect(() => parseDescribeEventsParams({ operationId: 'op-123', nextToken: 'token' })).toThrow(); + }); + }); }); diff --git a/tst/unit/stacks/StackOperationEventManager.test.ts b/tst/unit/stacks/StackOperationEventManager.test.ts new file mode 100644 index 00000000..7e57fd93 --- /dev/null +++ b/tst/unit/stacks/StackOperationEventManager.test.ts @@ -0,0 +1,212 @@ +import { OperationEvent } from '@aws-sdk/client-cloudformation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CfnService } from '../../../src/services/CfnService'; +import { StackOperationEventManager } from '../../../src/stacks/StackOperationEventManager'; + +describe('StackOperationEventManager', () => { + let cfnService: CfnService; + let manager: StackOperationEventManager; + + beforeEach(() => { + cfnService = { + describeEvents: vi.fn(), + } as unknown as CfnService; + manager = new StackOperationEventManager(cfnService); + }); + + describe('fetchEvents', () => { + it('should fetch and group events by operation ID', async () => { + const events: OperationEvent[] = [ + { EventId: '1', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, + { EventId: '2', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, + { EventId: '3', OperationId: 'op2', Timestamp: new Date('2024-01-03') }, + ]; + vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); + + const result = await manager.fetchEvents('test-stack'); + + expect(result.operations).toHaveLength(2); + expect(result.operations[0].operationId).toBe('op1'); + expect(result.operations[0].events).toHaveLength(2); + expect(result.operations[1].operationId).toBe('op2'); + expect(result.operations[1].events).toHaveLength(1); + }); + + it('should sort events within operation by timestamp descending', async () => { + const events: OperationEvent[] = [ + { EventId: '1', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, + { EventId: '2', OperationId: 'op1', Timestamp: new Date('2024-01-03') }, + { EventId: '3', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, + ]; + vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); + + const result = await manager.fetchEvents('test-stack'); + + expect(result.operations[0].events[0].EventId).toBe('2'); + expect(result.operations[0].events[1].EventId).toBe('3'); + expect(result.operations[0].events[2].EventId).toBe('1'); + }); + + it('should track most recent event ID on first fetch', async () => { + const events: OperationEvent[] = [ + { EventId: 'newest', OperationId: 'op1', Timestamp: new Date('2024-01-03') }, + { EventId: 'older', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, + ]; + vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); + + await manager.fetchEvents('test-stack'); + vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: [], $metadata: {} }); + const refreshResult = await manager.refresh('test-stack'); + + expect(refreshResult.operations).toHaveLength(0); + expect(refreshResult.gapDetected).toBe(false); + }); + + it('should clear state when switching stacks', async () => { + const events1: OperationEvent[] = [{ EventId: '1', OperationId: 'op1' }]; + const events2: OperationEvent[] = [{ EventId: '2', OperationId: 'op2' }]; + vi.mocked(cfnService.describeEvents) + .mockResolvedValueOnce({ OperationEvents: events1, $metadata: {} }) + .mockResolvedValueOnce({ OperationEvents: events2, $metadata: {} }); + + await manager.fetchEvents('stack1'); + const result = await manager.fetchEvents('stack2'); + + expect(result.operations[0].operationId).toBe('op2'); + }); + + it('should pass nextToken for pagination', async () => { + vi.mocked(cfnService.describeEvents).mockResolvedValue({ + OperationEvents: [], + NextToken: 'token123', + $metadata: {}, + }); + + const result = await manager.fetchEvents('test-stack', 'token123'); + + expect(cfnService.describeEvents).toHaveBeenCalledWith({ + StackName: 'test-stack', + NextToken: 'token123', + }); + expect(result.nextToken).toBe('token123'); + }); + + it('should handle events without operation ID', async () => { + const events: OperationEvent[] = [{ EventId: '1', OperationId: undefined }, { EventId: '2' }]; + vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); + + const result = await manager.fetchEvents('test-stack'); + + expect(result.operations).toHaveLength(1); + expect(result.operations[0].operationId).toBe('unknown'); + expect(result.operations[0].events).toHaveLength(2); + }); + }); + + describe('refresh', () => { + it('should return new events since last fetch', async () => { + const initialEvents: OperationEvent[] = [ + { EventId: 'old', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, + ]; + const newEvents: OperationEvent[] = [ + { EventId: 'new', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, + { EventId: 'old', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, + ]; + vi.mocked(cfnService.describeEvents) + .mockResolvedValueOnce({ OperationEvents: initialEvents, $metadata: {} }) + .mockResolvedValueOnce({ OperationEvents: newEvents, $metadata: {} }); + + await manager.fetchEvents('test-stack'); + const result = await manager.refresh('test-stack'); + + expect(result.operations).toHaveLength(1); + expect(result.operations[0].events).toHaveLength(1); + expect(result.operations[0].events[0].EventId).toBe('new'); + expect(result.gapDetected).toBe(false); + }); + + it('should detect gap when max pages reached', async () => { + const initialEvents: OperationEvent[] = [{ EventId: 'old', OperationId: 'op1' }]; + vi.mocked(cfnService.describeEvents).mockResolvedValueOnce({ + OperationEvents: initialEvents, + $metadata: {}, + }); + + for (let i = 0; i < 5; i++) { + vi.mocked(cfnService.describeEvents).mockResolvedValueOnce({ + OperationEvents: [{ EventId: `new${i}`, OperationId: 'op1' }], + NextToken: 'token', + $metadata: {}, + }); + } + + await manager.fetchEvents('test-stack'); + const result = await manager.refresh('test-stack'); + + expect(result.gapDetected).toBe(true); + }); + + it('should perform full fetch for new stack', async () => { + const events: OperationEvent[] = [{ EventId: '1', OperationId: 'op1' }]; + vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); + + const result = await manager.refresh('new-stack'); + + expect(result.operations).toHaveLength(1); + expect(result.gapDetected).toBe(false); + }); + + it('should stop at most recent event ID', async () => { + const initialEvents: OperationEvent[] = [{ EventId: 'marker', OperationId: 'op1' }]; + const newEvents: OperationEvent[] = [ + { EventId: 'new1', OperationId: 'op1' }, + { EventId: 'new2', OperationId: 'op1' }, + { EventId: 'marker', OperationId: 'op1' }, + { EventId: 'old', OperationId: 'op1' }, + ]; + vi.mocked(cfnService.describeEvents) + .mockResolvedValueOnce({ OperationEvents: initialEvents, $metadata: {} }) + .mockResolvedValueOnce({ OperationEvents: newEvents, $metadata: {} }); + + await manager.fetchEvents('test-stack'); + const result = await manager.refresh('test-stack'); + + expect(result.operations[0].events).toHaveLength(2); + expect(result.operations[0].events[0].EventId).toBe('new1'); + expect(result.operations[0].events[1].EventId).toBe('new2'); + }); + + it('should update most recent event ID after refresh', async () => { + const initialEvents: OperationEvent[] = [{ EventId: 'old', OperationId: 'op1' }]; + const newEvents: OperationEvent[] = [ + { EventId: 'newest', OperationId: 'op1' }, + { EventId: 'old', OperationId: 'op1' }, + ]; + vi.mocked(cfnService.describeEvents) + .mockResolvedValueOnce({ OperationEvents: initialEvents, $metadata: {} }) + .mockResolvedValueOnce({ OperationEvents: newEvents, $metadata: {} }) + .mockResolvedValueOnce({ OperationEvents: [], $metadata: {} }); + + await manager.fetchEvents('test-stack'); + await manager.refresh('test-stack'); + const result = await manager.refresh('test-stack'); + + expect(result.operations).toHaveLength(0); + }); + }); + + describe('clear', () => { + it('should reset state', async () => { + const events: OperationEvent[] = [{ EventId: '1', OperationId: 'op1' }]; + vi.mocked(cfnService.describeEvents) + .mockResolvedValueOnce({ OperationEvents: events, $metadata: {} }) + .mockResolvedValueOnce({ OperationEvents: [], $metadata: {} }); + + await manager.fetchEvents('test-stack'); + manager.clear(); + const result = await manager.refresh('test-stack'); + + expect(result.operations).toHaveLength(0); + }); + }); +}); diff --git a/tst/utils/MockServerComponents.ts b/tst/utils/MockServerComponents.ts index dac2ccf8..ba7bbcb9 100644 --- a/tst/utils/MockServerComponents.ts +++ b/tst/utils/MockServerComponents.ts @@ -65,6 +65,7 @@ import { ValidationManager } from '../../src/stacks/actions/ValidationManager'; import { ValidationWorkflow } from '../../src/stacks/actions/ValidationWorkflow'; import { StackEventManager } from '../../src/stacks/StackEventManager'; import { StackManager } from '../../src/stacks/StackManager'; +import { StackOperationEventManager } from '../../src/stacks/StackOperationEventManager'; import { ClientMessage } from '../../src/telemetry/ClientMessage'; import { UsageTracker } from '../../src/usageTracker/UsageTracker'; import { UsageTrackerMetrics } from '../../src/usageTracker/UsageTrackerMetrics'; @@ -399,6 +400,7 @@ export function createMockComponents(o: Partial = {} overrides.stackManagementInfoProvider ?? stubInterface(), stackManager: overrides.stackManager ?? stubInterface(), stackEventManager: overrides.stackEventManager ?? stubInterface(), + stackOperationEventManager: overrides.stackOperationEventManager ?? stubInterface(), validationWorkflowService: overrides.validationWorkflowService ?? createMockValidationWorkflowService(), deploymentWorkflowService: overrides.deploymentWorkflowService ?? createMockDeploymentWorkflowService(), changeSetDeletionWorkflowService: From f4c30a781163561db824a695d16c7f5ccb14aa56 Mon Sep 17 00:00:00 2001 From: satyaki <208557303+satyakigh@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:46:09 -0500 Subject: [PATCH 2/8] Temporary revert (#361) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0074a692..de96a65b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ ubuntu-latest, windows-latest, macos-latest ] + os: [ ubuntu-latest, macos-latest ] with: ref: ${{ needs.version-and-tag.outputs.tag }} runs-on: ${{ matrix.os }} From 413c4f76dc0617fa5a4c3ea82454aefc0a1caa33 Mon Sep 17 00:00:00 2001 From: Satyaki Ghosh Date: Thu, 11 Dec 2025 11:46:34 -0500 Subject: [PATCH 3/8] Revert "Temporary revert (#361)" This reverts commit f18aa09002e113cab04d7519f6b2c41c2c9f8081. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de96a65b..0074a692 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ ubuntu-latest, macos-latest ] + os: [ ubuntu-latest, windows-latest, macos-latest ] with: ref: ${{ needs.version-and-tag.outputs.tag }} runs-on: ${{ matrix.os }} From abea692bc6b701128b50406cc3e72b6b51a7b246 Mon Sep 17 00:00:00 2001 From: Satyaki Ghosh Date: Thu, 11 Dec 2025 11:47:24 -0500 Subject: [PATCH 4/8] Reapply "Temporary revert (#361)" This reverts commit 1ff1195f9ef3d8da86b8fbe8cf281d025b5ebaba. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0074a692..de96a65b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ ubuntu-latest, windows-latest, macos-latest ] + os: [ ubuntu-latest, macos-latest ] with: ref: ${{ needs.version-and-tag.outputs.tag }} runs-on: ${{ matrix.os }} From b41df780b0b22dca2e6d7b7535936ddaadf02235 Mon Sep 17 00:00:00 2001 From: Satyaki Ghosh Date: Thu, 11 Dec 2025 14:31:33 -0500 Subject: [PATCH 5/8] Revert "Reapply "Temporary revert (#361)"" This reverts commit a1498018c3f7db1d48c90d1998c17ab44a280c9e. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de96a65b..0074a692 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ ubuntu-latest, macos-latest ] + os: [ ubuntu-latest, windows-latest, macos-latest ] with: ref: ${{ needs.version-and-tag.outputs.tag }} runs-on: ${{ matrix.os }} From 1cb76d43b26476312ff01b4d0ddd9e2e59541231 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed Date: Thu, 11 Dec 2025 17:45:45 -0500 Subject: [PATCH 6/8] require stack name and exit early if falsy --- src/handlers/StackHandler.ts | 8 ++++++-- src/stacks/actions/StackActionParser.ts | 6 ------ tst/unit/stackActions/StackActionParser.test.ts | 8 -------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/handlers/StackHandler.ts b/src/handlers/StackHandler.ts index c779c9eb..f7d842d1 100644 --- a/src/handlers/StackHandler.ts +++ b/src/handlers/StackHandler.ts @@ -455,12 +455,16 @@ export function describeEventsHandler( try { const params = parseWithPrettyError(parseDescribeEventsParams, rawParams); + if (!params.stackName) { + throw new Error('stackName is required for describeEventsHandler'); + } + if (params.refresh) { - const result = await components.stackOperationEventManager.refresh(params.stackName ?? ''); + const result = await components.stackOperationEventManager.refresh(params.stackName); return { operations: result.operations, nextToken: undefined, gapDetected: result.gapDetected }; } - return await components.stackOperationEventManager.fetchEvents(params.stackName ?? '', params.nextToken); + return await components.stackOperationEventManager.fetchEvents(params.stackName, params.nextToken); } catch (error) { handleLspError(error, 'Failed to describe events'); } diff --git a/src/stacks/actions/StackActionParser.ts b/src/stacks/actions/StackActionParser.ts index 94948a23..fa448361 100644 --- a/src/stacks/actions/StackActionParser.ts +++ b/src/stacks/actions/StackActionParser.ts @@ -112,12 +112,6 @@ const DescribeEventsParamsSchema = z }) .refine((data) => data.stackName ?? data.changeSetName ?? data.operationId, { message: 'At least one of stackName, changeSetName, or operationId must be provided', - }) - .refine((data) => !data.refresh || data.stackName, { - message: 'stackName is required when refresh is true', - }) - .refine((data) => !data.nextToken || data.stackName, { - message: 'stackName is required when nextToken is provided', }); export function parseCreateValidationParams(input: unknown): CreateValidationParams { diff --git a/tst/unit/stackActions/StackActionParser.test.ts b/tst/unit/stackActions/StackActionParser.test.ts index 023744f6..5db623f7 100644 --- a/tst/unit/stackActions/StackActionParser.test.ts +++ b/tst/unit/stackActions/StackActionParser.test.ts @@ -326,13 +326,5 @@ describe('StackActionParser', () => { expect(result.nextToken).toBeUndefined(); expect(result.refresh).toBeUndefined(); }); - - it('should throw error when refresh is true without stackName', () => { - expect(() => parseDescribeEventsParams({ operationId: 'op-123', refresh: true })).toThrow(); - }); - - it('should throw error when nextToken provided without stackName', () => { - expect(() => parseDescribeEventsParams({ operationId: 'op-123', nextToken: 'token' })).toThrow(); - }); }); }); From 0c7f7e3c418a882366f6f4777e364a47e9caede1 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed Date: Fri, 12 Dec 2025 11:43:24 -0500 Subject: [PATCH 7/8] make DescribeEvents passthrough API and remove caching --- src/handlers/StackHandler.ts | 31 ++- src/server/CfnLspProviders.ts | 4 - src/stacks/StackOperationEventManager.ts | 94 -------- src/stacks/StackRequestType.ts | 2 - src/stacks/actions/StackActionParser.ts | 1 - tst/unit/handlers/StackHandler.test.ts | 93 +++----- .../stackActions/StackActionParser.test.ts | 5 - .../stacks/StackOperationEventManager.test.ts | 212 ------------------ tst/utils/MockServerComponents.ts | 2 - 9 files changed, 55 insertions(+), 389 deletions(-) delete mode 100644 src/stacks/StackOperationEventManager.ts delete mode 100644 tst/unit/stacks/StackOperationEventManager.test.ts diff --git a/src/handlers/StackHandler.ts b/src/handlers/StackHandler.ts index f7d842d1..9cc376f9 100644 --- a/src/handlers/StackHandler.ts +++ b/src/handlers/StackHandler.ts @@ -1,3 +1,4 @@ +import { OperationEvent } from '@aws-sdk/client-cloudformation'; import { ErrorCodes, RequestHandler, ResponseError } from 'vscode-languageserver'; import { ArtifactExporter } from '../artifactexporter/ArtifactExporter'; import { TopLevelSection } from '../context/ContextType'; @@ -455,16 +456,32 @@ export function describeEventsHandler( try { const params = parseWithPrettyError(parseDescribeEventsParams, rawParams); - if (!params.stackName) { - throw new Error('stackName is required for describeEventsHandler'); - } + const response = await components.cfnService.describeEvents({ + StackName: params.stackName, + ChangeSetName: params.changeSetName, + OperationId: params.operationId, + FailedEventsOnly: params.failedEventsOnly, + NextToken: params.nextToken, + }); - if (params.refresh) { - const result = await components.stackOperationEventManager.refresh(params.stackName); - return { operations: result.operations, nextToken: undefined, gapDetected: result.gapDetected }; + const operations = new Map(); + for (const event of response.OperationEvents ?? []) { + const opId = event.OperationId ?? 'unknown'; + const existing = operations.get(opId); + if (existing) { + existing.push(event); + } else { + operations.set(opId, [event]); + } } - return await components.stackOperationEventManager.fetchEvents(params.stackName, params.nextToken); + return { + operations: [...operations.entries()].map(([operationId, events]) => ({ + operationId, + events: events.sort((a, b) => (b.Timestamp?.getTime() ?? 0) - (a.Timestamp?.getTime() ?? 0)), + })), + nextToken: response.NextToken, + }; } catch (error) { handleLspError(error, 'Failed to describe events'); } diff --git a/src/server/CfnLspProviders.ts b/src/server/CfnLspProviders.ts index 76aba263..e0787f6e 100644 --- a/src/server/CfnLspProviders.ts +++ b/src/server/CfnLspProviders.ts @@ -25,7 +25,6 @@ import { StackActionWorkflow } from '../stacks/actions/StackActionWorkflowType'; import { ValidationWorkflow } from '../stacks/actions/ValidationWorkflow'; import { StackEventManager } from '../stacks/StackEventManager'; import { StackManager } from '../stacks/StackManager'; -import { StackOperationEventManager } from '../stacks/StackOperationEventManager'; import { Closeable, closeSafely } from '../utils/Closeable'; import { Configurable, Configurables } from '../utils/Configurable'; import { CfnExternal } from './CfnExternal'; @@ -39,7 +38,6 @@ export class CfnLspProviders implements Configurables, Closeable { readonly changeSetDeletionWorkflowService: StackActionWorkflow; readonly stackManager: StackManager; readonly stackEventManager: StackEventManager; - readonly stackOperationEventManager: StackOperationEventManager; readonly resourceStateManager: ResourceStateManager; readonly resourceStateImporter: ResourceStateImporter; readonly relationshipSchemaService: RelationshipSchemaService; @@ -62,8 +60,6 @@ export class CfnLspProviders implements Configurables, Closeable { overrides.stackManagementInfoProvider ?? new StackManagementInfoProvider(external.cfnService); this.stackManager = overrides.stackManager ?? new StackManager(external.cfnService); this.stackEventManager = overrides.stackEventManager ?? new StackEventManager(external.cfnService); - this.stackOperationEventManager = - overrides.stackOperationEventManager ?? new StackOperationEventManager(external.cfnService); this.validationWorkflowService = overrides.validationWorkflowService ?? ValidationWorkflow.create(core, external, core.validationManager); this.deploymentWorkflowService = diff --git a/src/stacks/StackOperationEventManager.ts b/src/stacks/StackOperationEventManager.ts deleted file mode 100644 index 7ddfffbe..00000000 --- a/src/stacks/StackOperationEventManager.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { OperationEvent } from '@aws-sdk/client-cloudformation'; -import { CfnService } from '../services/CfnService'; -import { StackOperationGroup } from './StackRequestType'; - -export class StackOperationEventManager { - private static readonly MAX_REFRESH_PAGES = 5; - - private mostRecentEventId?: string; - private stackName?: string; - - constructor(private readonly cfnService: CfnService) {} - - async fetchEvents( - stackName: string, - nextToken?: string, - ): Promise<{ operations: StackOperationGroup[]; nextToken?: string }> { - if (this.stackName !== stackName) { - this.clear(); - this.stackName = stackName; - } - - const response = await this.cfnService.describeEvents({ StackName: stackName, NextToken: nextToken }); - const events = response.OperationEvents ?? []; - - if (!nextToken && events.length > 0) { - this.mostRecentEventId = events[0].EventId; - } - - return { operations: this.groupByOperation(events), nextToken: response.NextToken }; - } - - async refresh(stackName: string): Promise<{ operations: StackOperationGroup[]; gapDetected: boolean }> { - if (this.stackName !== stackName) { - const result = await this.fetchEvents(stackName); - return { operations: result.operations, gapDetected: false }; - } - - const newEvents: OperationEvent[] = []; - let token: string | undefined = undefined; - let pagesChecked = 0; - - while (pagesChecked < StackOperationEventManager.MAX_REFRESH_PAGES) { - const response = await this.cfnService.describeEvents({ StackName: stackName, NextToken: token }); - const events = response.OperationEvents ?? []; - - for (const event of events) { - if (event.EventId === this.mostRecentEventId) { - if (newEvents.length > 0) { - this.mostRecentEventId = newEvents[0].EventId; - } - return { operations: this.groupByOperation(newEvents), gapDetected: false }; - } - newEvents.push(event); - } - - token = response.NextToken; - pagesChecked++; - if (!token) break; - } - - if (newEvents.length > 0) { - this.mostRecentEventId = newEvents[0].EventId; - } - - return { - operations: this.groupByOperation(newEvents), - gapDetected: pagesChecked === StackOperationEventManager.MAX_REFRESH_PAGES && !!token, - }; - } - - clear(): void { - this.mostRecentEventId = undefined; - this.stackName = undefined; - } - - private groupByOperation(events: OperationEvent[]): StackOperationGroup[] { - const groups = new Map(); - - for (const event of events) { - const opId = event.OperationId ?? 'unknown'; - const existing = groups.get(opId); - if (existing) { - existing.push(event); - } else { - groups.set(opId, [event]); - } - } - - return [...groups.entries()].map(([operationId, opEvents]) => ({ - operationId, - events: opEvents.sort((a, b) => (b.Timestamp?.getTime() ?? 0) - (a.Timestamp?.getTime() ?? 0)), - })); - } -} diff --git a/src/stacks/StackRequestType.ts b/src/stacks/StackRequestType.ts index b2206e3a..1eb0ae73 100644 --- a/src/stacks/StackRequestType.ts +++ b/src/stacks/StackRequestType.ts @@ -125,7 +125,6 @@ export type DescribeEventsParams = { operationId?: string; failedEventsOnly?: boolean; nextToken?: string; - refresh?: boolean; }; export type StackOperationGroup = { @@ -136,7 +135,6 @@ export type StackOperationGroup = { export type DescribeEventsResult = { operations: StackOperationGroup[]; nextToken?: string; - gapDetected?: boolean; }; export const DescribeEventsRequest = new RequestType( diff --git a/src/stacks/actions/StackActionParser.ts b/src/stacks/actions/StackActionParser.ts index fa448361..ebdfcad6 100644 --- a/src/stacks/actions/StackActionParser.ts +++ b/src/stacks/actions/StackActionParser.ts @@ -108,7 +108,6 @@ const DescribeEventsParamsSchema = z operationId: z.string().optional(), failedEventsOnly: z.boolean().optional(), nextToken: z.string().optional(), - refresh: z.boolean().optional(), }) .refine((data) => data.stackName ?? data.changeSetName ?? data.operationId, { message: 'At least one of stackName, changeSetName, or operationId must be provided', diff --git a/tst/unit/handlers/StackHandler.test.ts b/tst/unit/handlers/StackHandler.test.ts index cda312b0..f4f58d34 100644 --- a/tst/unit/handlers/StackHandler.test.ts +++ b/tst/unit/handlers/StackHandler.test.ts @@ -52,7 +52,6 @@ import { DescribeStackResult, DescribeChangeSetParams, DescribeChangeSetResult, - DescribeEventsParams, DescribeEventsResult, } from '../../../src/stacks/StackRequestType'; import { @@ -928,83 +927,53 @@ describe('StackActionHandler', () => { }); describe('describeEventsHandler', () => { - it('should fetch events for stack', async () => { - mockComponents.stackOperationEventManager.fetchEvents.resolves({ - operations: [{ operationId: 'op1', events: [] }], - nextToken: undefined, + it('should fetch and group events by operation ID', async () => { + mockComponents.cfnService.describeEvents.resolves({ + OperationEvents: [ + { EventId: '1', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, + { EventId: '2', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, + { EventId: '3', OperationId: 'op2', Timestamp: new Date('2024-01-03') }, + ], + $metadata: {}, }); const handler = describeEventsHandler(mockComponents); const result = (await handler({ stackName: 'test-stack' }, CancellationToken.None)) as DescribeEventsResult; - expect(result.operations).toHaveLength(1); - expect(result.nextToken).toBeUndefined(); - expect(mockComponents.stackOperationEventManager.fetchEvents.calledWith('test-stack', undefined)).toBe( - true, - ); + expect(result.operations).toHaveLength(2); + expect(result.operations[0].operationId).toBe('op1'); + expect(result.operations[0].events).toHaveLength(2); }); - it('should refresh events when refresh flag is true', async () => { - mockComponents.stackOperationEventManager.refresh.resolves({ - operations: [{ operationId: 'op1', events: [] }], - gapDetected: false, - }); + it('should pass all parameters to API', async () => { + mockComponents.cfnService.describeEvents.resolves({ OperationEvents: [], $metadata: {} }); const handler = describeEventsHandler(mockComponents); - const result = (await handler( - { stackName: 'test-stack', refresh: true }, - CancellationToken.None, - )) as DescribeEventsResult; - - expect(result.operations).toHaveLength(1); - expect(result.gapDetected).toBe(false); - expect(mockComponents.stackOperationEventManager.refresh.calledWith('test-stack')).toBe(true); - }); - - it('should pass nextToken for pagination', async () => { - mockComponents.stackOperationEventManager.fetchEvents.resolves({ - operations: [], - nextToken: 'token123', - }); - - const handler = describeEventsHandler(mockComponents); - const result = (await handler( - { stackName: 'test-stack', nextToken: 'token123' }, + await handler( + { + stackName: 'test-stack', + changeSetName: 'cs', + operationId: 'op', + failedEventsOnly: true, + nextToken: 'token', + }, CancellationToken.None, - )) as DescribeEventsResult; - - expect(result.nextToken).toBe('token123'); - expect(mockComponents.stackOperationEventManager.fetchEvents.calledWith('test-stack', 'token123')).toBe( - true, ); - }); - - it('should return gapDetected from refresh', async () => { - mockComponents.stackOperationEventManager.refresh.resolves({ - operations: [], - gapDetected: true, - }); - - const handler = describeEventsHandler(mockComponents); - const result = (await handler( - { stackName: 'test-stack', refresh: true }, - CancellationToken.None, - )) as DescribeEventsResult; - - expect(result.gapDetected).toBe(true); - }); - - it('should throw error when stackName is missing with refresh', async () => { - const handler = describeEventsHandler(mockComponents); - await expect( - handler({ operationId: 'op-123', refresh: true } as DescribeEventsParams, CancellationToken.None), - ).rejects.toThrow(); + expect( + mockComponents.cfnService.describeEvents.calledWith({ + StackName: 'test-stack', + ChangeSetName: 'cs', + OperationId: 'op', + FailedEventsOnly: true, + NextToken: 'token', + }), + ).toBe(true); }); it('should handle service errors', async () => { const serviceError = new Error('Service error'); - mockComponents.stackOperationEventManager.fetchEvents.rejects(serviceError); + mockComponents.cfnService.describeEvents.rejects(serviceError); const handler = describeEventsHandler(mockComponents); diff --git a/tst/unit/stackActions/StackActionParser.test.ts b/tst/unit/stackActions/StackActionParser.test.ts index 5db623f7..a420e487 100644 --- a/tst/unit/stackActions/StackActionParser.test.ts +++ b/tst/unit/stackActions/StackActionParser.test.ts @@ -284,7 +284,6 @@ describe('StackActionParser', () => { operationId: 'op-123', failedEventsOnly: true, nextToken: 'token123', - refresh: true, }); expect(result.stackName).toBe('test-stack'); @@ -292,7 +291,6 @@ describe('StackActionParser', () => { expect(result.operationId).toBe('op-123'); expect(result.failedEventsOnly).toBe(true); expect(result.nextToken).toBe('token123'); - expect(result.refresh).toBe(true); }); it('should throw error when no identifier provided', () => { @@ -310,7 +308,6 @@ describe('StackActionParser', () => { it('should throw error for invalid types', () => { expect(() => parseDescribeEventsParams({ stackName: 'test', failedEventsOnly: 'true' as any })).toThrow(); - expect(() => parseDescribeEventsParams({ stackName: 'test', refresh: 'yes' as any })).toThrow(); }); it('should accept undefined optional parameters', () => { @@ -318,13 +315,11 @@ describe('StackActionParser', () => { stackName: 'test-stack', failedEventsOnly: undefined, nextToken: undefined, - refresh: undefined, }); expect(result.stackName).toBe('test-stack'); expect(result.failedEventsOnly).toBeUndefined(); expect(result.nextToken).toBeUndefined(); - expect(result.refresh).toBeUndefined(); }); }); }); diff --git a/tst/unit/stacks/StackOperationEventManager.test.ts b/tst/unit/stacks/StackOperationEventManager.test.ts deleted file mode 100644 index 7e57fd93..00000000 --- a/tst/unit/stacks/StackOperationEventManager.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { OperationEvent } from '@aws-sdk/client-cloudformation'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CfnService } from '../../../src/services/CfnService'; -import { StackOperationEventManager } from '../../../src/stacks/StackOperationEventManager'; - -describe('StackOperationEventManager', () => { - let cfnService: CfnService; - let manager: StackOperationEventManager; - - beforeEach(() => { - cfnService = { - describeEvents: vi.fn(), - } as unknown as CfnService; - manager = new StackOperationEventManager(cfnService); - }); - - describe('fetchEvents', () => { - it('should fetch and group events by operation ID', async () => { - const events: OperationEvent[] = [ - { EventId: '1', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, - { EventId: '2', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, - { EventId: '3', OperationId: 'op2', Timestamp: new Date('2024-01-03') }, - ]; - vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); - - const result = await manager.fetchEvents('test-stack'); - - expect(result.operations).toHaveLength(2); - expect(result.operations[0].operationId).toBe('op1'); - expect(result.operations[0].events).toHaveLength(2); - expect(result.operations[1].operationId).toBe('op2'); - expect(result.operations[1].events).toHaveLength(1); - }); - - it('should sort events within operation by timestamp descending', async () => { - const events: OperationEvent[] = [ - { EventId: '1', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, - { EventId: '2', OperationId: 'op1', Timestamp: new Date('2024-01-03') }, - { EventId: '3', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, - ]; - vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); - - const result = await manager.fetchEvents('test-stack'); - - expect(result.operations[0].events[0].EventId).toBe('2'); - expect(result.operations[0].events[1].EventId).toBe('3'); - expect(result.operations[0].events[2].EventId).toBe('1'); - }); - - it('should track most recent event ID on first fetch', async () => { - const events: OperationEvent[] = [ - { EventId: 'newest', OperationId: 'op1', Timestamp: new Date('2024-01-03') }, - { EventId: 'older', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, - ]; - vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); - - await manager.fetchEvents('test-stack'); - vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: [], $metadata: {} }); - const refreshResult = await manager.refresh('test-stack'); - - expect(refreshResult.operations).toHaveLength(0); - expect(refreshResult.gapDetected).toBe(false); - }); - - it('should clear state when switching stacks', async () => { - const events1: OperationEvent[] = [{ EventId: '1', OperationId: 'op1' }]; - const events2: OperationEvent[] = [{ EventId: '2', OperationId: 'op2' }]; - vi.mocked(cfnService.describeEvents) - .mockResolvedValueOnce({ OperationEvents: events1, $metadata: {} }) - .mockResolvedValueOnce({ OperationEvents: events2, $metadata: {} }); - - await manager.fetchEvents('stack1'); - const result = await manager.fetchEvents('stack2'); - - expect(result.operations[0].operationId).toBe('op2'); - }); - - it('should pass nextToken for pagination', async () => { - vi.mocked(cfnService.describeEvents).mockResolvedValue({ - OperationEvents: [], - NextToken: 'token123', - $metadata: {}, - }); - - const result = await manager.fetchEvents('test-stack', 'token123'); - - expect(cfnService.describeEvents).toHaveBeenCalledWith({ - StackName: 'test-stack', - NextToken: 'token123', - }); - expect(result.nextToken).toBe('token123'); - }); - - it('should handle events without operation ID', async () => { - const events: OperationEvent[] = [{ EventId: '1', OperationId: undefined }, { EventId: '2' }]; - vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); - - const result = await manager.fetchEvents('test-stack'); - - expect(result.operations).toHaveLength(1); - expect(result.operations[0].operationId).toBe('unknown'); - expect(result.operations[0].events).toHaveLength(2); - }); - }); - - describe('refresh', () => { - it('should return new events since last fetch', async () => { - const initialEvents: OperationEvent[] = [ - { EventId: 'old', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, - ]; - const newEvents: OperationEvent[] = [ - { EventId: 'new', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, - { EventId: 'old', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, - ]; - vi.mocked(cfnService.describeEvents) - .mockResolvedValueOnce({ OperationEvents: initialEvents, $metadata: {} }) - .mockResolvedValueOnce({ OperationEvents: newEvents, $metadata: {} }); - - await manager.fetchEvents('test-stack'); - const result = await manager.refresh('test-stack'); - - expect(result.operations).toHaveLength(1); - expect(result.operations[0].events).toHaveLength(1); - expect(result.operations[0].events[0].EventId).toBe('new'); - expect(result.gapDetected).toBe(false); - }); - - it('should detect gap when max pages reached', async () => { - const initialEvents: OperationEvent[] = [{ EventId: 'old', OperationId: 'op1' }]; - vi.mocked(cfnService.describeEvents).mockResolvedValueOnce({ - OperationEvents: initialEvents, - $metadata: {}, - }); - - for (let i = 0; i < 5; i++) { - vi.mocked(cfnService.describeEvents).mockResolvedValueOnce({ - OperationEvents: [{ EventId: `new${i}`, OperationId: 'op1' }], - NextToken: 'token', - $metadata: {}, - }); - } - - await manager.fetchEvents('test-stack'); - const result = await manager.refresh('test-stack'); - - expect(result.gapDetected).toBe(true); - }); - - it('should perform full fetch for new stack', async () => { - const events: OperationEvent[] = [{ EventId: '1', OperationId: 'op1' }]; - vi.mocked(cfnService.describeEvents).mockResolvedValue({ OperationEvents: events, $metadata: {} }); - - const result = await manager.refresh('new-stack'); - - expect(result.operations).toHaveLength(1); - expect(result.gapDetected).toBe(false); - }); - - it('should stop at most recent event ID', async () => { - const initialEvents: OperationEvent[] = [{ EventId: 'marker', OperationId: 'op1' }]; - const newEvents: OperationEvent[] = [ - { EventId: 'new1', OperationId: 'op1' }, - { EventId: 'new2', OperationId: 'op1' }, - { EventId: 'marker', OperationId: 'op1' }, - { EventId: 'old', OperationId: 'op1' }, - ]; - vi.mocked(cfnService.describeEvents) - .mockResolvedValueOnce({ OperationEvents: initialEvents, $metadata: {} }) - .mockResolvedValueOnce({ OperationEvents: newEvents, $metadata: {} }); - - await manager.fetchEvents('test-stack'); - const result = await manager.refresh('test-stack'); - - expect(result.operations[0].events).toHaveLength(2); - expect(result.operations[0].events[0].EventId).toBe('new1'); - expect(result.operations[0].events[1].EventId).toBe('new2'); - }); - - it('should update most recent event ID after refresh', async () => { - const initialEvents: OperationEvent[] = [{ EventId: 'old', OperationId: 'op1' }]; - const newEvents: OperationEvent[] = [ - { EventId: 'newest', OperationId: 'op1' }, - { EventId: 'old', OperationId: 'op1' }, - ]; - vi.mocked(cfnService.describeEvents) - .mockResolvedValueOnce({ OperationEvents: initialEvents, $metadata: {} }) - .mockResolvedValueOnce({ OperationEvents: newEvents, $metadata: {} }) - .mockResolvedValueOnce({ OperationEvents: [], $metadata: {} }); - - await manager.fetchEvents('test-stack'); - await manager.refresh('test-stack'); - const result = await manager.refresh('test-stack'); - - expect(result.operations).toHaveLength(0); - }); - }); - - describe('clear', () => { - it('should reset state', async () => { - const events: OperationEvent[] = [{ EventId: '1', OperationId: 'op1' }]; - vi.mocked(cfnService.describeEvents) - .mockResolvedValueOnce({ OperationEvents: events, $metadata: {} }) - .mockResolvedValueOnce({ OperationEvents: [], $metadata: {} }); - - await manager.fetchEvents('test-stack'); - manager.clear(); - const result = await manager.refresh('test-stack'); - - expect(result.operations).toHaveLength(0); - }); - }); -}); diff --git a/tst/utils/MockServerComponents.ts b/tst/utils/MockServerComponents.ts index ba7bbcb9..dac2ccf8 100644 --- a/tst/utils/MockServerComponents.ts +++ b/tst/utils/MockServerComponents.ts @@ -65,7 +65,6 @@ import { ValidationManager } from '../../src/stacks/actions/ValidationManager'; import { ValidationWorkflow } from '../../src/stacks/actions/ValidationWorkflow'; import { StackEventManager } from '../../src/stacks/StackEventManager'; import { StackManager } from '../../src/stacks/StackManager'; -import { StackOperationEventManager } from '../../src/stacks/StackOperationEventManager'; import { ClientMessage } from '../../src/telemetry/ClientMessage'; import { UsageTracker } from '../../src/usageTracker/UsageTracker'; import { UsageTrackerMetrics } from '../../src/usageTracker/UsageTrackerMetrics'; @@ -400,7 +399,6 @@ export function createMockComponents(o: Partial = {} overrides.stackManagementInfoProvider ?? stubInterface(), stackManager: overrides.stackManager ?? stubInterface(), stackEventManager: overrides.stackEventManager ?? stubInterface(), - stackOperationEventManager: overrides.stackOperationEventManager ?? stubInterface(), validationWorkflowService: overrides.validationWorkflowService ?? createMockValidationWorkflowService(), deploymentWorkflowService: overrides.deploymentWorkflowService ?? createMockDeploymentWorkflowService(), changeSetDeletionWorkflowService: From 0b49e6b9782bce997116572d1eee3f686fd18235 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed Date: Fri, 12 Dec 2025 11:54:05 -0500 Subject: [PATCH 8/8] remove grouping by operation id --- src/handlers/StackHandler.ts | 17 +---------------- src/stacks/StackRequestType.ts | 7 +------ tst/unit/handlers/StackHandler.test.ts | 7 +++---- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/src/handlers/StackHandler.ts b/src/handlers/StackHandler.ts index 9cc376f9..330342af 100644 --- a/src/handlers/StackHandler.ts +++ b/src/handlers/StackHandler.ts @@ -1,4 +1,3 @@ -import { OperationEvent } from '@aws-sdk/client-cloudformation'; import { ErrorCodes, RequestHandler, ResponseError } from 'vscode-languageserver'; import { ArtifactExporter } from '../artifactexporter/ArtifactExporter'; import { TopLevelSection } from '../context/ContextType'; @@ -464,22 +463,8 @@ export function describeEventsHandler( NextToken: params.nextToken, }); - const operations = new Map(); - for (const event of response.OperationEvents ?? []) { - const opId = event.OperationId ?? 'unknown'; - const existing = operations.get(opId); - if (existing) { - existing.push(event); - } else { - operations.set(opId, [event]); - } - } - return { - operations: [...operations.entries()].map(([operationId, events]) => ({ - operationId, - events: events.sort((a, b) => (b.Timestamp?.getTime() ?? 0) - (a.Timestamp?.getTime() ?? 0)), - })), + events: response.OperationEvents ?? [], nextToken: response.NextToken, }; } catch (error) { diff --git a/src/stacks/StackRequestType.ts b/src/stacks/StackRequestType.ts index 1eb0ae73..e122b776 100644 --- a/src/stacks/StackRequestType.ts +++ b/src/stacks/StackRequestType.ts @@ -127,13 +127,8 @@ export type DescribeEventsParams = { nextToken?: string; }; -export type StackOperationGroup = { - operationId: string; - events: OperationEvent[]; -}; - export type DescribeEventsResult = { - operations: StackOperationGroup[]; + events: OperationEvent[]; nextToken?: string; }; diff --git a/tst/unit/handlers/StackHandler.test.ts b/tst/unit/handlers/StackHandler.test.ts index f4f58d34..5c7d358b 100644 --- a/tst/unit/handlers/StackHandler.test.ts +++ b/tst/unit/handlers/StackHandler.test.ts @@ -927,7 +927,7 @@ describe('StackActionHandler', () => { }); describe('describeEventsHandler', () => { - it('should fetch and group events by operation ID', async () => { + it('should return flat events from API', async () => { mockComponents.cfnService.describeEvents.resolves({ OperationEvents: [ { EventId: '1', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, @@ -940,9 +940,8 @@ describe('StackActionHandler', () => { const handler = describeEventsHandler(mockComponents); const result = (await handler({ stackName: 'test-stack' }, CancellationToken.None)) as DescribeEventsResult; - expect(result.operations).toHaveLength(2); - expect(result.operations[0].operationId).toBe('op1'); - expect(result.operations[0].events).toHaveLength(2); + expect(result.events).toHaveLength(3); + expect(result.events[0].EventId).toBe('1'); }); it('should pass all parameters to API', async () => {