Skip to content

Commit b22ad55

Browse files
committed
feat(assertions): throw typed errors
1 parent 80f741c commit b22ad55

File tree

10 files changed

+88
-34
lines changed

10 files changed

+88
-34
lines changed

packages/@aws-cdk/integ-tests-alpha/test/assertions/providers/lambda-handler/base.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,9 @@ describe('CustomResourceHandler', () => {
245245
});
246246
});
247247

248-
function nockUp(_predicate: (body: CloudFormationResponse) => boolean) {
248+
function nockUp(predicate: (body: CloudFormationResponse) => boolean) {
249249
return nock('https://someurl.com')
250-
.put('/')
250+
.put('/', predicate)
251251
.reply(200);
252252
}
253253

packages/aws-cdk-lib/.eslintrc.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ baseConfig.rules['import/no-extraneous-dependencies'] = [
1616

1717
// no-throw-default-error
1818
const enableNoThrowDefaultErrorIn = [
19-
'aws-backup',
19+
'assertions',
2020
'assets',
2121
'aws-amplify',
2222
'aws-amplifyuibuilder',
@@ -30,6 +30,7 @@ const enableNoThrowDefaultErrorIn = [
3030
'aws-appmesh',
3131
'aws-autoscaling',
3232
'aws-autoscaling-common',
33+
'aws-backup',
3334
'aws-batch',
3435
'aws-cognito',
3536
'aws-elasticloadbalancing',
@@ -46,7 +47,6 @@ const enableNoThrowDefaultErrorIn = [
4647
'aws-ssmincidents',
4748
'aws-ssmquicksetup',
4849
'aws-synthetics',
49-
'cloudformation-include',
5050
'aws-route53',
5151
'aws-route53-patterns',
5252
'aws-route53-targets',
@@ -61,7 +61,6 @@ const enableNoThrowDefaultErrorIn = [
6161
'aws-ssmincidents',
6262
'aws-ssmquicksetup',
6363
'aws-synthetics',
64-
'cx-api',
6564
'aws-s3',
6665
'aws-s3-assets',
6766
'aws-s3-deployment',
@@ -70,6 +69,8 @@ const enableNoThrowDefaultErrorIn = [
7069
'aws-s3objectlambda',
7170
'aws-s3outposts',
7271
'aws-s3tables',
72+
'cloudformation-include',
73+
'cx-api',
7374
'pipelines',
7475
];
7576
baseConfig.overrides.push({

packages/aws-cdk-lib/assertions/lib/annotations.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Messages } from './private/message';
22
import { findMessage, hasMessage, hasNoMessage } from './private/messages';
33
import { Stack, Stage } from '../../core';
44
import { SynthesisMessage } from '../../cx-api';
5+
import { AssertionError } from './private/error';
56

67
/**
78
* Suite of assertions that can be run on a CDK Stack.
@@ -31,7 +32,7 @@ export class Annotations {
3132
public hasError(constructPath: string, message: any): void {
3233
const matchError = hasMessage(this._messages, constructPath, constructMessage('error', message));
3334
if (matchError) {
34-
throw new Error(matchError);
35+
throw new AssertionError(matchError);
3536
}
3637
}
3738

@@ -44,7 +45,7 @@ export class Annotations {
4445
public hasNoError(constructPath: string, message: any): void {
4546
const matchError = hasNoMessage(this._messages, constructPath, constructMessage('error', message));
4647
if (matchError) {
47-
throw new Error(matchError);
48+
throw new AssertionError(matchError);
4849
}
4950
}
5051

@@ -67,7 +68,7 @@ export class Annotations {
6768
public hasWarning(constructPath: string, message: any): void {
6869
const matchError = hasMessage(this._messages, constructPath, constructMessage('warning', message));
6970
if (matchError) {
70-
throw new Error(matchError);
71+
throw new AssertionError(matchError);
7172
}
7273
}
7374

@@ -80,7 +81,7 @@ export class Annotations {
8081
public hasNoWarning(constructPath: string, message: any): void {
8182
const matchError = hasNoMessage(this._messages, constructPath, constructMessage('warning', message));
8283
if (matchError) {
83-
throw new Error(matchError);
84+
throw new AssertionError(matchError);
8485
}
8586
}
8687

@@ -103,7 +104,7 @@ export class Annotations {
103104
public hasInfo(constructPath: string, message: any): void {
104105
const matchError = hasMessage(this._messages, constructPath, constructMessage('info', message));
105106
if (matchError) {
106-
throw new Error(matchError);
107+
throw new AssertionError(matchError);
107108
}
108109
}
109110

@@ -116,7 +117,7 @@ export class Annotations {
116117
public hasNoInfo(constructPath: string, message: any): void {
117118
const matchError = hasNoMessage(this._messages, constructPath, constructMessage('info', message));
118119
if (matchError) {
119-
throw new Error(matchError);
120+
throw new AssertionError(matchError);
120121
}
121122
}
122123

@@ -156,7 +157,7 @@ function convertMessagesTypeToArray(messages: Messages): SynthesisMessage[] {
156157
function toMessages(stack: Stack): any {
157158
const root = stack.node.root;
158159
if (!Stage.isStage(root)) {
159-
throw new Error('unexpected: all stacks must be part of a Stage or an App');
160+
throw new AssertionError('unexpected: all stacks must be part of a Stage or an App');
160161
}
161162

162163
// to support incremental assertions (i.e. "expect(stack).toNotContainSomething(); doSomething(); expect(stack).toContainSomthing()")

packages/aws-cdk-lib/assertions/lib/capture.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Match } from '.';
22
import { Matcher, MatchResult } from './matcher';
3+
import { AssertionError } from './private/error';
34
import { Type, getType } from './private/type';
45

56
/**
@@ -124,12 +125,12 @@ export class Capture extends Matcher {
124125

125126
private validate(): void {
126127
if (this._captured.length === 0) {
127-
throw new Error('No value captured');
128+
throw new AssertionError('No value captured');
128129
}
129130
}
130131

131132
private reportIncorrectType(expected: Type): never {
132-
throw new Error(`Captured value is expected to be ${expected} but found ${getType(this._captured[this.idx])}. ` +
133+
throw new AssertionError(`Captured value is expected to be ${expected} but found ${getType(this._captured[this.idx])}. ` +
133134
`Value is ${JSON.stringify(this._captured[this.idx], undefined, 2)}`);
134135
}
135136
}

packages/aws-cdk-lib/assertions/lib/match.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Matcher, MatchResult } from './matcher';
2+
import { AssertionError } from './private/error';
23
import { AbsentMatch } from './private/matchers/absent';
34
import { sortKeyComparator } from './private/sorting';
45
import { SparseMatrix } from './private/sparse-matrix';
@@ -116,7 +117,7 @@ class LiteralMatch extends Matcher {
116117
this.partialObjects = options.partialObjects ?? false;
117118

118119
if (Matcher.isMatcher(this.pattern)) {
119-
throw new Error('LiteralMatch cannot directly contain another matcher. ' +
120+
throw new AssertionError('LiteralMatch cannot directly contain another matcher. ' +
120121
'Remove the top-level matcher or nest it more deeply.');
121122
}
122123
}
@@ -253,7 +254,7 @@ class ArrayMatch extends Matcher {
253254
const matcherName = matcher.name;
254255
if (matcherName == 'absent' || matcherName == 'anyValue') {
255256
// array subsequence matcher is not compatible with anyValue() or absent() matcher. They don't make sense to be used together.
256-
throw new Error(`The Matcher ${matcherName}() cannot be nested within arrayWith()`);
257+
throw new AssertionError(`The Matcher ${matcherName}() cannot be nested within arrayWith()`);
257258
}
258259

259260
const innerResult = matcher.test(actual[actualIdx]);

packages/aws-cdk-lib/assertions/lib/private/cyclic.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AssertionError } from './error';
12
import { Resource, Template } from './template';
23

34
/**
@@ -28,7 +29,7 @@ export function checkTemplateForCyclicDependencies(template: Template): void {
2829
cycleResources[logicalId] = template.Resources?.[logicalId];
2930
}
3031

31-
throw new Error(`Template is undeployable, these resources have a dependency cycle: ${cycle.join(' -> ')}:\n\n${JSON.stringify(cycleResources, undefined, 2)}`);
32+
throw new AssertionError(`Template is undeployable, these resources have a dependency cycle: ${cycle.join(' -> ')}:\n\n${JSON.stringify(cycleResources, undefined, 2)}`);
3233
}
3334

3435
for (const [logicalId, _] of free) {
@@ -163,7 +164,7 @@ function findCycle(deps: ReadonlyMap<string, ReadonlySet<string>>): string[] {
163164
const cycle = recurse(node, [node]);
164165
if (cycle) { return cycle; }
165166
}
166-
throw new Error('No cycle found. Assertion failure!');
167+
throw new AssertionError('No cycle found. Assertion failure!');
167168

168169
function recurse(node: string, path: string[]): string[] | undefined {
169170
for (const dep of deps.get(node) ?? []) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const ASSERTION_ERROR_SYMBOL = Symbol.for('@aws-cdk/assertions.AssertionError');
2+
3+
/**
4+
* An AssertionError is thrown from the assertions module when an assertion fails.
5+
* Assertion errors are directly connected to an assertion a user wrote.
6+
*
7+
* Not all errors from the assertions module are automatically AssertionErrors.
8+
* When a pre-condition is incorrect (e.g. disallowed use of a matcher),
9+
* throwing an UnscopedValidationError is more appropriate.
10+
*
11+
* @internal
12+
*/
13+
export class AssertionError extends Error {
14+
#time: string;
15+
16+
/**
17+
* The time the error was thrown.
18+
*/
19+
public get time(): string {
20+
return this.#time;
21+
}
22+
23+
public get type(): 'assertion' {
24+
return 'assertion';
25+
}
26+
27+
constructor(msg: string) {
28+
super(msg);
29+
30+
Object.setPrototypeOf(this, AssertionError.prototype);
31+
Object.defineProperty(this, ASSERTION_ERROR_SYMBOL, { value: true });
32+
33+
this.name = new.target.name;
34+
this.#time = new Date().toISOString();
35+
}
36+
}

packages/aws-cdk-lib/assertions/lib/tags.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Match } from './match';
22
import { Matcher } from './matcher';
33
import { Stack, Stage } from '../../core';
4+
import { AssertionError } from './private/error';
45

56
type ManifestTags = { [key: string]: string };
67

@@ -37,7 +38,7 @@ export class Tags {
3738
// Match.absent() will not work as the caller expects, so we push them
3839
// towards a working API.
3940
if (Matcher.isMatcher(tags) && tags.name === 'absent') {
40-
throw new Error(
41+
throw new AssertionError(
4142
'Match.absent() will never match Tags because "{}" is the default value. Use Tags.hasNone() instead.',
4243
);
4344
}
@@ -46,7 +47,7 @@ export class Tags {
4647

4748
const result = matcher.test(this.all());
4849
if (result.hasFailed()) {
49-
throw new Error(
50+
throw new AssertionError(
5051
'Stack tags did not match as expected:\n' + result.renderMismatch(),
5152
);
5253
}
@@ -79,7 +80,7 @@ export class Tags {
7980
function getManifestTags(stack: Stack): ManifestTags {
8081
const root = stack.node.root;
8182
if (!Stage.isStage(root)) {
82-
throw new Error('unexpected: all stacks must be part of a Stage or an App');
83+
throw new AssertionError('unexpected: all stacks must be part of a Stage or an App');
8384
}
8485

8586
// synthesis is not forced: the stack will only be synthesized once regardless

packages/aws-cdk-lib/assertions/lib/template.ts

+13-12
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { findParameters, hasParameter } from './private/parameters';
1010
import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources';
1111
import { Template as TemplateType } from './private/template';
1212
import { Stack, Stage } from '../../core';
13+
import { AssertionError } from './private/error';
1314

1415
/**
1516
* Suite of assertions that can be run on a CDK stack.
@@ -74,7 +75,7 @@ export class Template {
7475
public resourceCountIs(type: string, count: number): void {
7576
const counted = countResources(this.template, type);
7677
if (counted !== count) {
77-
throw new Error(`Expected ${count} resources of type ${type} but found ${counted}`);
78+
throw new AssertionError(`Expected ${count} resources of type ${type} but found ${counted}`);
7879
}
7980
}
8081

@@ -88,7 +89,7 @@ export class Template {
8889
public resourcePropertiesCountIs(type: string, props: any, count: number): void {
8990
const counted = countResourcesProperties(this.template, type, props);
9091
if (counted !== count) {
91-
throw new Error(`Expected ${count} resources of type ${type} but found ${counted}`);
92+
throw new AssertionError(`Expected ${count} resources of type ${type} but found ${counted}`);
9293
}
9394
}
9495

@@ -103,7 +104,7 @@ export class Template {
103104
public hasResourceProperties(type: string, props: any): void {
104105
const matchError = hasResourceProperties(this.template, type, props);
105106
if (matchError) {
106-
throw new Error(matchError);
107+
throw new AssertionError(matchError);
107108
}
108109
}
109110

@@ -118,7 +119,7 @@ export class Template {
118119
public hasResource(type: string, props: any): void {
119120
const matchError = hasResource(this.template, type, props);
120121
if (matchError) {
121-
throw new Error(matchError);
122+
throw new AssertionError(matchError);
122123
}
123124
}
124125

@@ -144,7 +145,7 @@ export class Template {
144145
public allResources(type: string, props: any): void {
145146
const matchError = allResources(this.template, type, props);
146147
if (matchError) {
147-
throw new Error(matchError);
148+
throw new AssertionError(matchError);
148149
}
149150
}
150151

@@ -159,7 +160,7 @@ export class Template {
159160
public allResourcesProperties(type: string, props: any): void {
160161
const matchError = allResourcesProperties(this.template, type, props);
161162
if (matchError) {
162-
throw new Error(matchError);
163+
throw new AssertionError(matchError);
163164
}
164165
}
165166

@@ -173,7 +174,7 @@ export class Template {
173174
public hasParameter(logicalId: string, props: any): void {
174175
const matchError = hasParameter(this.template, logicalId, props);
175176
if (matchError) {
176-
throw new Error(matchError);
177+
throw new AssertionError(matchError);
177178
}
178179
}
179180

@@ -198,7 +199,7 @@ export class Template {
198199
public hasOutput(logicalId: string, props: any): void {
199200
const matchError = hasOutput(this.template, logicalId, props);
200201
if (matchError) {
201-
throw new Error(matchError);
202+
throw new AssertionError(matchError);
202203
}
203204
}
204205

@@ -223,7 +224,7 @@ export class Template {
223224
public hasMapping(logicalId: string, props: any): void {
224225
const matchError = hasMapping(this.template, logicalId, props);
225226
if (matchError) {
226-
throw new Error(matchError);
227+
throw new AssertionError(matchError);
227228
}
228229
}
229230

@@ -248,7 +249,7 @@ export class Template {
248249
public hasCondition(logicalId: string, props: any): void {
249250
const matchError = hasCondition(this.template, logicalId, props);
250251
if (matchError) {
251-
throw new Error(matchError);
252+
throw new AssertionError(matchError);
252253
}
253254
}
254255

@@ -272,7 +273,7 @@ export class Template {
272273
const result = matcher.test(this.template);
273274

274275
if (result.hasFailed()) {
275-
throw new Error([
276+
throw new AssertionError([
276277
'Template did not match as expected. The following mismatches were found:',
277278
...result.toHumanStrings().map(s => `\t${s}`),
278279
].join('\n'));
@@ -297,7 +298,7 @@ export interface TemplateParsingOptions {
297298
function toTemplate(stack: Stack): any {
298299
const stage = Stage.of(stack);
299300
if (!Stage.isStage(stage)) {
300-
throw new Error('unexpected: all stacks must be part of a Stage or an App');
301+
throw new AssertionError('unexpected: all stacks must be part of a Stage or an App');
301302
}
302303

303304
const assembly = stage.synth();

0 commit comments

Comments
 (0)