Skip to content

Commit

Permalink
feat(assertions): support assertions on stack messages (aws#18521)
Browse files Browse the repository at this point in the history
Looking to unblock users who want to use assertions with annotations added on a stack.

Fixes aws#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*
  • Loading branch information
kaizencc authored and TikiTDO committed Feb 21, 2022
1 parent 37e99a0 commit 8d246bd
Show file tree
Hide file tree
Showing 8 changed files with 404 additions and 6 deletions.
75 changes: 73 additions & 2 deletions packages/@aws-cdk/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() ],
},
});

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.*'),
);
```
129 changes: 129 additions & 0 deletions packages/@aws-cdk/assertions/lib/annotations.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/assertions/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './capture';
export * from './template';
export * from './match';
export * from './matcher';
export * from './matcher';
export * from './annotations';
5 changes: 5 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SynthesisMessage } from '@aws-cdk/cx-api';

export type Messages = {
[logicalId: string]: SynthesisMessage;
}
41 changes: 41 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/messages.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/assertions/lib/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/assertions/rosetta/default.ts-fixture
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Loading

0 comments on commit 8d246bd

Please sign in to comment.