Skip to content

Commit

Permalink
feat(assertions): major improvements to the capture feature (#17713)
Browse files Browse the repository at this point in the history
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*
  • Loading branch information
Niranjan Jayakar committed Dec 2, 2021
1 parent b284eba commit 9a67ce7
Show file tree
Hide file tree
Showing 9 changed files with 372 additions and 69 deletions.
64 changes: 64 additions & 0 deletions packages/@aws-cdk/assertions/README.md
Expand Up @@ -399,3 +399,67 @@ template.hasResourceProperties('Foo::Bar', {
fredCapture.asArray(); // returns ["Flob", "Cat"]
waldoCapture.asString(); // returns "Qux"
```

With captures, a nested pattern can also be specified, so that only targets
that match the nested pattern will be captured. This pattern can be literals or
further Matchers.

```ts
// Given a template -
// {
// "Resources": {
// "MyBar1": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": ["Flob", "Cat"],
// }
// }
// "MyBar2": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": ["Qix", "Qux"],
// }
// }
// }
// }

const capture = new Capture(Match.arrayWith(['Cat']));
template.hasResourceProperties('Foo::Bar', {
Fred: capture,
});

capture.asArray(); // returns ['Flob', 'Cat']
```

When multiple resources match the given condition, each `Capture` defined in
the condition will capture all matching values. They can be paged through using
the `next()` API. The following example illustrates this -

```ts
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": "Flob",
// }
// },
// "MyBaz": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": "Quib",
// }
// }
// }
// }

const fredCapture = new Capture();
template.hasResourceProperties('Foo::Bar', {
Fred: fredCapture,
});

fredCapture.asString(); // returns "Flob"
fredCapture.next(); // returns true
fredCapture.asString(); // returns "Quib"
```
85 changes: 61 additions & 24 deletions packages/@aws-cdk/assertions/lib/capture.ts
@@ -1,3 +1,4 @@
import { Match } from '.';
import { Matcher, MatchResult } from './matcher';
import { Type, getType } from './private/type';

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

constructor() {
/**
* Initialize a new capture
* @param pattern a nested pattern or Matcher.
* If a nested pattern is provided `objectLike()` matching is applied.
*/
constructor(private readonly pattern?: any) {
super();
this.name = 'Capture';
}

public test(actual: any): MatchResult {
this.value = actual;

const result = new MatchResult(actual);
if (actual == null) {
result.push(this, [], `Can only capture non-nullish values. Found ${actual}`);
return result.recordFailure({
matcher: this,
path: [],
message: `Can only capture non-nullish values. Found ${actual}`,
});
}

if (this.pattern !== undefined) {
const innerMatcher = Matcher.isMatcher(this.pattern) ? this.pattern : Match.objectLike(this.pattern);
const innerResult = innerMatcher.test(actual);
if (innerResult.hasFailed()) {
return innerResult;
}
}

result.recordCapture({ capture: this, value: actual });
return result;
}

/**
* When multiple results are captured, move the iterator to the next result.
* @returns true if another capture is present, false otherwise
*/
public next(): boolean {
if (this.idx < this._captured.length - 1) {
this.idx++;
return true;
}
return false;
}

/**
* Retrieve the captured value as a string.
* An error is generated if no value is captured or if the value is not a string.
*/
public asString(): string {
this.checkNotNull();
if (getType(this.value) === 'string') {
return this.value;
this.validate();
const val = this._captured[this.idx];
if (getType(val) === 'string') {
return val;
}
this.reportIncorrectType('string');
}
Expand All @@ -42,9 +75,10 @@ export class Capture extends Matcher {
* An error is generated if no value is captured or if the value is not a number.
*/
public asNumber(): number {
this.checkNotNull();
if (getType(this.value) === 'number') {
return this.value;
this.validate();
const val = this._captured[this.idx];
if (getType(val) === 'number') {
return val;
}
this.reportIncorrectType('number');
}
Expand All @@ -54,9 +88,10 @@ export class Capture extends Matcher {
* An error is generated if no value is captured or if the value is not a boolean.
*/
public asBoolean(): boolean {
this.checkNotNull();
if (getType(this.value) === 'boolean') {
return this.value;
this.validate();
const val = this._captured[this.idx];
if (getType(val) === 'boolean') {
return val;
}
this.reportIncorrectType('boolean');
}
Expand All @@ -66,9 +101,10 @@ export class Capture extends Matcher {
* An error is generated if no value is captured or if the value is not an array.
*/
public asArray(): any[] {
this.checkNotNull();
if (getType(this.value) === 'array') {
return this.value;
this.validate();
const val = this._captured[this.idx];
if (getType(val) === 'array') {
return val;
}
this.reportIncorrectType('array');
}
Expand All @@ -78,21 +114,22 @@ export class Capture extends Matcher {
* An error is generated if no value is captured or if the value is not an object.
*/
public asObject(): { [key: string]: any } {
this.checkNotNull();
if (getType(this.value) === 'object') {
return this.value;
this.validate();
const val = this._captured[this.idx];
if (getType(val) === 'object') {
return val;
}
this.reportIncorrectType('object');
}

private checkNotNull(): void {
if (this.value == null) {
private validate(): void {
if (this._captured.length === 0) {
throw new Error('No value captured');
}
}

private reportIncorrectType(expected: Type): never {
throw new Error(`Captured value is expected to be ${expected} but found ${getType(this.value)}. ` +
`Value is ${JSON.stringify(this.value, undefined, 2)}`);
throw new Error(`Captured value is expected to be ${expected} but found ${getType(this._captured[this.idx])}. ` +
`Value is ${JSON.stringify(this._captured[this.idx], undefined, 2)}`);
}
}
72 changes: 60 additions & 12 deletions packages/@aws-cdk/assertions/lib/match.ts
Expand Up @@ -124,12 +124,20 @@ class LiteralMatch extends Matcher {

const result = new MatchResult(actual);
if (typeof this.pattern !== typeof actual) {
result.push(this, [], `Expected type ${typeof this.pattern} but received ${getType(actual)}`);
result.recordFailure({
matcher: this,
path: [],
message: `Expected type ${typeof this.pattern} but received ${getType(actual)}`,
});
return result;
}

if (actual !== this.pattern) {
result.push(this, [], `Expected ${this.pattern} but received ${actual}`);
result.recordFailure({
matcher: this,
path: [],
message: `Expected ${this.pattern} but received ${actual}`,
});
}

return result;
Expand Down Expand Up @@ -166,10 +174,18 @@ class ArrayMatch extends Matcher {

public test(actual: any): MatchResult {
if (!Array.isArray(actual)) {
return new MatchResult(actual).push(this, [], `Expected type array but received ${getType(actual)}`);
return new MatchResult(actual).recordFailure({
matcher: this,
path: [],
message: `Expected type array but received ${getType(actual)}`,
});
}
if (!this.subsequence && this.pattern.length !== actual.length) {
return new MatchResult(actual).push(this, [], `Expected array of length ${this.pattern.length} but received ${actual.length}`);
return new MatchResult(actual).recordFailure({
matcher: this,
path: [],
message: `Expected array of length ${this.pattern.length} but received ${actual.length}`,
});
}

let patternIdx = 0;
Expand Down Expand Up @@ -200,7 +216,11 @@ class ArrayMatch extends Matcher {
for (; patternIdx < this.pattern.length; patternIdx++) {
const pattern = this.pattern[patternIdx];
const element = (Matcher.isMatcher(pattern) || typeof pattern === 'object') ? ' ' : ` [${pattern}] `;
result.push(this, [], `Missing element${element}at pattern index ${patternIdx}`);
result.recordFailure({
matcher: this,
path: [],
message: `Missing element${element}at pattern index ${patternIdx}`,
});
}

return result;
Expand Down Expand Up @@ -236,21 +256,33 @@ class ObjectMatch extends Matcher {

public test(actual: any): MatchResult {
if (typeof actual !== 'object' || Array.isArray(actual)) {
return new MatchResult(actual).push(this, [], `Expected type object but received ${getType(actual)}`);
return new MatchResult(actual).recordFailure({
matcher: this,
path: [],
message: `Expected type object but received ${getType(actual)}`,
});
}

const result = new MatchResult(actual);
if (!this.partial) {
for (const a of Object.keys(actual)) {
if (!(a in this.pattern)) {
result.push(this, [`/${a}`], 'Unexpected key');
result.recordFailure({
matcher: this,
path: [`/${a}`],
message: 'Unexpected key',
});
}
}
}

for (const [patternKey, patternVal] of Object.entries(this.pattern)) {
if (!(patternKey in actual) && !(patternVal instanceof AbsentMatch)) {
result.push(this, [`/${patternKey}`], 'Missing key');
result.recordFailure({
matcher: this,
path: [`/${patternKey}`],
message: 'Missing key',
});
continue;
}
const matcher = Matcher.isMatcher(patternVal) ?
Expand All @@ -275,15 +307,23 @@ class SerializedJson extends Matcher {
public test(actual: any): MatchResult {
const result = new MatchResult(actual);
if (getType(actual) !== 'string') {
result.push(this, [], `Expected JSON as a string but found ${getType(actual)}`);
result.recordFailure({
matcher: this,
path: [],
message: `Expected JSON as a string but found ${getType(actual)}`,
});
return result;
}
let parsed;
try {
parsed = JSON.parse(actual);
} catch (err) {
if (err instanceof SyntaxError) {
result.push(this, [], `Invalid JSON string: ${actual}`);
result.recordFailure({
matcher: this,
path: [],
message: `Invalid JSON string: ${actual}`,
});
return result;
} else {
throw err;
Expand Down Expand Up @@ -311,7 +351,11 @@ class NotMatch extends Matcher {
const innerResult = matcher.test(actual);
const result = new MatchResult(actual);
if (innerResult.failCount === 0) {
result.push(this, [], `Found unexpected match: ${JSON.stringify(actual, undefined, 2)}`);
result.recordFailure({
matcher: this,
path: [],
message: `Found unexpected match: ${JSON.stringify(actual, undefined, 2)}`,
});
}
return result;
}
Expand All @@ -325,7 +369,11 @@ class AnyMatch extends Matcher {
public test(actual: any): MatchResult {
const result = new MatchResult(actual);
if (actual == null) {
result.push(this, [], 'Expected a value but found none');
result.recordFailure({
matcher: this,
path: [],
message: 'Expected a value but found none',
});
}
return result;
}
Expand Down

0 comments on commit 9a67ce7

Please sign in to comment.