diff --git a/packages/@aws-cdk/assertions/README.md b/packages/@aws-cdk/assertions/README.md index 706d467eb3e59..1fc0bb28e0cd3 100644 --- a/packages/@aws-cdk/assertions/README.md +++ b/packages/@aws-cdk/assertions/README.md @@ -184,7 +184,9 @@ assert.hasResourceProperties('Foo::Bar', { The `Match.objectEquals()` API can be used to assert a target as a deep exact match. -In addition, the `Match.absentProperty()` can be used to specify that a specific +### Presence and Absence + +The `Match.absentProperty()` matcher can be used to specify that a specific property should not exist on the target. This can be used within `Match.objectLike()` or outside of any matchers. @@ -218,6 +220,42 @@ assert.hasResourceProperties('Foo::Bar', { }); ``` +The `Match.anyValue()` matcher can be used to specify that a specific value should be found +at the location. This matcher will fail if when the target location has null-ish values +(i.e., `null` or `undefined`). + +This matcher can be combined with any of the other matchers. + +```ts +// Given a template - +// { +// "Resources": { +// "MyBar": { +// "Type": "Foo::Bar", +// "Properties": { +// "Fred": { +// "Wobble": ["Flob", "Flib"], +// } +// } +// } +// } +// } + +// The following will NOT throw an assertion error +assert.hasResourceProperties('Foo::Bar', { + Fred: { + Wobble: [Match.anyValue(), "Flip"], + }, +}); + +// The following will throw an assertion error +assert.hasResourceProperties('Foo::Bar', { + Fred: { + Wimble: Match.anyValue(), + }, +}); +``` + ### Array Matchers The `Match.arrayWith()` API can be used to assert that the target is equal to or a subset @@ -283,6 +321,37 @@ assert.hasResourceProperties('Foo::Bar', Match.objectLike({ }}); ``` +## Capturing Values + +This 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. + +```ts +// Given a template - +// { +// "Resources": { +// "MyBar": { +// "Type": "Foo::Bar", +// "Properties": { +// "Fred": ["Flob", "Cat"], +// "Waldo": ["Qix", "Qux"], +// } +// } +// } +// } + +const fredCapture = new Capture(); +const waldoCapture = new Capture(); +assert.hasResourceProperties('Foo::Bar', { + Fred: fredCapture, + Waldo: ["Qix", waldoCapture], +}); + +fredCapture.asArray(); // returns ["Flob", "Cat"] +waldoCapture.asString(); // returns "Qux" +``` + ## Strongly typed languages Some of the APIs documented above, such as `templateMatches()` and diff --git a/packages/@aws-cdk/assertions/lib/capture.ts b/packages/@aws-cdk/assertions/lib/capture.ts new file mode 100644 index 0000000000000..c639dec79583f --- /dev/null +++ b/packages/@aws-cdk/assertions/lib/capture.ts @@ -0,0 +1,98 @@ +import { Matcher, MatchResult } from './matcher'; +import { Type, getType } from './private/type'; + +/** + * Capture values while matching templates. + * Using an instance of this class within a Matcher will capture the matching value. + * The `as*()` APIs on the instance can be used to get the captured value. + */ +export class Capture extends Matcher { + public readonly name: string; + private value: any = null; + + constructor() { + 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; + } + + /** + * 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.reportIncorrectType('string'); + } + + /** + * Retrieve the captured value as a number. + * 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.reportIncorrectType('number'); + } + + /** + * Retrieve the captured value as a boolean. + * 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.reportIncorrectType('boolean'); + } + + /** + * Retrieve the captured value as an array. + * 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.reportIncorrectType('array'); + } + + /** + * Retrieve the captured value as a JSON object. + * 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.reportIncorrectType('object'); + } + + private checkNotNull(): void { + if (this.value == null) { + 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)}`); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/lib/index.ts b/packages/@aws-cdk/assertions/lib/index.ts index 963039f921bc1..492fad1227af3 100644 --- a/packages/@aws-cdk/assertions/lib/index.ts +++ b/packages/@aws-cdk/assertions/lib/index.ts @@ -1,3 +1,4 @@ +export * from './capture'; export * from './template'; export * from './match'; export * from './matcher'; \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/lib/match.ts b/packages/@aws-cdk/assertions/lib/match.ts index 802acdc603e70..5c7e3fad8e90c 100644 --- a/packages/@aws-cdk/assertions/lib/match.ts +++ b/packages/@aws-cdk/assertions/lib/match.ts @@ -1,4 +1,5 @@ import { Matcher, MatchResult } from './matcher'; +import { getType } from './private/type'; import { ABSENT } from './vendored/assert'; /** @@ -63,6 +64,13 @@ export abstract class Match { public static not(pattern: any): Matcher { return new NotMatch('not', pattern); } + + /** + * Matches any non-null value at the target. + */ + public static anyValue(): Matcher { + return new AnyMatch('anyValue'); + } } /** @@ -141,7 +149,7 @@ interface ArrayMatchOptions { * Match class that matches arrays. */ class ArrayMatch extends Matcher { - private readonly partial: boolean; + private readonly subsequence: boolean; constructor( public readonly name: string, @@ -149,14 +157,14 @@ class ArrayMatch extends Matcher { options: ArrayMatchOptions = {}) { super(); - this.partial = options.subsequence ?? true; + this.subsequence = options.subsequence ?? true; } public test(actual: any): MatchResult { if (!Array.isArray(actual)) { return new MatchResult(actual).push(this, [], `Expected type array but received ${getType(actual)}`); } - if (!this.partial && this.pattern.length !== actual.length) { + 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}`); } @@ -166,10 +174,16 @@ class ArrayMatch extends Matcher { const result = new MatchResult(actual); while (patternIdx < this.pattern.length && actualIdx < actual.length) { const patternElement = this.pattern[patternIdx]; + const matcher = Matcher.isMatcher(patternElement) ? patternElement : new LiteralMatch(this.name, patternElement); + if (this.subsequence && matcher instanceof AnyMatch) { + // array subsequence matcher is not compatible with anyValue() matcher. They don't make sense to be used together. + throw new Error('The Matcher anyValue() cannot be nested within arrayWith()'); + } + const innerResult = matcher.test(actual[actualIdx]); - if (!this.partial || !innerResult.hasFailed()) { + if (!this.subsequence || !innerResult.hasFailed()) { result.compose(`[${actualIdx}]`, innerResult); patternIdx++; actualIdx++; @@ -271,6 +285,16 @@ class NotMatch extends Matcher { } } -function getType(obj: any): string { - return Array.isArray(obj) ? 'array' : typeof obj; +class AnyMatch extends Matcher { + constructor(public readonly name: string) { + super(); + } + + public test(actual: any): MatchResult { + const result = new MatchResult(actual); + if (actual == null) { + result.push(this, [], 'Expected a value but found none'); + } + return result; + } } \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/lib/matcher.ts b/packages/@aws-cdk/assertions/lib/matcher.ts index a3263d6f00829..fdf8954a90b93 100644 --- a/packages/@aws-cdk/assertions/lib/matcher.ts +++ b/packages/@aws-cdk/assertions/lib/matcher.ts @@ -17,6 +17,8 @@ export abstract class Matcher { /** * Test whether a target matches the provided pattern. + * Every Matcher must implement this method. + * This method will be invoked by the assertions framework. Do not call this method directly. * @param actual the target to match * @return the list of match failures. An empty array denotes a successful match. */ diff --git a/packages/@aws-cdk/assertions/lib/private/type.ts b/packages/@aws-cdk/assertions/lib/private/type.ts new file mode 100644 index 0000000000000..ffd894c576939 --- /dev/null +++ b/packages/@aws-cdk/assertions/lib/private/type.ts @@ -0,0 +1,5 @@ +export type Type = 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | 'array'; + +export function getType(obj: any): Type { + return Array.isArray(obj) ? 'array' : typeof obj; +} \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/test/capture.test.ts b/packages/@aws-cdk/assertions/test/capture.test.ts new file mode 100644 index 0000000000000..818592fa35baf --- /dev/null +++ b/packages/@aws-cdk/assertions/test/capture.test.ts @@ -0,0 +1,69 @@ +import { Capture, Match } from '../lib'; + +describe('Capture', () => { + test('uncaptured', () => { + const capture = new Capture(); + expect(() => capture.asString()).toThrow(/No value captured/); + }); + + test('nullish', () => { + const capture = new Capture(); + const matcher = Match.objectEquals({ foo: capture }); + + const result = matcher.test({ foo: null }); + expect(result.failCount).toEqual(1); + expect(result.toHumanStrings()[0]).toMatch(/Can only capture non-nullish values/); + }); + + test('asString()', () => { + const capture = new Capture(); + const matcher = Match.objectEquals({ foo: capture }); + + matcher.test({ foo: 'bar' }); + expect(capture.asString()).toEqual('bar'); + + matcher.test({ foo: 3 }); + expect(() => capture.asString()).toThrow(/expected to be string but found number/); + }); + + test('asNumber()', () => { + const capture = new Capture(); + const matcher = Match.objectEquals({ foo: capture }); + + matcher.test({ foo: 3 }); + expect(capture.asNumber()).toEqual(3); + + matcher.test({ foo: 'bar' }); + expect(() => capture.asNumber()).toThrow(/expected to be number but found string/); + }); + + test('asArray()', () => { + const capture = new Capture(); + const matcher = Match.objectEquals({ foo: capture }); + + matcher.test({ foo: ['bar'] }); + expect(capture.asArray()).toEqual(['bar']); + + matcher.test({ foo: 'bar' }); + expect(() => capture.asArray()).toThrow(/expected to be array but found string/); + }); + + test('asObject()', () => { + const capture = new Capture(); + const matcher = Match.objectEquals({ foo: capture }); + + matcher.test({ foo: { fred: 'waldo' } }); + expect(capture.asObject()).toEqual({ fred: 'waldo' }); + + matcher.test({ foo: 'bar' }); + expect(() => capture.asObject()).toThrow(/expected to be object but found string/); + }); + + test('nested within an array', () => { + const capture = new Capture(); + const matcher = Match.objectEquals({ foo: ['bar', capture] }); + + matcher.test({ foo: ['bar', 'baz'] }); + expect(capture.asString()).toEqual('baz'); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/test/match.test.ts b/packages/@aws-cdk/assertions/test/match.test.ts index b46eb0d53d204..268810857f9a8 100644 --- a/packages/@aws-cdk/assertions/test/match.test.ts +++ b/packages/@aws-cdk/assertions/test/match.test.ts @@ -128,6 +128,11 @@ describe('Matchers', () => { test('absent', () => { expect(() => Match.arrayWith([Match.absentProperty()]).test(['foo'])).toThrow(/absentProperty/); }); + + test('incompatible with anyValue', () => { + matcher = Match.arrayWith(['foo', Match.anyValue()]); + expect(() => matcher.test(['foo', 'bar'])).toThrow(/anyValue\(\) cannot be nested within arrayWith\(\)/); + }); }); describe('arrayEquals', () => { @@ -285,6 +290,39 @@ describe('Matchers', () => { }, [msg]); }); }); + + describe('anyValue()', () => { + let matcher: Matcher; + + test('simple', () => { + matcher = Match.anyValue(); + expectPass(matcher, 'foo'); + expectPass(matcher, 5); + expectPass(matcher, false); + expectPass(matcher, []); + expectPass(matcher, {}); + + expectFailure(matcher, null, ['Expected a value but found none']); + expectFailure(matcher, undefined, ['Expected a value but found none']); + }); + + test('nested in array', () => { + matcher = Match.arrayEquals(['foo', Match.anyValue(), 'bar']); + expectPass(matcher, ['foo', 'baz', 'bar']); + expectPass(matcher, ['foo', 3, 'bar']); + + expectFailure(matcher, ['foo', null, 'bar'], ['Expected a value but found none at [1]']); + }); + + test('nested in object', () => { + matcher = Match.objectLike({ foo: Match.anyValue() }); + expectPass(matcher, { foo: 'bar' }); + expectPass(matcher, { foo: [1, 2] }); + + expectFailure(matcher, { foo: null }, ['Expected a value but found none at /foo']); + expectFailure(matcher, {}, ['Missing key at /foo']); + }); + }); }); function expectPass(matcher: Matcher, target: any): void { diff --git a/packages/@aws-cdk/pipelines/package.json b/packages/@aws-cdk/pipelines/package.json index 5edf5a75d7f85..37605df5210d4 100644 --- a/packages/@aws-cdk/pipelines/package.json +++ b/packages/@aws-cdk/pipelines/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@aws-cdk/assert-internal": "0.0.0", + "@aws-cdk/assertions": "0.0.0", "@aws-cdk/aws-apigateway": "0.0.0", "@aws-cdk/aws-ecr-assets": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", diff --git a/packages/@aws-cdk/pipelines/test/blueprint/logicalid-stability.test.ts b/packages/@aws-cdk/pipelines/test/blueprint/logicalid-stability.test.ts index 319d25203c92b..52d63d00d64b3 100644 --- a/packages/@aws-cdk/pipelines/test/blueprint/logicalid-stability.test.ts +++ b/packages/@aws-cdk/pipelines/test/blueprint/logicalid-stability.test.ts @@ -1,4 +1,3 @@ -import '@aws-cdk/assert-internal/jest'; import { Stack } from '@aws-cdk/core'; import { mkdict } from '../../lib/private/javascript'; import { PIPELINE_ENV, TestApp, LegacyTestGitHubNpmPipeline, ModernTestGitHubNpmPipeline, MegaAssetsApp, stackTemplate } from '../testhelpers'; diff --git a/packages/@aws-cdk/pipelines/test/codepipeline/codepipeline-sources.test.ts b/packages/@aws-cdk/pipelines/test/codepipeline/codepipeline-sources.test.ts index 84aef10bb171c..46fb468c37623 100644 --- a/packages/@aws-cdk/pipelines/test/codepipeline/codepipeline-sources.test.ts +++ b/packages/@aws-cdk/pipelines/test/codepipeline/codepipeline-sources.test.ts @@ -1,5 +1,4 @@ -import { anything, arrayWith, Capture, objectLike } from '@aws-cdk/assert-internal'; -import '@aws-cdk/assert-internal/jest'; +import { Capture, Match, Template } from '@aws-cdk/assertions'; import * as ccommit from '@aws-cdk/aws-codecommit'; import { CodeCommitTrigger, GitHubTrigger } from '@aws-cdk/aws-codepipeline-actions'; import { AnyPrincipal, Role } from '@aws-cdk/aws-iam'; @@ -28,18 +27,18 @@ test('CodeCommit source handles tokenized names correctly', () => { input: cdkp.CodePipelineSource.codeCommit(repo, 'main'), }); - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Source', Actions: [ - objectLike({ - Configuration: objectLike({ - RepositoryName: { 'Fn::GetAtt': [anything(), 'Name'] }, + Match.objectLike({ + Configuration: Match.objectLike({ + RepositoryName: { 'Fn::GetAtt': [Match.anyValue(), 'Name'] }, }), - Name: { 'Fn::GetAtt': [anything(), 'Name'] }, + Name: { 'Fn::GetAtt': [Match.anyValue(), 'Name'] }, }), ], - }), + }]), }); }); @@ -58,20 +57,20 @@ test('CodeCommit source honors all valid properties', () => { }), }); - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Source', Actions: [ - objectLike({ - Configuration: objectLike({ + Match.objectLike({ + Configuration: Match.objectLike({ BranchName: 'main', PollForSourceChanges: true, OutputArtifactFormat: 'CODEBUILD_CLONE_REF', }), - RoleArn: { 'Fn::GetAtt': [anything(), 'Arn'] }, + RoleArn: { 'Fn::GetAtt': [Match.anyValue(), 'Arn'] }, }), ], - }), + }]), }); }); @@ -81,19 +80,19 @@ test('S3 source handles tokenized names correctly', () => { input: cdkp.CodePipelineSource.s3(buckit, 'thefile.zip'), }); - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Source', Actions: [ - objectLike({ - Configuration: objectLike({ - S3Bucket: { Ref: anything() }, + Match.objectLike({ + Configuration: Match.objectLike({ + S3Bucket: { Ref: Match.anyValue() }, S3ObjectKey: 'thefile.zip', }), - Name: { Ref: anything() }, + Name: { Ref: Match.anyValue() }, }), ], - }), + }]), }); }); @@ -105,12 +104,12 @@ test('GitHub source honors all valid properties', () => { }), }); - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Source', Actions: [ - objectLike({ - Configuration: objectLike({ + Match.objectLike({ + Configuration: Match.objectLike({ Owner: 'owner', Repo: 'repo', Branch: 'main', @@ -120,7 +119,7 @@ test('GitHub source honors all valid properties', () => { Name: 'owner_repo', }), ], - }), + }]), }); }); @@ -145,17 +144,17 @@ test('Dashes in repo names are removed from artifact names', () => { input: cdkp.CodePipelineSource.gitHub('owner/my-repo', 'main'), }); - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Source', Actions: [ - objectLike({ + Match.objectLike({ OutputArtifacts: [ { Name: 'owner_my_repo_Source' }, ], }), ], - }), + }]), }); }); @@ -164,19 +163,19 @@ test('artifact names are never longer than 128 characters', () => { input: cdkp.CodePipelineSource.gitHub('owner/' + 'my-repo'.repeat(100), 'main'), }); - const artifactId = Capture.aString(); - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + const artifactId = new Capture(); + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Source', Actions: [ - objectLike({ + Match.objectLike({ OutputArtifacts: [ - { Name: artifactId.capture() }, + { Name: artifactId }, ], }), ], - }), + }]), }); - expect(artifactId.capturedValue.length).toBeLessThanOrEqual(128); + expect(artifactId.asString().length).toBeLessThanOrEqual(128); }); \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/compliance/basic-behavior.test.ts b/packages/@aws-cdk/pipelines/test/compliance/basic-behavior.test.ts index 1248831737bdf..85d7d5911dd0a 100644 --- a/packages/@aws-cdk/pipelines/test/compliance/basic-behavior.test.ts +++ b/packages/@aws-cdk/pipelines/test/compliance/basic-behavior.test.ts @@ -1,11 +1,11 @@ /* eslint-disable import/no-extraneous-dependencies */ import * as fs from 'fs'; import * as path from 'path'; -import { arrayWith, Capture, objectLike, stringLike } from '@aws-cdk/assert-internal'; +import { Capture, Match, Template } from '@aws-cdk/assertions'; import '@aws-cdk/assert-internal/jest'; import { Stack, Stage, StageProps, Tags } from '@aws-cdk/core'; import { Construct } from 'constructs'; -import { behavior, LegacyTestGitHubNpmPipeline, OneStackApp, BucketStack, PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline } from '../testhelpers'; +import { behavior, LegacyTestGitHubNpmPipeline, OneStackApp, BucketStack, PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline, stringLike } from '../testhelpers'; let app: TestApp; let pipelineStack: Stack; @@ -37,20 +37,20 @@ behavior('stack templates in nested assemblies are correctly addressed', (suite) }); function THEN_codePipelineExpectation() { - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'App', - Actions: arrayWith( - objectLike({ + Actions: Match.arrayWith([ + Match.objectLike({ Name: stringLike('*Prepare'), - InputArtifacts: [objectLike({})], - Configuration: objectLike({ + InputArtifacts: [Match.objectLike({})], + Configuration: Match.objectLike({ StackName: 'App-Stack', TemplatePath: stringLike('*::assembly-App/*.template.json'), }), }), - ), - }), + ]), + }]), }); } }); @@ -94,27 +94,27 @@ behavior('overridden stack names are respected', (suite) => { }); function THEN_codePipelineExpectation() { - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith( + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([ { Name: 'App1', - Actions: arrayWith(objectLike({ + Actions: Match.arrayWith([Match.objectLike({ Name: stringLike('*Prepare'), - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'MyFancyStack', }), - })), + })]), }, { Name: 'App2', - Actions: arrayWith(objectLike({ + Actions: Match.arrayWith([Match.objectLike({ Name: stringLike('*Prepare'), - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'MyFancyStack', }), - })), + })]), }, - ), + ]), }); } }); @@ -154,17 +154,17 @@ behavior('changing CLI version leads to a different pipeline structure (restarti function THEN_codePipelineExpectation(stack2: Stack, stack3: Stack) { // THEN - const structure2 = Capture.anyType(); - const structure3 = Capture.anyType(); + const structure2 = new Capture(); + const structure3 = new Capture(); - expect(stack2).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: structure2.capture(), + Template.fromStack(stack2).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: structure2, }); - expect(stack3).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: structure3.capture(), + Template.fromStack(stack3).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: structure3, }); - expect(JSON.stringify(structure2.capturedValue)).not.toEqual(JSON.stringify(structure3.capturedValue)); + expect(JSON.stringify(structure2.asArray())).not.toEqual(JSON.stringify(structure3.asArray())); } }); @@ -190,24 +190,25 @@ behavior('tags get reflected in pipeline', (suite) => { function THEN_codePipelineExpectation() { // THEN - const templateConfig = Capture.aString(); - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + const templateConfig = new Capture(); + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'App', - Actions: arrayWith( - objectLike({ + Actions: Match.arrayWith([ + Match.objectLike({ Name: stringLike('*Prepare'), - InputArtifacts: [objectLike({})], - Configuration: objectLike({ + InputArtifacts: [Match.objectLike({})], + Configuration: Match.objectLike({ StackName: 'App-Stack', - TemplateConfiguration: templateConfig.capture(stringLike('*::assembly-App/*.template.*json')), + TemplateConfiguration: templateConfig, }), }), - ), - }), + ]), + }]), }); - const [, relConfigFile] = templateConfig.capturedValue.split('::'); + expect(templateConfig.asString()).toMatch(/::assembly-App\/.*\.template\..*json/); + const [, relConfigFile] = templateConfig.asString().split('::'); const absConfigFile = path.join(app.outdir, relConfigFile); const configFile = JSON.parse(fs.readFileSync(absConfigFile, { encoding: 'utf-8' })); expect(configFile).toEqual(expect.objectContaining({ diff --git a/packages/@aws-cdk/pipelines/test/compliance/environments.test.ts b/packages/@aws-cdk/pipelines/test/compliance/environments.test.ts index d30e5a423fcb3..6ab303e7df43d 100644 --- a/packages/@aws-cdk/pipelines/test/compliance/environments.test.ts +++ b/packages/@aws-cdk/pipelines/test/compliance/environments.test.ts @@ -1,8 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ -import { arrayWith, objectLike, stringLike } from '@aws-cdk/assert-internal'; -import '@aws-cdk/assert-internal/jest'; +import { Match, Template } from '@aws-cdk/assertions'; import { Stack } from '@aws-cdk/core'; -import { behavior, LegacyTestGitHubNpmPipeline, OneStackApp, PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline } from '../testhelpers'; +import { behavior, LegacyTestGitHubNpmPipeline, OneStackApp, PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline, stringLike } from '../testhelpers'; let app: TestApp; let pipelineStack: Stack; @@ -51,38 +50,38 @@ behavior('action has right settings for same-env deployment', (suite) => { function THEN_codePipelineExpection(roleArn: (x: string) => any) { // THEN: pipeline structure is correct - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Same', Actions: [ - objectLike({ + Match.objectLike({ Name: stringLike('*Prepare'), RoleArn: roleArn('deploy-role'), - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'Same-Stack', RoleArn: roleArn('cfn-exec-role'), }), }), - objectLike({ + Match.objectLike({ Name: stringLike('*Deploy'), RoleArn: roleArn('deploy-role'), - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'Same-Stack', }), }), ], - }), + }]), }); // THEN: artifact bucket can be read by deploy role - expect(pipelineStack).toHaveResourceLike('AWS::S3::BucketPolicy', { + Template.fromStack(pipelineStack).hasResourceProperties('AWS::S3::BucketPolicy', { PolicyDocument: { - Statement: arrayWith(objectLike({ + Statement: Match.arrayWith([Match.objectLike({ Action: ['s3:GetObject*', 's3:GetBucket*', 's3:List*'], Principal: { AWS: roleArn('deploy-role'), }, - })), + })]), }, }); } @@ -109,11 +108,11 @@ behavior('action has right settings for cross-account deployment', (suite) => { function THEN_codePipelineExpectation() { // THEN: Pipelien structure is correct - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'CrossAccount', Actions: [ - objectLike({ + Match.objectLike({ Name: stringLike('*Prepare'), RoleArn: { 'Fn::Join': ['', [ @@ -123,7 +122,7 @@ behavior('action has right settings for cross-account deployment', (suite) => { { Ref: 'AWS::Region' }, ]], }, - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'CrossAccount-Stack', RoleArn: { 'Fn::Join': ['', [ @@ -135,7 +134,7 @@ behavior('action has right settings for cross-account deployment', (suite) => { }, }), }), - objectLike({ + Match.objectLike({ Name: stringLike('*Deploy'), RoleArn: { 'Fn::Join': ['', [ @@ -145,18 +144,18 @@ behavior('action has right settings for cross-account deployment', (suite) => { { Ref: 'AWS::Region' }, ]], }, - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'CrossAccount-Stack', }), }), ], - }), + }]), }); // THEN: Artifact bucket can be read by deploy role - expect(pipelineStack).toHaveResourceLike('AWS::S3::BucketPolicy', { + Template.fromStack(pipelineStack).hasResourceProperties('AWS::S3::BucketPolicy', { PolicyDocument: { - Statement: arrayWith(objectLike({ + Statement: Match.arrayWith([Match.objectLike({ Action: ['s3:GetObject*', 's3:GetBucket*', 's3:List*'], Principal: { AWS: { @@ -168,7 +167,7 @@ behavior('action has right settings for cross-account deployment', (suite) => { ]], }, }, - })), + })]), }, }); } @@ -194,11 +193,11 @@ behavior('action has right settings for cross-region deployment', (suite) => { function THEN_codePipelineExpectation() { // THEN - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'CrossRegion', Actions: [ - objectLike({ + Match.objectLike({ Name: stringLike('*Prepare'), RoleArn: { 'Fn::Join': ['', [ @@ -212,7 +211,7 @@ behavior('action has right settings for cross-region deployment', (suite) => { ]], }, Region: 'elsewhere', - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'CrossRegion-Stack', RoleArn: { 'Fn::Join': ['', [ @@ -227,7 +226,7 @@ behavior('action has right settings for cross-region deployment', (suite) => { }, }), }), - objectLike({ + Match.objectLike({ Name: stringLike('*Deploy'), RoleArn: { 'Fn::Join': ['', [ @@ -241,12 +240,12 @@ behavior('action has right settings for cross-region deployment', (suite) => { ]], }, Region: 'elsewhere', - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'CrossRegion-Stack', }), }), ], - }), + }]), }); } }); @@ -282,11 +281,13 @@ behavior('action has right settings for cross-account/cross-region deployment', function THEN_codePipelineExpectations() { // THEN: pipeline structure must be correct - expect(app.stackArtifact(pipelineStack)).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + const stack = app.stackArtifact(pipelineStack); + expect(stack).toBeDefined(); + Template.fromStack(stack!).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'CrossBoth', Actions: [ - objectLike({ + Match.objectLike({ Name: stringLike('*Prepare'), RoleArn: { 'Fn::Join': ['', [ @@ -296,7 +297,7 @@ behavior('action has right settings for cross-account/cross-region deployment', ]], }, Region: 'elsewhere', - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'CrossBoth-Stack', RoleArn: { 'Fn::Join': ['', [ @@ -307,7 +308,7 @@ behavior('action has right settings for cross-account/cross-region deployment', }, }), }), - objectLike({ + Match.objectLike({ Name: stringLike('*Deploy'), RoleArn: { 'Fn::Join': ['', [ @@ -317,20 +318,21 @@ behavior('action has right settings for cross-account/cross-region deployment', ]], }, Region: 'elsewhere', - Configuration: objectLike({ + Configuration: Match.objectLike({ StackName: 'CrossBoth-Stack', }), }), ], - }), + }]), }); // THEN: artifact bucket can be read by deploy role - const supportStack = 'PipelineStack-support-elsewhere'; - expect(app.stackArtifact(supportStack)).toHaveResourceLike('AWS::S3::BucketPolicy', { + const supportStack = app.stackArtifact('PipelineStack-support-elsewhere'); + expect(supportStack).toBeDefined(); + Template.fromStack(supportStack!).hasResourceProperties('AWS::S3::BucketPolicy', { PolicyDocument: { - Statement: arrayWith(objectLike({ - Action: arrayWith('s3:GetObject*', 's3:GetBucket*', 's3:List*'), + Statement: Match.arrayWith([Match.objectLike({ + Action: Match.arrayWith(['s3:GetObject*', 's3:GetBucket*', 's3:List*']), Principal: { AWS: { 'Fn::Join': ['', [ @@ -340,15 +342,15 @@ behavior('action has right settings for cross-account/cross-region deployment', ]], }, }, - })), + })]), }, }); // And the key to go along with it - expect(app.stackArtifact(supportStack)).toHaveResourceLike('AWS::KMS::Key', { + Template.fromStack(supportStack!).hasResourceProperties('AWS::KMS::Key', { KeyPolicy: { - Statement: arrayWith(objectLike({ - Action: arrayWith('kms:Decrypt', 'kms:DescribeKey'), + Statement: Match.arrayWith([Match.objectLike({ + Action: Match.arrayWith(['kms:Decrypt', 'kms:DescribeKey']), Principal: { AWS: { 'Fn::Join': ['', [ @@ -358,7 +360,7 @@ behavior('action has right settings for cross-account/cross-region deployment', ]], }, }, - })), + })]), }, }); } diff --git a/packages/@aws-cdk/pipelines/test/compliance/escape-hatching.test.ts b/packages/@aws-cdk/pipelines/test/compliance/escape-hatching.test.ts index 3a8cb26d4d2fd..822a4f06f6164 100644 --- a/packages/@aws-cdk/pipelines/test/compliance/escape-hatching.test.ts +++ b/packages/@aws-cdk/pipelines/test/compliance/escape-hatching.test.ts @@ -1,5 +1,4 @@ -import { arrayWith, objectLike } from '@aws-cdk/assert-internal'; -import '@aws-cdk/assert-internal/jest'; +import { Match, Template } from '@aws-cdk/assertions'; import * as cp from '@aws-cdk/aws-codepipeline'; import * as cpa from '@aws-cdk/aws-codepipeline-actions'; import { SecretValue, Stack } from '@aws-cdk/core'; @@ -69,11 +68,11 @@ describe('with empty existing CodePipeline', () => { function THEN_codePipelineExpectation() { // THEN - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { Stages: [ - objectLike({ Name: 'Source' }), - objectLike({ Name: 'Build' }), - objectLike({ Name: 'UpdatePipeline' }), + Match.objectLike({ Name: 'Source' }), + Match.objectLike({ Name: 'Build' }), + Match.objectLike({ Name: 'UpdatePipeline' }), ], }); } @@ -118,11 +117,11 @@ describe('with custom Source stage in existing Pipeline', () => { function THEN_codePipelineExpectation() { // THEN - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { Stages: [ - objectLike({ Name: 'CustomSource' }), - objectLike({ Name: 'Build' }), - objectLike({ Name: 'UpdatePipeline' }), + Match.objectLike({ Name: 'CustomSource' }), + Match.objectLike({ Name: 'Build' }), + Match.objectLike({ Name: 'UpdatePipeline' }), ], }); } @@ -167,11 +166,11 @@ describe('with Source and Build stages in existing Pipeline', () => { function THEN_codePipelineExpectation() { // THEN - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { Stages: [ - objectLike({ Name: 'CustomSource' }), - objectLike({ Name: 'CustomBuild' }), - objectLike({ Name: 'UpdatePipeline' }), + Match.objectLike({ Name: 'CustomSource' }), + Match.objectLike({ Name: 'CustomBuild' }), + Match.objectLike({ Name: 'UpdatePipeline' }), ], }); } @@ -209,14 +208,14 @@ behavior('can add another action to an existing stage', (suite) => { }); function THEN_codePipelineExpectation() { - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { - Stages: arrayWith({ + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { + Stages: Match.arrayWith([{ Name: 'Source', Actions: [ - objectLike({ ActionTypeId: objectLike({ Provider: 'GitHub' }) }), - objectLike({ ActionTypeId: objectLike({ Provider: 'GitHub' }), Name: 'GitHub2' }), + Match.objectLike({ ActionTypeId: Match.objectLike({ Provider: 'GitHub' }) }), + Match.objectLike({ ActionTypeId: Match.objectLike({ Provider: 'GitHub' }), Name: 'GitHub2' }), ], - }), + }]), }); } }); @@ -264,12 +263,12 @@ behavior('assets stage inserted after existing pipeline actions', (suite) => { }); function THEN_codePipelineExpectation() { - expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodePipeline::Pipeline', { Stages: [ - objectLike({ Name: 'CustomSource' }), - objectLike({ Name: 'CustomBuild' }), - objectLike({ Name: 'Assets' }), - objectLike({ Name: 'App' }), + Match.objectLike({ Name: 'CustomSource' }), + Match.objectLike({ Name: 'CustomBuild' }), + Match.objectLike({ Name: 'Assets' }), + Match.objectLike({ Name: 'App' }), ], }); } diff --git a/packages/@aws-cdk/pipelines/test/docker-credentials.test.ts b/packages/@aws-cdk/pipelines/test/docker-credentials.test.ts index 9e3559242e04c..a2b5fc2c577dd 100644 --- a/packages/@aws-cdk/pipelines/test/docker-credentials.test.ts +++ b/packages/@aws-cdk/pipelines/test/docker-credentials.test.ts @@ -1,5 +1,4 @@ -import { arrayWith } from '@aws-cdk/assert-internal'; -import '@aws-cdk/assert-internal/jest'; +import { Match, Template } from '@aws-cdk/assertions'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecr from '@aws-cdk/aws-ecr'; import * as iam from '@aws-cdk/aws-iam'; @@ -44,7 +43,7 @@ describe('ExternalDockerCredential', () => { }); }); - test('maximmum example includes all expected properties', () => { + test('maximum example includes all expected properties', () => { const roleArn = 'arn:aws:iam::0123456789012:role/MyRole'; const creds = cdkp.DockerCredential.customRegistry('example.com', secret, { secretUsernameField: 'login', @@ -71,7 +70,7 @@ describe('ExternalDockerCredential', () => { const user = new iam.User(stack, 'User'); creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); - expect(stack).toHaveResource('AWS::IAM::Policy', { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: [ { @@ -93,7 +92,7 @@ describe('ExternalDockerCredential', () => { const user = new iam.User(stack, 'User'); creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); - expect(stack).toHaveResource('AWS::IAM::Policy', { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: [{ Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], @@ -104,7 +103,7 @@ describe('ExternalDockerCredential', () => { }, Roles: ['MyRole'], }); - expect(stack).toHaveResource('AWS::IAM::Policy', { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: [{ Action: 'sts:AssumeRole', @@ -127,7 +126,7 @@ describe('ExternalDockerCredential', () => { const user = new iam.User(stack, 'User'); creds.grantRead(user, cdkp.DockerCredentialUsage.SELF_UPDATE); - expect(stack).not.toHaveResource('AWS::IAM::Policy'); + Template.fromStack(stack).resourceCountIs('AWS::IAM::Policy', 0); }); }); @@ -193,7 +192,7 @@ describe('EcrDockerCredential', () => { const user = new iam.User(stack, 'User'); creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); - expect(stack).toHaveResource('AWS::IAM::Policy', { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: [{ Action: [ @@ -222,7 +221,7 @@ describe('EcrDockerCredential', () => { const user = new iam.User(stack, 'User'); creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); - expect(stack).toHaveResource('AWS::IAM::Policy', { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: [{ Action: [ @@ -242,7 +241,7 @@ describe('EcrDockerCredential', () => { }, Roles: ['MyRole'], }); - expect(stack).toHaveResource('AWS::IAM::Policy', { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: [{ Action: 'sts:AssumeRole', @@ -262,9 +261,9 @@ describe('EcrDockerCredential', () => { const user = new iam.User(stack, 'User'); creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); - expect(stack).toHaveResource('AWS::IAM::Policy', { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { - Statement: arrayWith({ + Statement: Match.arrayWith([{ Action: [ 'ecr:BatchCheckLayerAvailability', 'ecr:GetDownloadUrlForLayer', @@ -273,6 +272,11 @@ describe('EcrDockerCredential', () => { Effect: 'Allow', Resource: 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo', }, + { + Action: 'ecr:GetAuthorizationToken', + Effect: 'Allow', + Resource: '*', + }, { Action: [ 'ecr:BatchCheckLayerAvailability', @@ -281,12 +285,7 @@ describe('EcrDockerCredential', () => { ], Effect: 'Allow', Resource: 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo2', - }, - { - Action: 'ecr:GetAuthorizationToken', - Effect: 'Allow', - Resource: '*', - }), + }]), Version: '2012-10-17', }, Users: [{ Ref: 'User00B015A1' }], @@ -299,7 +298,7 @@ describe('EcrDockerCredential', () => { const user = new iam.User(stack, 'User'); creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); - expect(stack).not.toHaveResource('AWS::IAM::Policy'); + Template.fromStack(stack).resourceCountIs('AWS::IAM::Policy', 0); }); }); diff --git a/packages/@aws-cdk/pipelines/test/testhelpers/index.ts b/packages/@aws-cdk/pipelines/test/testhelpers/index.ts index 21ca108240f27..87a02ce0b6a66 100644 --- a/packages/@aws-cdk/pipelines/test/testhelpers/index.ts +++ b/packages/@aws-cdk/pipelines/test/testhelpers/index.ts @@ -2,4 +2,5 @@ export * from './compliance'; export * from './legacy-pipeline'; export * from './modern-pipeline'; export * from './test-app'; -export * from './testmatchers'; \ No newline at end of file +export * from './testmatchers'; +export * from './matchers'; \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/testhelpers/matchers.ts b/packages/@aws-cdk/pipelines/test/testhelpers/matchers.ts new file mode 100644 index 0000000000000..4ace0148c5eaa --- /dev/null +++ b/packages/@aws-cdk/pipelines/test/testhelpers/matchers.ts @@ -0,0 +1,32 @@ +import { Matcher, MatchResult } from '@aws-cdk/assertions'; + +export function stringLike(pattern: string) { + return new StringLike(pattern); +} + +// Reimplementation of +// https://github.com/aws/aws-cdk/blob/430f50a546e9c575f8cdbd259367e440d985e68f/packages/%40aws-cdk/assert-internal/lib/assertions/have-resource-matchers.ts#L244 +class StringLike extends Matcher { + public name = 'StringLike'; + + constructor(private readonly pattern: string) { + super(); + } + + public test(actual: any): MatchResult { + if (typeof(actual) !== 'string') { + throw new Error(`Expected string but found ${typeof(actual)}`); + } + const re = new RegExp(`^${this.pattern.split('*').map(escapeRegex).join('.*')}$`); + + const result = new MatchResult(actual); + if (!re.test(actual)) { + result.push(this, [], `Looking for string with pattern "${this.pattern}" but found "${actual}"`); + } + return result; + } +} + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} \ No newline at end of file