diff --git a/package.json b/package.json index d8d255c9..5df2c484 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,13 @@ "devDependencies": { "@aws-cdk/aws-apprunner-alpha": "2.20.0-alpha.0", "@pulumi/aws": "^6.32.0", - "@pulumi/docker": "^4.5.0", "@pulumi/aws-native": "0.121.0", + "@pulumi/docker": "^4.5.0", "@pulumi/pulumi": "^3.117.0", "@types/archiver": "^6.0.2", "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.2", + "@types/mock-fs": "^4.13.4", "@types/node": "^20.12.13", "aws-cdk-lib": "2.149.0", "constructs": "^10.0.111", @@ -40,6 +41,7 @@ "eslint-config-prettier": "^9.1.0", "jest": "^29.5.0", "jest-junit": "^15", + "mock-fs": "^5.3.0", "prettier": "^2.6.2", "ts-jest": "^29.1.0", "ts-node": "^10.9.2", diff --git a/tests/assembly/manifest.test.ts b/tests/assembly/manifest.test.ts new file mode 100644 index 00000000..8a0e4c9b --- /dev/null +++ b/tests/assembly/manifest.test.ts @@ -0,0 +1,185 @@ +import * as path from 'path'; +import * as mockfs from 'mock-fs'; +import { AssemblyManifestReader } from '../../src/assembly'; + +describe('cloud assembly manifest reader', () => { + const manifestFile = '/tmp/foo/bar/does/not/exist/manifest.json'; + const manifestStack = '/tmp/foo/bar/does/not/exist/test-stack.template.json'; + const manifestTree = '/tmp/foo/bar/does/not/exist/tree.json'; + const manifestAssets = '/tmp/foo/bar/does/not/exist/test-stack.assets.json'; + beforeEach(() => { + mockfs({ + // Recursively loads all node_modules + node_modules: mockfs.load(path.resolve(__dirname, '../../node_modules')), + [manifestAssets]: JSON.stringify({ + version: '36.0.0', + files: { + abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44: { + source: { + path: 'asset.abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44', + packaging: 'zip', + }, + destinations: { + 'current_account-current_region': { + bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', + objectKey: 'abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44.zip', + }, + }, + }, + cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579: { + source: { + path: 'stack.template.json', + packaging: 'file', + }, + destinations: { + 'current_account-current_region': { + bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', + objectKey: 'cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579.json', + }, + }, + }, + }, + dockerImages: {}, + }), + [manifestTree]: JSON.stringify({ + version: 'tree-0.1', + tree: { + id: 'App', + path: '', + children: { + 'test-stack': { + id: 'test-stack', + path: 'test-stack', + }, + }, + }, + }), + [manifestStack]: JSON.stringify({ + Resources: { + MyFunction1ServiceRole9852B06B: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + MyFunction12A744C2E: { + Type: 'AWS::Lambda::Function', + Properties: {}, + }, + }, + }), + [manifestFile]: JSON.stringify({ + version: '17.0.0', + artifacts: { + 'test-stack.assets': { + type: 'cdk:asset-manifest', + properties: { + file: 'test-stack.assets.json', + }, + }, + Tree: { + type: 'cdk:tree', + properties: { + file: 'tree.json', + }, + }, + 'test-stack': { + type: 'aws:cloudformation:stack', + environment: 'aws://unknown-account/unknown-region', + properties: { + templateFile: 'test-stack.template.json', + validateOnSynth: false, + }, + metadata: { + '/test-stack/MyFunction1/ServiceRole/Resource': [ + { + type: 'aws:cdk:logicalId', + data: 'MyFunction1ServiceRole9852B06B', + }, + ], + '/test-stack/MyFunction1/Resource': [ + { + type: 'aws:cdk:logicalId', + data: 'MyFunction12A744C2E', + }, + ], + }, + displayName: 'test-stack', + }, + }, + }), + }); + }); + + afterEach(() => { + mockfs.restore(); + }); + + test('can read manifest from file', () => { + expect(() => { + AssemblyManifestReader.fromFile(manifestFile); + }).not.toThrow(); + }); + + test('throws if manifest not found', () => { + expect(() => { + AssemblyManifestReader.fromFile('some-other-file'); + }).toThrow(/Cannot read manifest 'some-other-file':/); + }); + + test('throws if manifest file not found', () => { + expect(() => { + AssemblyManifestReader.fromPath('some-other-file'); + }).toThrow(/Cannot read manifest at 'some-other-file':/); + }); + + test('can read manifest file from path', () => { + expect(() => { + AssemblyManifestReader.fromPath(manifestFile); + }).not.toThrow(); + }); + + test('can read manifest from path', () => { + expect(() => { + AssemblyManifestReader.fromPath(path.dirname(manifestFile)); + }).not.toThrow(); + }); + + test('fromPath sets directory correctly', () => { + const manifest = AssemblyManifestReader.fromPath(path.dirname(manifestFile)); + expect(manifest.directory).toEqual('/tmp/foo/bar/does/not/exist'); + }); + + test('can get stacks from manifest', () => { + const manifest = AssemblyManifestReader.fromFile(manifestFile); + + expect(manifest.stackManifests[0]).toEqual({ + assets: expect.anything(), + constructTree: { id: 'test-stack', path: 'test-stack' }, + directory: '/tmp/foo/bar/does/not/exist', + id: 'test-stack', + metadata: { + 'test-stack/MyFunction1/Resource': 'MyFunction12A744C2E', + 'test-stack/MyFunction1/ServiceRole/Resource': 'MyFunction1ServiceRole9852B06B', + }, + outputs: undefined, + parameters: undefined, + resources: { + MyFunction12A744C2E: { Properties: {}, Type: 'AWS::Lambda::Function' }, + MyFunction1ServiceRole9852B06B: { Properties: {}, Type: 'AWS::IAM::Role' }, + }, + templatePath: 'test-stack.template.json', + }); + expect(manifest.stackManifests[0].fileAssets.length).toEqual(1); + expect(manifest.stackManifests[0].fileAssets[0]).toEqual({ + destination: { + bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', + objectKey: 'abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44.zip', + }, + id: { + assetId: 'abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44', + destinationId: 'current_account-current_region', + }, + packaging: 'zip', + path: '/tmp/foo/bar/does/not/exist/asset.abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44', + }); + }); +}); diff --git a/tests/assembly/stack.test.ts b/tests/assembly/stack.test.ts new file mode 100644 index 00000000..fd7d53ef --- /dev/null +++ b/tests/assembly/stack.test.ts @@ -0,0 +1,140 @@ +import { StackManifest } from '../../src/assembly'; + +describe('StackManifest', () => { + test('Throws if template has no resources', () => { + expect(() => { + new StackManifest('dir', 'id', 'path', {}, { id: 'id', path: 'path' }, {}, []); + }).toThrow(/CloudFormation template has no resources/); + }); + + test('get file assets', () => { + const stack = new StackManifest( + 'dir', + 'id', + 'path', + {}, + { id: 'id', path: 'path' }, + { + Resources: { SomeResource: { Type: 'sometype', Properties: {} } }, + }, + [ + { + id: { + assetId: 'asset', + destinationId: 'dest', + }, + type: 'file', + source: { path: 'somepath' }, + destination: { objectKey: 'abc', bucketName: 'bucket' }, + genericSource: {}, + genericDestination: {}, + }, + { + id: { + assetId: 'asset2', + destinationId: 'dest2', + }, + type: 'docker-image', + source: {}, + destination: { imageTag: 'tag', repositoryName: 'repop' }, + genericSource: {}, + genericDestination: {}, + }, + ], + ); + expect(stack.fileAssets.length).toEqual(1); + expect(stack.fileAssets[0]).toEqual({ + destination: { + bucketName: 'bucket', + objectKey: 'abc', + }, + id: { + assetId: 'asset', + destinationId: 'dest', + }, + packaging: 'file', + path: 'dir/somepath', + }); + }); + + test('can get logicalId for path', () => { + const stack = new StackManifest( + 'dir', + 'id', + 'path', + { + 'stack/bucket': 'SomeBucket', + }, + { + id: 'id', + path: 'path', + }, + { + Resources: { + SomeBucket: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + }, + }, + [], + ); + expect(stack.logicalIdForPath('stack/bucket')).toEqual('SomeBucket'); + }); + + test('can get resource for path', () => { + const stack = new StackManifest( + 'dir', + 'id', + 'path', + { + 'stack/bucket': 'SomeBucket', + }, + { + id: 'id', + path: 'path', + }, + { + Resources: { + SomeBucket: { + Type: 'AWS::S3::Bucket', + Properties: { Key: 'Value' }, + }, + }, + }, + [], + ); + expect(stack.resourceWithPath('stack/bucket')).toEqual({ + Type: 'AWS::S3::Bucket', + Properties: { Key: 'Value' }, + }); + }); + + test('can get resource for logicalId', () => { + const stack = new StackManifest( + 'dir', + 'id', + 'path', + { + 'stack/bucket': 'SomeBucket', + }, + { + id: 'id', + path: 'path', + }, + { + Resources: { + SomeBucket: { + Type: 'AWS::S3::Bucket', + Properties: { Key: 'Value' }, + }, + }, + }, + [], + ); + expect(stack.resourceWithLogicalId('SomeBucket')).toEqual({ + Type: 'AWS::S3::Bucket', + Properties: { Key: 'Value' }, + }); + }); +}); diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 295da401..85128db5 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -30,7 +30,6 @@ function setMocks() { return {}; }, newResource: (args: MockResourceArgs): { id: string; state: any } => { - console.error(args.type); switch (args.type) { case 'cdk:index:Stack': return { id: '', state: {} }; diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 9112bbbc..8432fc0f 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -12,241 +12,281 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as autoscaling from 'aws-cdk-lib/aws-autoscaling'; -import * as ec2 from 'aws-cdk-lib/aws-ec2'; -import * as ecs from 'aws-cdk-lib/aws-ecs'; -import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; -import * as iam from 'aws-cdk-lib/aws-iam'; -import * as s3 from 'aws-cdk-lib/aws-s3'; -import { Service, Source } from '@aws-cdk/aws-apprunner-alpha'; -import { App, Stack, Aspects } from 'aws-cdk-lib'; import { GraphBuilder, GraphNode } from '../src/graph'; -import { Construct } from 'constructs'; +import { StackManifest } from '../src/assembly'; -function testGraph(fn: (scope: Construct) => void, expected: (string | RegExp)[], done: any) { - const app = new App(); - const stack = new GraphTester(app, 'graphtest', fn); - app.synth(); - const sortedPaths = stack.nodes.map((n) => n.construct.node.path); - - expect(sortedPaths.length).toEqual(expected.length); - for (let i = 0; i < sortedPaths.length; i++) { - const [actualPath, expectedPath] = [sortedPaths[i], expected[i]]; - if (typeof expectedPath === 'string') { - expect(actualPath).toEqual(expectedPath); - } else { - expect(actualPath).toMatch(expectedPath); - } - } - - done(); -} - -class GraphTester extends Stack { - public nodes: GraphNode[] = []; - - constructor(scope: Construct, id: string, fn: (scope: Construct) => void) { - super(undefined, id); - - Aspects.of(scope).add({ - visit: (node) => { - if (node === scope) { - this.nodes = GraphBuilder.build(this); - } +const nodes = GraphBuilder.build( + new StackManifest( + 'test', + 'stack', + 'test/stack', + { + 'stack/example-bucket/Resource': 'examplebucketC9DFA43E', + 'stack/example-bucket/Policy/Resource': 'examplebucketPolicyE09B485E', + }, + { + path: 'stack', + id: 'stack', + children: { + 'example-bucket': { + id: 'example-bucket', + path: 'stack/example-bucket', + children: { + Resource: { + id: 'Resource', + path: 'stack/example-bucket/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucket', + version: '2.149.0', + }, + }, + Policy: { + id: 'Policy', + path: 'stack/example-bucket/Policy', + children: { + Resource: { + id: 'Resource', + path: 'stack/example-bucket/Policy/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::BucketPolicy', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucketPolicy', + version: '2.149.0', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.BucketPolicy', + version: '2.149.0', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.Bucket', + version: '2.149.0', + }, + }, }, - }); - - fn(this); - } -} + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + { + Resources: { + examplebucketC9DFA43E: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + examplebucketPolicyE09B485E: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'examplebucketC9DFA43E', + }, + }, + }, + }, + }, + [], + ), +); describe('Graph tests', () => { - test('Test sort for single resource', (done) => { - testGraph( - (stack) => { - new s3.Bucket(stack, 'MyFirstBucket', { versioned: true }); + test.each([ + [ + nodes, + 'stack', + { + construct: { + path: 'stack', + id: 'stack', + type: 'Stack', + parent: undefined, + }, + logicalId: undefined, + resource: undefined, + incomingEdges: ['stack/example-bucket'], + outgoingEdges: [], }, - ['graphtest', 'graphtest/MyFirstBucket', 'graphtest/MyFirstBucket/Resource'], - done, - ); - }); - - test('Test sort for ALB example', (done) => { - testGraph( - (stack) => { - const vpc = new ec2.Vpc(stack, 'VPC'); - - const asg = new autoscaling.AutoScalingGroup(stack, 'ASG', { - vpc, - instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MICRO), - machineImage: new ec2.AmazonLinuxImage(), - }); - - const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { - vpc, - internetFacing: true, - }); - - const listener = lb.addListener('Listener', { - port: 80, - }); - - listener.addTargets('Target', { - port: 80, - targets: [asg], - }); - - listener.connections.allowDefaultPortFromAnyIpv4('Open to the world'); - - asg.scaleOnRequestCount('AModestLoad', { - targetRequestsPerMinute: 60, - }); + ], + [ + nodes, + 'stack/example-bucket', + { + construct: { + parent: 'stack', + path: 'stack/example-bucket', + id: 'example-bucket', + type: 'Bucket', + }, + logicalId: undefined, + resource: undefined, + incomingEdges: ['stack/example-bucket/Resource', 'stack/example-bucket/Policy'], + outgoingEdges: ['stack'], }, - [ - 'graphtest', - 'graphtest/VPC', - 'graphtest/VPC/Resource', - 'graphtest/VPC/PublicSubnet1', - 'graphtest/VPC/PublicSubnet1/Subnet', - 'graphtest/VPC/PublicSubnet1/RouteTable', - 'graphtest/VPC/PublicSubnet1/RouteTableAssociation', - 'graphtest/VPC/IGW', - 'graphtest/VPC/PublicSubnet1/DefaultRoute', - 'graphtest/VPC/PublicSubnet1/EIP', - 'graphtest/VPC/PublicSubnet1/NATGateway', - 'graphtest/VPC/PublicSubnet2', - 'graphtest/VPC/PublicSubnet2/Subnet', - 'graphtest/VPC/PublicSubnet2/RouteTable', - 'graphtest/VPC/PublicSubnet2/RouteTableAssociation', - 'graphtest/VPC/PublicSubnet2/DefaultRoute', - 'graphtest/VPC/PublicSubnet2/EIP', - 'graphtest/VPC/PublicSubnet2/NATGateway', - 'graphtest/VPC/PrivateSubnet1', - 'graphtest/VPC/PrivateSubnet1/Subnet', - 'graphtest/VPC/PrivateSubnet1/RouteTable', - 'graphtest/VPC/PrivateSubnet1/RouteTableAssociation', - 'graphtest/VPC/PrivateSubnet1/DefaultRoute', - 'graphtest/VPC/PrivateSubnet2', - 'graphtest/VPC/PrivateSubnet2/Subnet', - 'graphtest/VPC/PrivateSubnet2/RouteTable', - 'graphtest/VPC/PrivateSubnet2/RouteTableAssociation', - 'graphtest/VPC/PrivateSubnet2/DefaultRoute', - 'graphtest/VPC/VPCGW', - 'graphtest/ASG', - 'graphtest/ASG/InstanceSecurityGroup', - 'graphtest/ASG/InstanceSecurityGroup/Resource', - 'graphtest/LB', - 'graphtest/LB/SecurityGroup', - 'graphtest/LB/SecurityGroup/Resource', - /graphtest\/ASG\/InstanceSecurityGroup\/from graphtestLBSecurityGroup[A-Z0-9]+:80/, - 'graphtest/ASG/InstanceRole', - 'graphtest/ASG/InstanceRole/Resource', - 'graphtest/ASG/InstanceProfile', - /graphtest\/SsmParameterValue:.*/, - 'graphtest/ASG/LaunchConfig', - 'graphtest/LB/Listener', - 'graphtest/LB/Listener/TargetGroup', - 'graphtest/LB/Listener/TargetGroup/Resource', - 'graphtest/ASG/ASG', - 'graphtest/ASG/ScalingPolicyAModestLoad', - 'graphtest/LB/Resource', - 'graphtest/LB/Listener/Resource', - 'graphtest/ASG/ScalingPolicyAModestLoad/Resource', - /graphtest\/LB\/SecurityGroup\/to graphtestASGInstanceSecurityGroup[A-Z0-9]+:80/, - ], - done, - ); - }); - - test('Test sort for appsvc example', (done) => { - testGraph( - (stack) => { - const cluster = new ecs.CfnCluster(stack, 'clusterstack'); - - const role = new iam.Role(stack, 'taskexecrole', { - assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), - }); - - new elbv2.CfnListener(stack, 'web', { - loadBalancerArn: 'dummy-alb-arn', - port: 80, - protocol: 'HTTP', - defaultActions: [ - { - type: 'forward', - targetGroupArn: 'dummy-target-group-arn', - }, - ], - }); - - const taskDefinition = new ecs.CfnTaskDefinition(stack, 'apptask', { - family: 'fargate-task-definition', - cpu: '256', - memory: '512', - networkMode: 'awsvpc', - requiresCompatibilities: ['FARGATE'], - executionRoleArn: role.roleArn, - containerDefinitions: [ - { - name: 'my-app', - image: 'nginx', - portMappings: [ - { - containerPort: 80, - hostPort: 80, - protocol: 'tcp', - }, - ], - }, - ], - }); - new ecs.CfnService(stack, 'appsvc', { - serviceName: 'app-svc-cloud-api', - cluster: cluster.attrArn, - desiredCount: 1, - launchType: 'FARGATE', - taskDefinition: taskDefinition.attrTaskDefinitionArn, - networkConfiguration: { - awsvpcConfiguration: { - assignPublicIp: 'ENABLED', - subnets: ['dummy-subnet-id-0', 'dummy-subnet-id-1'], - securityGroups: ['dummy-security-group-id'], + ], + [ + nodes, + 'stack/example-bucket/Resource', + { + construct: { + parent: 'example-bucket', + path: 'stack/example-bucket/Resource', + id: 'Resource', + type: 'Bucket', + }, + resource: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + logicalId: 'examplebucketC9DFA43E', + incomingEdges: ['stack/example-bucket/Policy/Resource'], + outgoingEdges: ['stack/example-bucket'], + }, + ], + [ + nodes, + 'stack/example-bucket/Policy', + { + construct: { + parent: 'example-bucket', + path: 'stack/example-bucket/Policy', + id: 'Policy', + type: 'BucketPolicy', + }, + logicalId: undefined, + resource: undefined, + incomingEdges: ['stack/example-bucket/Policy/Resource'], + outgoingEdges: ['stack/example-bucket'], + }, + ], + [ + nodes, + 'stack/example-bucket/Policy/Resource', + { + construct: { + parent: 'Policy', + path: 'stack/example-bucket/Policy/Resource', + id: 'Resource', + type: 'BucketPolicy', + }, + resource: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'examplebucketC9DFA43E', }, }, - loadBalancers: [ - { - targetGroupArn: 'dummy-target-group-arn', - containerName: 'my-app', - containerPort: 80, - }, - ], - }); + }, + logicalId: 'examplebucketPolicyE09B485E', + incomingEdges: [], + outgoingEdges: ['stack/example-bucket/Policy', 'stack/example-bucket/Resource'], }, - [ - 'graphtest', - 'graphtest/clusterstack', - 'graphtest/taskexecrole', - 'graphtest/taskexecrole/Resource', - 'graphtest/web', - 'graphtest/apptask', - 'graphtest/appsvc', - ], - done, - ); + ], + ])('Parses the graph correctly', (graph, path, expected) => { + const actual = graph.find((node) => node.construct.path === path); + expect(actual).toBeDefined(); + expect(actual!.logicalId).toEqual(expected.logicalId); + expect(actual!.resource).toEqual(expected.resource); + expect(actual!.construct.parent?.id).toEqual(expected.construct.parent); + expect(edgesToArray(actual!.incomingEdges)).toEqual(expected.incomingEdges); + expect(edgesToArray(actual!.outgoingEdges)).toEqual(expected.outgoingEdges); }); - test('Test sort for apprunner example', (done) => { - testGraph( - (stack) => { - const service = new Service(stack, 'service', { - source: Source.fromEcrPublic({ - imageConfiguration: { port: 8000 }, - imageIdentifier: 'public.ecr.aws/aws-containers/hello-app-runner:latest', - }), - }); - }, - ['graphtest', 'graphtest/service', 'graphtest/service/Resource'], - done, - ); + test.each([ + ['dependsOn', createStackManifest({}, {}, ['resource1'])], + [ + 'ref', + createStackManifest({ + SomeProp: { Ref: 'resource1' }, + }), + ], + [ + 'GetAtt', + createStackManifest({ + SomeProp: { 'Fn::GetAtt': ['resource1', 'Arn'] }, + }), + ], + [ + 'Sub-Ref', + createStackManifest({ + SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { Ref: 'resource1' } }] }, + }), + ], + [ + 'Sub-GetAtt', + createStackManifest({ + SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { 'Fn::GetAtt': ['resource1', 'Arn'] } }] }, + }), + ], + ])('adds edge for %s', (_name, stackManifest) => { + const graph = GraphBuilder.build(stackManifest); + expect(graph[1].construct.path).toEqual('stack/resource-1'); + expect(edgesToArray(graph[1].incomingEdges)).toEqual(['stack/resource-2']); + expect(edgesToArray(graph[1].outgoingEdges)).toEqual(['stack']); + expect(graph[2].construct.path).toEqual('stack/resource-2'); + expect(edgesToArray(graph[2].incomingEdges)).toEqual([]); + expect(edgesToArray(graph[2].outgoingEdges)).toEqual(['stack', 'stack/resource-1']); }); }); + +function edgesToArray(edges: Set): string[] { + return Array.from(edges).flatMap((value) => value.construct.path); +} + +function createStackManifest( + resource2Props: any, + resource1Props?: any, + resource2DependsOn?: string | string[], + resource1DependsOn?: string | string[], +): StackManifest { + return new StackManifest( + 'dir', + 'stack', + 'template', + { + 'stack/resource-1': 'resource1', + 'stack/resource-2': 'resource2', + }, + { + path: 'stack', + id: 'stack', + children: { + 'resource-1': { + id: 'resource-1', + path: 'stack/resource-1', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + 'resource-2': { + id: 'resource-2', + path: 'stack/resource-2', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + }, + }, + { + Resources: { + resource1: { + Type: 'AWS::S3::Bucket', + Properties: resource1Props ?? {}, + DependsOn: resource1DependsOn, + }, + resource2: { + Type: 'AWS::S3::Bucket', + Properties: resource2Props, + DependsOn: resource2DependsOn, + }, + }, + }, + [], + ); +} diff --git a/yarn.lock b/yarn.lock index f692911a..7c54b8c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1335,6 +1335,13 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== +"@types/mock-fs@^4.13.4": + version "4.13.4" + resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-4.13.4.tgz#e73edb4b4889d44d23f1ea02d6eebe50aa30b09a" + integrity sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@>=13.7.0", "@types/node@^20.12.13": version "20.14.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.11.tgz#09b300423343460455043ddd4d0ded6ac579b74b" @@ -3972,6 +3979,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mock-fs@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.3.0.tgz#7dfc95ce5528aff8e10fa117161b91d8129e0e9e" + integrity sha512-IMvz1X+RF7vf+ur7qUenXMR7/FSKSIqS3HqFHXcyNI7G0FbpFO8L5lfsUJhl+bhK1AiulVHWKUSxebWauPA+xQ== + module-details-from-path@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b"