From 8d246bd00d691a570c60fbef8644d16d9970f1ae Mon Sep 17 00:00:00 2001 From: Kaizen Conroy <36202692+kaizen3031593@users.noreply.github.com> Date: Mon, 24 Jan 2022 12:57:41 -0500 Subject: [PATCH] feat(assertions): support assertions on stack messages (#18521) Looking to unblock users who want to use assertions with annotations added on a stack. Fixes #18347 Example: ```ts class MyAspect implements IAspect { public visit(node: IConstruct): void { if (node instanceof CfnResource) { this.warn(node, 'insert message here', } } protected warn(node: IConstruct, message: string): void { Annotations.of(node).addWarning(message); } } const app = new App(); const stack = new Stack(app); new CfnResource(stack, 'Foo', { type: 'Foo::Bar', properties: { Baz: 'Qux', }, }); Aspects.of(stack).add(new MyAspect()); AssertAnnotations.fromStack(stack).hasMessage({ level: 'warning', entry: { data: 'insert message here', }, }); ``` ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/assertions/README.md | 75 ++++++++- .../@aws-cdk/assertions/lib/annotations.ts | 129 +++++++++++++++ packages/@aws-cdk/assertions/lib/index.ts | 3 +- .../assertions/lib/private/message.ts | 5 + .../assertions/lib/private/messages.ts | 41 +++++ packages/@aws-cdk/assertions/lib/template.ts | 3 +- .../assertions/rosetta/default.ts-fixture | 4 +- .../@aws-cdk/assertions/test/annotations.ts | 150 ++++++++++++++++++ 8 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 packages/@aws-cdk/assertions/lib/annotations.ts create mode 100644 packages/@aws-cdk/assertions/lib/private/message.ts create mode 100644 packages/@aws-cdk/assertions/lib/private/messages.ts create mode 100644 packages/@aws-cdk/assertions/test/annotations.ts diff --git a/packages/@aws-cdk/assertions/README.md b/packages/@aws-cdk/assertions/README.md index 3d1275d2b4a3e..5370b1d094c8e 100644 --- a/packages/@aws-cdk/assertions/README.md +++ b/packages/@aws-cdk/assertions/README.md @@ -251,7 +251,7 @@ This matcher can be combined with any of the other matchers. // The following will NOT throw an assertion error template.hasResourceProperties('Foo::Bar', { Fred: { - Wobble: [ Match.anyValue(), "Flip" ], + Wobble: [ Match.anyValue(), Match.anyValue() ], }, }); @@ -400,7 +400,7 @@ template.hasResourceProperties('Foo::Bar', { ## Capturing Values -This matcher APIs documented above allow capturing values in the matching entry +The matcher APIs documented above allow capturing values in the matching entry (Resource, Output, Mapping, etc.). The following code captures a string from a matching resource. @@ -492,3 +492,74 @@ fredCapture.asString(); // returns "Flob" fredCapture.next(); // returns true fredCapture.asString(); // returns "Quib" ``` + +## Asserting Annotations + +In addition to template matching, we provide an API for annotation matching. +[Annotations](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.Annotations.html) +can be added via the [Aspects](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.Aspects.html) +API. You can learn more about Aspects [here](https://docs.aws.amazon.com/cdk/v2/guide/aspects.html). + +Say you have a `MyAspect` and a `MyStack` that uses `MyAspect`: + +```ts nofixture +import * as cdk from '@aws-cdk/core'; +import { Construct, IConstruct } from 'constructs'; + +class MyAspect implements cdk.IAspect { + public visit(node: IConstruct): void { + if (node instanceof cdk.CfnResource && node.cfnResourceType === 'Foo::Bar') { + this.error(node, 'we do not want a Foo::Bar resource'); + } + } + + protected error(node: IConstruct, message: string): void { + cdk.Annotations.of(node).addError(message); + } +} + +class MyStack extends cdk.Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + const stack = new cdk.Stack(); + new cdk.CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { + Fred: 'Thud', + }, + }); + cdk.Aspects.of(stack).add(new MyAspect()); + } +} +``` + +We can then assert that the stack contains the expected Error: + +```ts +// import { Annotations } from '@aws-cdk/assertions'; + +Annotations.fromStack(stack).hasError( + '/Default/Foo', + 'we do not want a Foo::Bar resource', +); +``` + +Here are the available APIs for `Annotations`: + +- `hasError()` and `findError()` +- `hasWarning()` and `findWarning()` +- `hasInfo()` and `findInfo()` + +The corresponding `findXxx()` API is complementary to the `hasXxx()` API, except instead +of asserting its presence, it returns the set of matching messages. + +In addition, this suite of APIs is compatable with `Matchers` for more fine-grained control. +For example, the following assertion works as well: + +```ts +Annotations.fromStack(stack).hasError( + '/Default/Foo', + Match.stringLikeRegexp('.*Foo::Bar.*'), +); +``` diff --git a/packages/@aws-cdk/assertions/lib/annotations.ts b/packages/@aws-cdk/assertions/lib/annotations.ts new file mode 100644 index 0000000000000..c656b15d6bab8 --- /dev/null +++ b/packages/@aws-cdk/assertions/lib/annotations.ts @@ -0,0 +1,129 @@ +import { Stack, Stage } from '@aws-cdk/core'; +import { SynthesisMessage } from '@aws-cdk/cx-api'; +import { Messages } from './private/message'; +import { findMessage, hasMessage } from './private/messages'; + +/** + * Suite of assertions that can be run on a CDK Stack. + * Focused on asserting annotations. + */ +export class Annotations { + /** + * Base your assertions on the messages returned by a synthesized CDK `Stack`. + * @param stack the CDK Stack to run assertions on + */ + public static fromStack(stack: Stack): Annotations { + return new Annotations(toMessages(stack)); + } + + private readonly _messages: Messages; + + private constructor(messages: SynthesisMessage[]) { + this._messages = convertArrayToMessagesType(messages); + } + + /** + * Assert that an error with the given message exists in the synthesized CDK `Stack`. + * + * @param constructPath the construct path to the error. Provide `'*'` to match all errors in the template. + * @param message the error message as should be expected. This should be a string or Matcher object. + */ + public hasError(constructPath: string, message: any): void { + const matchError = hasMessage(this._messages, constructPath, constructMessage('error', message)); + if (matchError) { + throw new Error(matchError); + } + } + + /** + * Get the set of matching errors of a given construct path and message. + * + * @param constructPath the construct path to the error. Provide `'*'` to match all errors in the template. + * @param message the error message as should be expected. This should be a string or Matcher object. + */ + public findError(constructPath: string, message: any): SynthesisMessage[] { + return convertMessagesTypeToArray(findMessage(this._messages, constructPath, constructMessage('error', message)) as Messages); + } + + /** + * Assert that an warning with the given message exists in the synthesized CDK `Stack`. + * + * @param constructPath the construct path to the warning. Provide `'*'` to match all warnings in the template. + * @param message the warning message as should be expected. This should be a string or Matcher object. + */ + public hasWarning(constructPath: string, message: any): void { + const matchError = hasMessage(this._messages, constructPath, constructMessage('warning', message)); + if (matchError) { + throw new Error(matchError); + } + } + + /** + * Get the set of matching warning of a given construct path and message. + * + * @param constructPath the construct path to the warning. Provide `'*'` to match all warnings in the template. + * @param message the warning message as should be expected. This should be a string or Matcher object. + */ + public findWarning(constructPath: string, message: any): SynthesisMessage[] { + return convertMessagesTypeToArray(findMessage(this._messages, constructPath, constructMessage('warning', message)) as Messages); + } + + /** + * Assert that an info with the given message exists in the synthesized CDK `Stack`. + * + * @param constructPath the construct path to the info. Provide `'*'` to match all info in the template. + * @param message the info message as should be expected. This should be a string or Matcher object. + */ + public hasInfo(constructPath: string, message: any): void { + const matchError = hasMessage(this._messages, constructPath, constructMessage('info', message)); + if (matchError) { + throw new Error(matchError); + } + } + + /** + * Get the set of matching infos of a given construct path and message. + * + * @param constructPath the construct path to the info. Provide `'*'` to match all infos in the template. + * @param message the info message as should be expected. This should be a string or Matcher object. + */ + public findInfo(constructPath: string, message: any): SynthesisMessage[] { + return convertMessagesTypeToArray(findMessage(this._messages, constructPath, constructMessage('info', message)) as Messages); + } +} + +function constructMessage(type: 'info' | 'warning' | 'error', message: any): {[key:string]: any } { + return { + level: type, + entry: { + data: message, + }, + }; +} + +function convertArrayToMessagesType(messages: SynthesisMessage[]): Messages { + return messages.reduce((obj, item) => { + return { + ...obj, + [item.id]: item, + }; + }, {}) as Messages; +} + +function convertMessagesTypeToArray(messages: Messages): SynthesisMessage[] { + return Object.values(messages) as SynthesisMessage[]; +} + +function toMessages(stack: Stack): any { + const root = stack.node.root; + if (!Stage.isStage(root)) { + throw new Error('unexpected: all stacks must be part of a Stage or an App'); + } + + // to support incremental assertions (i.e. "expect(stack).toNotContainSomething(); doSomething(); expect(stack).toContainSomthing()") + const force = true; + + const assembly = root.synth({ force }); + + return assembly.getStackArtifact(stack.artifactId).messages; +} diff --git a/packages/@aws-cdk/assertions/lib/index.ts b/packages/@aws-cdk/assertions/lib/index.ts index 492fad1227af3..eccbfac38637f 100644 --- a/packages/@aws-cdk/assertions/lib/index.ts +++ b/packages/@aws-cdk/assertions/lib/index.ts @@ -1,4 +1,5 @@ export * from './capture'; export * from './template'; export * from './match'; -export * from './matcher'; \ No newline at end of file +export * from './matcher'; +export * from './annotations'; \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/lib/private/message.ts b/packages/@aws-cdk/assertions/lib/private/message.ts new file mode 100644 index 0000000000000..9657a5d90ad99 --- /dev/null +++ b/packages/@aws-cdk/assertions/lib/private/message.ts @@ -0,0 +1,5 @@ +import { SynthesisMessage } from '@aws-cdk/cx-api'; + +export type Messages = { + [logicalId: string]: SynthesisMessage; +} diff --git a/packages/@aws-cdk/assertions/lib/private/messages.ts b/packages/@aws-cdk/assertions/lib/private/messages.ts new file mode 100644 index 0000000000000..75c6fe3ae50b1 --- /dev/null +++ b/packages/@aws-cdk/assertions/lib/private/messages.ts @@ -0,0 +1,41 @@ +import { MatchResult } from '../matcher'; +import { Messages } from './message'; +import { filterLogicalId, formatFailure, matchSection } from './section'; + +export function findMessage(messages: Messages, logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } { + const section: { [key: string]: {} } = messages; + const result = matchSection(filterLogicalId(section, logicalId), props); + + if (!result.match) { + return {}; + } + + return result.matches; +} + +export function hasMessage(messages: Messages, logicalId: string, props: any): string | void { + const section: { [key: string]: {} } = messages; + const result = matchSection(filterLogicalId(section, logicalId), props); + + if (result.match) { + return; + } + + if (result.closestResult === undefined) { + return 'No messages found in the stack'; + } + + return [ + `Stack has ${result.analyzedCount} messages, but none match as expected.`, + formatFailure(formatMessage(result.closestResult)), + ].join('\n'); +} + +// We redact the stack trace by default because it is unnecessarily long and unintelligible. +// If there is a use case for rendering the trace, we can add it later. +function formatMessage(match: MatchResult, renderTrace: boolean = false): MatchResult { + if (!renderTrace) { + match.target.entry.trace = 'redacted'; + } + return match; +} diff --git a/packages/@aws-cdk/assertions/lib/template.ts b/packages/@aws-cdk/assertions/lib/template.ts index 8875a91d0ac9c..ec23538eaf4aa 100644 --- a/packages/@aws-cdk/assertions/lib/template.ts +++ b/packages/@aws-cdk/assertions/lib/template.ts @@ -129,7 +129,8 @@ export class Template { * @param logicalId the name of the parameter. Provide `'*'` to match all parameters in the template. * @param props by default, matches all Parameters in the template. * When a literal object is provided, performs a partial match via `Match.objectLike()`. - * Use the `Match` APIs to configure a different behaviour. */ + * Use the `Match` APIs to configure a different behaviour. + */ public findParameters(logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } { return findParameters(this.template, logicalId, props); } diff --git a/packages/@aws-cdk/assertions/rosetta/default.ts-fixture b/packages/@aws-cdk/assertions/rosetta/default.ts-fixture index fa862da5c609a..32d751f8c38fe 100644 --- a/packages/@aws-cdk/assertions/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/assertions/rosetta/default.ts-fixture @@ -1,7 +1,7 @@ // Fixture with packages imported, but nothing else import { Construct } from 'constructs'; -import { Stack } from '@aws-cdk/core'; -import { Capture, Match, Template } from '@aws-cdk/assertions'; +import { Aspects, CfnResource, Stack } from '@aws-cdk/core'; +import { Annotations, Capture, Match, Template } from '@aws-cdk/assertions'; class Fixture extends Stack { constructor(scope: Construct, id: string) { diff --git a/packages/@aws-cdk/assertions/test/annotations.ts b/packages/@aws-cdk/assertions/test/annotations.ts new file mode 100644 index 0000000000000..8275e52d39dff --- /dev/null +++ b/packages/@aws-cdk/assertions/test/annotations.ts @@ -0,0 +1,150 @@ +import { Annotations, Aspects, CfnResource, IAspect, Stack } from '@aws-cdk/core'; +import { IConstruct } from 'constructs'; +import { Annotations as _Annotations, Match } from '../lib'; + +describe('Messages', () => { + let stack: Stack; + let annotations: _Annotations; + beforeAll(() => { + stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { + Fred: 'Thud', + }, + }); + + new CfnResource(stack, 'Bar', { + type: 'Foo::Bar', + properties: { + Baz: 'Qux', + }, + }); + + new CfnResource(stack, 'Fred', { + type: 'Baz::Qux', + properties: { + Foo: 'Bar', + }, + }); + + new CfnResource(stack, 'Qux', { + type: 'Fred::Thud', + properties: { + Fred: 'Bar', + }, + }); + + Aspects.of(stack).add(new MyAspect()); + annotations = _Annotations.fromStack(stack); + }); + + describe('hasError', () => { + test('match', () => { + annotations.hasError('/Default/Foo', 'this is an error'); + }); + + test('no match', () => { + expect(() => annotations.hasError('/Default/Fred', Match.anyValue())) + .toThrowError(/Stack has 1 messages, but none match as expected./); + }); + }); + + describe('findError', () => { + test('match', () => { + const result = annotations.findError('*', Match.anyValue()); + expect(Object.keys(result).length).toEqual(2); + }); + + test('no match', () => { + const result = annotations.findError('*', 'no message looks like this'); + expect(Object.keys(result).length).toEqual(0); + }); + }); + + describe('hasWarning', () => { + test('match', () => { + annotations.hasWarning('/Default/Fred', 'this is a warning'); + }); + + test('no match', () => { + expect(() => annotations.hasWarning('/Default/Foo', Match.anyValue())).toThrowError(/Stack has 1 messages, but none match as expected./); + }); + }); + + describe('findWarning', () => { + test('match', () => { + const result = annotations.findWarning('*', Match.anyValue()); + expect(Object.keys(result).length).toEqual(1); + }); + + test('no match', () => { + const result = annotations.findWarning('*', 'no message looks like this'); + expect(Object.keys(result).length).toEqual(0); + }); + }); + + describe('hasInfo', () => { + test('match', () => { + annotations.hasInfo('/Default/Qux', 'this is an info'); + }); + + test('no match', () => { + expect(() => annotations.hasInfo('/Default/Qux', 'this info is incorrect')).toThrowError(/Stack has 1 messages, but none match as expected./); + }); + }); + + describe('findInfo', () => { + test('match', () => { + const result = annotations.findInfo('/Default/Qux', 'this is an info'); + expect(Object.keys(result).length).toEqual(1); + }); + + test('no match', () => { + const result = annotations.findInfo('*', 'no message looks like this'); + expect(Object.keys(result).length).toEqual(0); + }); + }); + + describe('with matchers', () => { + test('anyValue', () => { + const result = annotations.findError('*', Match.anyValue()); + expect(Object.keys(result).length).toEqual(2); + }); + + test('not', () => { + expect(() => annotations.hasError('/Default/Foo', Match.not('this is an error'))) + .toThrowError(/Found unexpected match: "this is an error" at \/entry\/data/); + }); + + test('stringLikeRegEx', () => { + annotations.hasError('/Default/Foo', Match.stringLikeRegexp('.*error')); + }); + }); +}); + +class MyAspect implements IAspect { + public visit(node: IConstruct): void { + if (node instanceof CfnResource) { + if (node.cfnResourceType === 'Foo::Bar') { + this.error(node, 'this is an error'); + } else if (node.cfnResourceType === 'Baz::Qux') { + this.warn(node, 'this is a warning'); + } else { + this.info(node, 'this is an info'); + } + } + }; + + protected warn(node: IConstruct, message: string): void { + Annotations.of(node).addWarning(message); + } + + protected error(node: IConstruct, message: string): void { + Annotations.of(node).addError(message); + } + + protected info(node: IConstruct, message: string): void { + Annotations.of(node).addInfo(message); + } +} \ No newline at end of file