Skip to content

Commit 9a67ce7

Browse files
author
Niranjan Jayakar
authored
feat(assertions): major improvements to the capture feature (#17713)
There are three major changes around the capture feature of assertions. Firstly, when there are multiple targets (say, Resource in the CloudFormation template) that matches the given condition, any `Capture` defined in the condition will contain only the last matched resource. Convert the `Capture` class into an iterable so all matching values can be retrieved. Secondly, add support to allow sub-patterns to be specified to the `Capture` class. This allows further conditions be specified, via Matchers or literals, when a value is to be captured. Finally, this fixes a bug with the current implementation where `Capture` contains the results of the last matched section, irrespective of whether that section matched with the rest of the matcher or not. fixes #17009 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent b284eba commit 9a67ce7

File tree

9 files changed

+372
-69
lines changed

9 files changed

+372
-69
lines changed

packages/@aws-cdk/assertions/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,67 @@ template.hasResourceProperties('Foo::Bar', {
399399
fredCapture.asArray(); // returns ["Flob", "Cat"]
400400
waldoCapture.asString(); // returns "Qux"
401401
```
402+
403+
With captures, a nested pattern can also be specified, so that only targets
404+
that match the nested pattern will be captured. This pattern can be literals or
405+
further Matchers.
406+
407+
```ts
408+
// Given a template -
409+
// {
410+
// "Resources": {
411+
// "MyBar1": {
412+
// "Type": "Foo::Bar",
413+
// "Properties": {
414+
// "Fred": ["Flob", "Cat"],
415+
// }
416+
// }
417+
// "MyBar2": {
418+
// "Type": "Foo::Bar",
419+
// "Properties": {
420+
// "Fred": ["Qix", "Qux"],
421+
// }
422+
// }
423+
// }
424+
// }
425+
426+
const capture = new Capture(Match.arrayWith(['Cat']));
427+
template.hasResourceProperties('Foo::Bar', {
428+
Fred: capture,
429+
});
430+
431+
capture.asArray(); // returns ['Flob', 'Cat']
432+
```
433+
434+
When multiple resources match the given condition, each `Capture` defined in
435+
the condition will capture all matching values. They can be paged through using
436+
the `next()` API. The following example illustrates this -
437+
438+
```ts
439+
// Given a template -
440+
// {
441+
// "Resources": {
442+
// "MyBar": {
443+
// "Type": "Foo::Bar",
444+
// "Properties": {
445+
// "Fred": "Flob",
446+
// }
447+
// },
448+
// "MyBaz": {
449+
// "Type": "Foo::Bar",
450+
// "Properties": {
451+
// "Fred": "Quib",
452+
// }
453+
// }
454+
// }
455+
// }
456+
457+
const fredCapture = new Capture();
458+
template.hasResourceProperties('Foo::Bar', {
459+
Fred: fredCapture,
460+
});
461+
462+
fredCapture.asString(); // returns "Flob"
463+
fredCapture.next(); // returns true
464+
fredCapture.asString(); // returns "Quib"
465+
```
Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Match } from '.';
12
import { Matcher, MatchResult } from './matcher';
23
import { Type, getType } from './private/type';
34

@@ -8,31 +9,63 @@ import { Type, getType } from './private/type';
89
*/
910
export class Capture extends Matcher {
1011
public readonly name: string;
11-
private value: any = null;
12+
/** @internal */
13+
public _captured: any[] = [];
14+
private idx = 0;
1215

13-
constructor() {
16+
/**
17+
* Initialize a new capture
18+
* @param pattern a nested pattern or Matcher.
19+
* If a nested pattern is provided `objectLike()` matching is applied.
20+
*/
21+
constructor(private readonly pattern?: any) {
1422
super();
1523
this.name = 'Capture';
1624
}
1725

1826
public test(actual: any): MatchResult {
19-
this.value = actual;
20-
2127
const result = new MatchResult(actual);
2228
if (actual == null) {
23-
result.push(this, [], `Can only capture non-nullish values. Found ${actual}`);
29+
return result.recordFailure({
30+
matcher: this,
31+
path: [],
32+
message: `Can only capture non-nullish values. Found ${actual}`,
33+
});
2434
}
35+
36+
if (this.pattern !== undefined) {
37+
const innerMatcher = Matcher.isMatcher(this.pattern) ? this.pattern : Match.objectLike(this.pattern);
38+
const innerResult = innerMatcher.test(actual);
39+
if (innerResult.hasFailed()) {
40+
return innerResult;
41+
}
42+
}
43+
44+
result.recordCapture({ capture: this, value: actual });
2545
return result;
2646
}
2747

48+
/**
49+
* When multiple results are captured, move the iterator to the next result.
50+
* @returns true if another capture is present, false otherwise
51+
*/
52+
public next(): boolean {
53+
if (this.idx < this._captured.length - 1) {
54+
this.idx++;
55+
return true;
56+
}
57+
return false;
58+
}
59+
2860
/**
2961
* Retrieve the captured value as a string.
3062
* An error is generated if no value is captured or if the value is not a string.
3163
*/
3264
public asString(): string {
33-
this.checkNotNull();
34-
if (getType(this.value) === 'string') {
35-
return this.value;
65+
this.validate();
66+
const val = this._captured[this.idx];
67+
if (getType(val) === 'string') {
68+
return val;
3669
}
3770
this.reportIncorrectType('string');
3871
}
@@ -42,9 +75,10 @@ export class Capture extends Matcher {
4275
* An error is generated if no value is captured or if the value is not a number.
4376
*/
4477
public asNumber(): number {
45-
this.checkNotNull();
46-
if (getType(this.value) === 'number') {
47-
return this.value;
78+
this.validate();
79+
const val = this._captured[this.idx];
80+
if (getType(val) === 'number') {
81+
return val;
4882
}
4983
this.reportIncorrectType('number');
5084
}
@@ -54,9 +88,10 @@ export class Capture extends Matcher {
5488
* An error is generated if no value is captured or if the value is not a boolean.
5589
*/
5690
public asBoolean(): boolean {
57-
this.checkNotNull();
58-
if (getType(this.value) === 'boolean') {
59-
return this.value;
91+
this.validate();
92+
const val = this._captured[this.idx];
93+
if (getType(val) === 'boolean') {
94+
return val;
6095
}
6196
this.reportIncorrectType('boolean');
6297
}
@@ -66,9 +101,10 @@ export class Capture extends Matcher {
66101
* An error is generated if no value is captured or if the value is not an array.
67102
*/
68103
public asArray(): any[] {
69-
this.checkNotNull();
70-
if (getType(this.value) === 'array') {
71-
return this.value;
104+
this.validate();
105+
const val = this._captured[this.idx];
106+
if (getType(val) === 'array') {
107+
return val;
72108
}
73109
this.reportIncorrectType('array');
74110
}
@@ -78,21 +114,22 @@ export class Capture extends Matcher {
78114
* An error is generated if no value is captured or if the value is not an object.
79115
*/
80116
public asObject(): { [key: string]: any } {
81-
this.checkNotNull();
82-
if (getType(this.value) === 'object') {
83-
return this.value;
117+
this.validate();
118+
const val = this._captured[this.idx];
119+
if (getType(val) === 'object') {
120+
return val;
84121
}
85122
this.reportIncorrectType('object');
86123
}
87124

88-
private checkNotNull(): void {
89-
if (this.value == null) {
125+
private validate(): void {
126+
if (this._captured.length === 0) {
90127
throw new Error('No value captured');
91128
}
92129
}
93130

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

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

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,20 @@ class LiteralMatch extends Matcher {
124124

125125
const result = new MatchResult(actual);
126126
if (typeof this.pattern !== typeof actual) {
127-
result.push(this, [], `Expected type ${typeof this.pattern} but received ${getType(actual)}`);
127+
result.recordFailure({
128+
matcher: this,
129+
path: [],
130+
message: `Expected type ${typeof this.pattern} but received ${getType(actual)}`,
131+
});
128132
return result;
129133
}
130134

131135
if (actual !== this.pattern) {
132-
result.push(this, [], `Expected ${this.pattern} but received ${actual}`);
136+
result.recordFailure({
137+
matcher: this,
138+
path: [],
139+
message: `Expected ${this.pattern} but received ${actual}`,
140+
});
133141
}
134142

135143
return result;
@@ -166,10 +174,18 @@ class ArrayMatch extends Matcher {
166174

167175
public test(actual: any): MatchResult {
168176
if (!Array.isArray(actual)) {
169-
return new MatchResult(actual).push(this, [], `Expected type array but received ${getType(actual)}`);
177+
return new MatchResult(actual).recordFailure({
178+
matcher: this,
179+
path: [],
180+
message: `Expected type array but received ${getType(actual)}`,
181+
});
170182
}
171183
if (!this.subsequence && this.pattern.length !== actual.length) {
172-
return new MatchResult(actual).push(this, [], `Expected array of length ${this.pattern.length} but received ${actual.length}`);
184+
return new MatchResult(actual).recordFailure({
185+
matcher: this,
186+
path: [],
187+
message: `Expected array of length ${this.pattern.length} but received ${actual.length}`,
188+
});
173189
}
174190

175191
let patternIdx = 0;
@@ -200,7 +216,11 @@ class ArrayMatch extends Matcher {
200216
for (; patternIdx < this.pattern.length; patternIdx++) {
201217
const pattern = this.pattern[patternIdx];
202218
const element = (Matcher.isMatcher(pattern) || typeof pattern === 'object') ? ' ' : ` [${pattern}] `;
203-
result.push(this, [], `Missing element${element}at pattern index ${patternIdx}`);
219+
result.recordFailure({
220+
matcher: this,
221+
path: [],
222+
message: `Missing element${element}at pattern index ${patternIdx}`,
223+
});
204224
}
205225

206226
return result;
@@ -236,21 +256,33 @@ class ObjectMatch extends Matcher {
236256

237257
public test(actual: any): MatchResult {
238258
if (typeof actual !== 'object' || Array.isArray(actual)) {
239-
return new MatchResult(actual).push(this, [], `Expected type object but received ${getType(actual)}`);
259+
return new MatchResult(actual).recordFailure({
260+
matcher: this,
261+
path: [],
262+
message: `Expected type object but received ${getType(actual)}`,
263+
});
240264
}
241265

242266
const result = new MatchResult(actual);
243267
if (!this.partial) {
244268
for (const a of Object.keys(actual)) {
245269
if (!(a in this.pattern)) {
246-
result.push(this, [`/${a}`], 'Unexpected key');
270+
result.recordFailure({
271+
matcher: this,
272+
path: [`/${a}`],
273+
message: 'Unexpected key',
274+
});
247275
}
248276
}
249277
}
250278

251279
for (const [patternKey, patternVal] of Object.entries(this.pattern)) {
252280
if (!(patternKey in actual) && !(patternVal instanceof AbsentMatch)) {
253-
result.push(this, [`/${patternKey}`], 'Missing key');
281+
result.recordFailure({
282+
matcher: this,
283+
path: [`/${patternKey}`],
284+
message: 'Missing key',
285+
});
254286
continue;
255287
}
256288
const matcher = Matcher.isMatcher(patternVal) ?
@@ -275,15 +307,23 @@ class SerializedJson extends Matcher {
275307
public test(actual: any): MatchResult {
276308
const result = new MatchResult(actual);
277309
if (getType(actual) !== 'string') {
278-
result.push(this, [], `Expected JSON as a string but found ${getType(actual)}`);
310+
result.recordFailure({
311+
matcher: this,
312+
path: [],
313+
message: `Expected JSON as a string but found ${getType(actual)}`,
314+
});
279315
return result;
280316
}
281317
let parsed;
282318
try {
283319
parsed = JSON.parse(actual);
284320
} catch (err) {
285321
if (err instanceof SyntaxError) {
286-
result.push(this, [], `Invalid JSON string: ${actual}`);
322+
result.recordFailure({
323+
matcher: this,
324+
path: [],
325+
message: `Invalid JSON string: ${actual}`,
326+
});
287327
return result;
288328
} else {
289329
throw err;
@@ -311,7 +351,11 @@ class NotMatch extends Matcher {
311351
const innerResult = matcher.test(actual);
312352
const result = new MatchResult(actual);
313353
if (innerResult.failCount === 0) {
314-
result.push(this, [], `Found unexpected match: ${JSON.stringify(actual, undefined, 2)}`);
354+
result.recordFailure({
355+
matcher: this,
356+
path: [],
357+
message: `Found unexpected match: ${JSON.stringify(actual, undefined, 2)}`,
358+
});
315359
}
316360
return result;
317361
}
@@ -325,7 +369,11 @@ class AnyMatch extends Matcher {
325369
public test(actual: any): MatchResult {
326370
const result = new MatchResult(actual);
327371
if (actual == null) {
328-
result.push(this, [], 'Expected a value but found none');
372+
result.recordFailure({
373+
matcher: this,
374+
path: [],
375+
message: 'Expected a value but found none',
376+
});
329377
}
330378
return result;
331379
}

0 commit comments

Comments
 (0)