Skip to content

Commit cb86e30

Browse files
authored
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*
1 parent e743525 commit cb86e30

File tree

8 files changed

+404
-6
lines changed

8 files changed

+404
-6
lines changed

Diff for: packages/@aws-cdk/assertions/README.md

+73-2
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ This matcher can be combined with any of the other matchers.
251251
// The following will NOT throw an assertion error
252252
template.hasResourceProperties('Foo::Bar', {
253253
Fred: {
254-
Wobble: [ Match.anyValue(), "Flip" ],
254+
Wobble: [ Match.anyValue(), Match.anyValue() ],
255255
},
256256
});
257257

@@ -400,7 +400,7 @@ template.hasResourceProperties('Foo::Bar', {
400400

401401
## Capturing Values
402402

403-
This matcher APIs documented above allow capturing values in the matching entry
403+
The matcher APIs documented above allow capturing values in the matching entry
404404
(Resource, Output, Mapping, etc.). The following code captures a string from a
405405
matching resource.
406406

@@ -492,3 +492,74 @@ fredCapture.asString(); // returns "Flob"
492492
fredCapture.next(); // returns true
493493
fredCapture.asString(); // returns "Quib"
494494
```
495+
496+
## Asserting Annotations
497+
498+
In addition to template matching, we provide an API for annotation matching.
499+
[Annotations](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.Annotations.html)
500+
can be added via the [Aspects](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.Aspects.html)
501+
API. You can learn more about Aspects [here](https://docs.aws.amazon.com/cdk/v2/guide/aspects.html).
502+
503+
Say you have a `MyAspect` and a `MyStack` that uses `MyAspect`:
504+
505+
```ts nofixture
506+
import * as cdk from '@aws-cdk/core';
507+
import { Construct, IConstruct } from 'constructs';
508+
509+
class MyAspect implements cdk.IAspect {
510+
public visit(node: IConstruct): void {
511+
if (node instanceof cdk.CfnResource && node.cfnResourceType === 'Foo::Bar') {
512+
this.error(node, 'we do not want a Foo::Bar resource');
513+
}
514+
}
515+
516+
protected error(node: IConstruct, message: string): void {
517+
cdk.Annotations.of(node).addError(message);
518+
}
519+
}
520+
521+
class MyStack extends cdk.Stack {
522+
constructor(scope: Construct, id: string) {
523+
super(scope, id);
524+
525+
const stack = new cdk.Stack();
526+
new cdk.CfnResource(stack, 'Foo', {
527+
type: 'Foo::Bar',
528+
properties: {
529+
Fred: 'Thud',
530+
},
531+
});
532+
cdk.Aspects.of(stack).add(new MyAspect());
533+
}
534+
}
535+
```
536+
537+
We can then assert that the stack contains the expected Error:
538+
539+
```ts
540+
// import { Annotations } from '@aws-cdk/assertions';
541+
542+
Annotations.fromStack(stack).hasError(
543+
'/Default/Foo',
544+
'we do not want a Foo::Bar resource',
545+
);
546+
```
547+
548+
Here are the available APIs for `Annotations`:
549+
550+
- `hasError()` and `findError()`
551+
- `hasWarning()` and `findWarning()`
552+
- `hasInfo()` and `findInfo()`
553+
554+
The corresponding `findXxx()` API is complementary to the `hasXxx()` API, except instead
555+
of asserting its presence, it returns the set of matching messages.
556+
557+
In addition, this suite of APIs is compatable with `Matchers` for more fine-grained control.
558+
For example, the following assertion works as well:
559+
560+
```ts
561+
Annotations.fromStack(stack).hasError(
562+
'/Default/Foo',
563+
Match.stringLikeRegexp('.*Foo::Bar.*'),
564+
);
565+
```

Diff for: packages/@aws-cdk/assertions/lib/annotations.ts

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Stack, Stage } from '@aws-cdk/core';
2+
import { SynthesisMessage } from '@aws-cdk/cx-api';
3+
import { Messages } from './private/message';
4+
import { findMessage, hasMessage } from './private/messages';
5+
6+
/**
7+
* Suite of assertions that can be run on a CDK Stack.
8+
* Focused on asserting annotations.
9+
*/
10+
export class Annotations {
11+
/**
12+
* Base your assertions on the messages returned by a synthesized CDK `Stack`.
13+
* @param stack the CDK Stack to run assertions on
14+
*/
15+
public static fromStack(stack: Stack): Annotations {
16+
return new Annotations(toMessages(stack));
17+
}
18+
19+
private readonly _messages: Messages;
20+
21+
private constructor(messages: SynthesisMessage[]) {
22+
this._messages = convertArrayToMessagesType(messages);
23+
}
24+
25+
/**
26+
* Assert that an error with the given message exists in the synthesized CDK `Stack`.
27+
*
28+
* @param constructPath the construct path to the error. Provide `'*'` to match all errors in the template.
29+
* @param message the error message as should be expected. This should be a string or Matcher object.
30+
*/
31+
public hasError(constructPath: string, message: any): void {
32+
const matchError = hasMessage(this._messages, constructPath, constructMessage('error', message));
33+
if (matchError) {
34+
throw new Error(matchError);
35+
}
36+
}
37+
38+
/**
39+
* Get the set of matching errors of a given construct path and message.
40+
*
41+
* @param constructPath the construct path to the error. Provide `'*'` to match all errors in the template.
42+
* @param message the error message as should be expected. This should be a string or Matcher object.
43+
*/
44+
public findError(constructPath: string, message: any): SynthesisMessage[] {
45+
return convertMessagesTypeToArray(findMessage(this._messages, constructPath, constructMessage('error', message)) as Messages);
46+
}
47+
48+
/**
49+
* Assert that an warning with the given message exists in the synthesized CDK `Stack`.
50+
*
51+
* @param constructPath the construct path to the warning. Provide `'*'` to match all warnings in the template.
52+
* @param message the warning message as should be expected. This should be a string or Matcher object.
53+
*/
54+
public hasWarning(constructPath: string, message: any): void {
55+
const matchError = hasMessage(this._messages, constructPath, constructMessage('warning', message));
56+
if (matchError) {
57+
throw new Error(matchError);
58+
}
59+
}
60+
61+
/**
62+
* Get the set of matching warning of a given construct path and message.
63+
*
64+
* @param constructPath the construct path to the warning. Provide `'*'` to match all warnings in the template.
65+
* @param message the warning message as should be expected. This should be a string or Matcher object.
66+
*/
67+
public findWarning(constructPath: string, message: any): SynthesisMessage[] {
68+
return convertMessagesTypeToArray(findMessage(this._messages, constructPath, constructMessage('warning', message)) as Messages);
69+
}
70+
71+
/**
72+
* Assert that an info with the given message exists in the synthesized CDK `Stack`.
73+
*
74+
* @param constructPath the construct path to the info. Provide `'*'` to match all info in the template.
75+
* @param message the info message as should be expected. This should be a string or Matcher object.
76+
*/
77+
public hasInfo(constructPath: string, message: any): void {
78+
const matchError = hasMessage(this._messages, constructPath, constructMessage('info', message));
79+
if (matchError) {
80+
throw new Error(matchError);
81+
}
82+
}
83+
84+
/**
85+
* Get the set of matching infos of a given construct path and message.
86+
*
87+
* @param constructPath the construct path to the info. Provide `'*'` to match all infos in the template.
88+
* @param message the info message as should be expected. This should be a string or Matcher object.
89+
*/
90+
public findInfo(constructPath: string, message: any): SynthesisMessage[] {
91+
return convertMessagesTypeToArray(findMessage(this._messages, constructPath, constructMessage('info', message)) as Messages);
92+
}
93+
}
94+
95+
function constructMessage(type: 'info' | 'warning' | 'error', message: any): {[key:string]: any } {
96+
return {
97+
level: type,
98+
entry: {
99+
data: message,
100+
},
101+
};
102+
}
103+
104+
function convertArrayToMessagesType(messages: SynthesisMessage[]): Messages {
105+
return messages.reduce((obj, item) => {
106+
return {
107+
...obj,
108+
[item.id]: item,
109+
};
110+
}, {}) as Messages;
111+
}
112+
113+
function convertMessagesTypeToArray(messages: Messages): SynthesisMessage[] {
114+
return Object.values(messages) as SynthesisMessage[];
115+
}
116+
117+
function toMessages(stack: Stack): any {
118+
const root = stack.node.root;
119+
if (!Stage.isStage(root)) {
120+
throw new Error('unexpected: all stacks must be part of a Stage or an App');
121+
}
122+
123+
// to support incremental assertions (i.e. "expect(stack).toNotContainSomething(); doSomething(); expect(stack).toContainSomthing()")
124+
const force = true;
125+
126+
const assembly = root.synth({ force });
127+
128+
return assembly.getStackArtifact(stack.artifactId).messages;
129+
}

Diff for: packages/@aws-cdk/assertions/lib/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './capture';
22
export * from './template';
33
export * from './match';
4-
export * from './matcher';
4+
export * from './matcher';
5+
export * from './annotations';

Diff for: packages/@aws-cdk/assertions/lib/private/message.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { SynthesisMessage } from '@aws-cdk/cx-api';
2+
3+
export type Messages = {
4+
[logicalId: string]: SynthesisMessage;
5+
}

Diff for: packages/@aws-cdk/assertions/lib/private/messages.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { MatchResult } from '../matcher';
2+
import { Messages } from './message';
3+
import { filterLogicalId, formatFailure, matchSection } from './section';
4+
5+
export function findMessage(messages: Messages, logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } {
6+
const section: { [key: string]: {} } = messages;
7+
const result = matchSection(filterLogicalId(section, logicalId), props);
8+
9+
if (!result.match) {
10+
return {};
11+
}
12+
13+
return result.matches;
14+
}
15+
16+
export function hasMessage(messages: Messages, logicalId: string, props: any): string | void {
17+
const section: { [key: string]: {} } = messages;
18+
const result = matchSection(filterLogicalId(section, logicalId), props);
19+
20+
if (result.match) {
21+
return;
22+
}
23+
24+
if (result.closestResult === undefined) {
25+
return 'No messages found in the stack';
26+
}
27+
28+
return [
29+
`Stack has ${result.analyzedCount} messages, but none match as expected.`,
30+
formatFailure(formatMessage(result.closestResult)),
31+
].join('\n');
32+
}
33+
34+
// We redact the stack trace by default because it is unnecessarily long and unintelligible.
35+
// If there is a use case for rendering the trace, we can add it later.
36+
function formatMessage(match: MatchResult, renderTrace: boolean = false): MatchResult {
37+
if (!renderTrace) {
38+
match.target.entry.trace = 'redacted';
39+
}
40+
return match;
41+
}

Diff for: packages/@aws-cdk/assertions/lib/template.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ export class Template {
129129
* @param logicalId the name of the parameter. Provide `'*'` to match all parameters in the template.
130130
* @param props by default, matches all Parameters in the template.
131131
* When a literal object is provided, performs a partial match via `Match.objectLike()`.
132-
* Use the `Match` APIs to configure a different behaviour. */
132+
* Use the `Match` APIs to configure a different behaviour.
133+
*/
133134
public findParameters(logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } {
134135
return findParameters(this.template, logicalId, props);
135136
}

Diff for: packages/@aws-cdk/assertions/rosetta/default.ts-fixture

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Fixture with packages imported, but nothing else
22
import { Construct } from 'constructs';
3-
import { Stack } from '@aws-cdk/core';
4-
import { Capture, Match, Template } from '@aws-cdk/assertions';
3+
import { Aspects, CfnResource, Stack } from '@aws-cdk/core';
4+
import { Annotations, Capture, Match, Template } from '@aws-cdk/assertions';
55

66
class Fixture extends Stack {
77
constructor(scope: Construct, id: string) {

0 commit comments

Comments
 (0)