From 450f7ca695f5f0bab758c31f3fd8390649adce51 Mon Sep 17 00:00:00 2001 From: Cory Hall <43035978+corymhall@users.noreply.github.com> Date: Fri, 10 Dec 2021 05:09:59 -0500 Subject: [PATCH 1/5] fix(cognito): remove invalid SES region check (#17868) When configuring the Cognito SES email integration we were performing a region check to make sure you were configuring SES in one of the 3 supported regions. This was based on the Cognito documentation [here](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html#user-pool-email-developer) which is not correct. This PR removes that check allowing CloudFormation to provide the validation. If a user provides an incorrect region the CloudFormation deployment will fail with a descriptive error message. fixes #17795 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-cognito/README.md | 2 +- .../aws-cognito/lib/user-pool-email.ts | 11 ----- .../aws-cognito/test/user-pool.test.ts | 40 ------------------- 3 files changed, 1 insertion(+), 52 deletions(-) diff --git a/packages/@aws-cdk/aws-cognito/README.md b/packages/@aws-cdk/aws-cognito/README.md index 57a442512d9f6..2cb86ba2885dd 100644 --- a/packages/@aws-cdk/aws-cognito/README.md +++ b/packages/@aws-cdk/aws-cognito/README.md @@ -349,7 +349,7 @@ new cognito.UserPool(this, 'myuserpool', { }); ``` -Sending emails through SES requires that SES be configured (as described above) in one of the regions - `us-east-1`, `us-west-1`, or `eu-west-1`. +Sending emails through SES requires that SES be configured (as described above) in a valid SES region. If the UserPool is being created in a different region, `sesRegion` must be used to specify the correct SES region. ```ts diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts index 2d5b8af06447f..398a1838f2128 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts @@ -2,11 +2,6 @@ import { Stack, Token } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { toASCII as punycodeEncode } from 'punycode/'; -/** - * The valid Amazon SES configuration regions - */ -const REGIONS = ['us-east-1', 'us-west-2', 'eu-west-1']; - /** * Configuration for Cognito sending emails via Amazon SES */ @@ -164,12 +159,6 @@ class SESEmail extends UserPoolEmail { throw new Error('Your stack region cannot be determined so "sesRegion" is required in SESOptions'); } - if (this.options.sesRegion && !REGIONS.includes(this.options.sesRegion)) { - throw new Error(`sesRegion must be one of 'us-east-1', 'us-west-2', 'eu-west-1'. received ${this.options.sesRegion}`); - } else if (!this.options.sesRegion && !REGIONS.includes(region)) { - throw new Error(`Your stack is in ${region}, which is not a SES Region. Please provide a valid value for 'sesRegion'`); - } - let from = this.options.fromEmail; if (this.options.fromName) { from = `${this.options.fromName} <${this.options.fromEmail}>`; diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts index df252d401a000..1efa42aeda79b 100644 --- a/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts +++ b/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts @@ -1701,48 +1701,8 @@ describe('User Pool', () => { }, }); - }); - test('email withSES invalid region throws error', () => { - // GIVEN - const stack = new Stack(undefined, undefined, { - env: { - region: 'us-east-2', - account: '11111111111', - }, - }); - - // WHEN - expect(() => new UserPool(stack, 'Pool', { - email: UserPoolEmail.withSES({ - fromEmail: 'mycustomemail@example.com', - fromName: 'My Custom Email', - replyTo: 'reply@example.com', - configurationSetName: 'default', - }), - })).toThrow(/Please provide a valid value/); - }); - test('email withSES invalid sesRegion throws error', () => { - // GIVEN - const stack = new Stack(undefined, undefined, { - env: { - account: '11111111111', - }, - }); - - // WHEN - expect(() => new UserPool(stack, 'Pool', { - email: UserPoolEmail.withSES({ - sesRegion: 'us-east-2', - fromEmail: 'mycustomemail@example.com', - fromName: 'My Custom Email', - replyTo: 'reply@example.com', - configurationSetName: 'default', - }), - })).toThrow(/sesRegion must be one of/); - - }); }); test('device tracking is configured correctly', () => { From ed4a4b4b70e72e3fa9a76af871d1d1e84447140a Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Fri, 10 Dec 2021 11:51:04 +0100 Subject: [PATCH 2/5] fix(iam): AWS Managed Policy ARNs are not deduped (#17623) Managed Policy ARNs should be deduped when added to a Role, otherwise the deployment is going to fail. Remove the unnecessary use of `Lazy.uncachedString` to make sure that the ARNs of two `ManagedPolicy.fromAwsManagedPolicyName()` policies are consistent. Fixes #17552. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-iam/lib/managed-policy.ts | 19 ++++++++----------- .../aws-iam/test/managed-policy.test.ts | 7 +++++++ packages/@aws-cdk/core/lib/arn.ts | 14 ++++++++++---- packages/@aws-cdk/core/test/arn.test.ts | 8 ++++++++ 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/@aws-cdk/aws-iam/lib/managed-policy.ts b/packages/@aws-cdk/aws-iam/lib/managed-policy.ts index f41ca17820c03..013542f4eef11 100644 --- a/packages/@aws-cdk/aws-iam/lib/managed-policy.ts +++ b/packages/@aws-cdk/aws-iam/lib/managed-policy.ts @@ -1,4 +1,4 @@ -import { ArnFormat, IResolveContext, Lazy, Resource, Stack } from '@aws-cdk/core'; +import { ArnFormat, Resource, Stack, Arn, Aws } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { IGroup } from './group'; import { CfnManagedPolicy } from './iam.generated'; @@ -156,16 +156,13 @@ export class ManagedPolicy extends Resource implements IManagedPolicy { */ public static fromAwsManagedPolicyName(managedPolicyName: string): IManagedPolicy { class AwsManagedPolicy implements IManagedPolicy { - public readonly managedPolicyArn = Lazy.uncachedString({ - produce(ctx: IResolveContext) { - return Stack.of(ctx.scope).formatArn({ - service: 'iam', - region: '', // no region for managed policy - account: 'aws', // the account for a managed policy is 'aws' - resource: 'policy', - resourceName: managedPolicyName, - }); - }, + public readonly managedPolicyArn = Arn.format({ + partition: Aws.PARTITION, + service: 'iam', + region: '', // no region for managed policy + account: 'aws', // the account for a managed policy is 'aws' + resource: 'policy', + resourceName: managedPolicyName, }); } return new AwsManagedPolicy(); diff --git a/packages/@aws-cdk/aws-iam/test/managed-policy.test.ts b/packages/@aws-cdk/aws-iam/test/managed-policy.test.ts index 3561ed4f79f19..9ed969b4a0afe 100644 --- a/packages/@aws-cdk/aws-iam/test/managed-policy.test.ts +++ b/packages/@aws-cdk/aws-iam/test/managed-policy.test.ts @@ -614,3 +614,10 @@ describe('managed policy', () => { }); }); }); + +test('ARN for two instances of the same AWS Managed Policy is the same', () => { + const mp1 = ManagedPolicy.fromAwsManagedPolicyName('foo/bar'); + const mp2 = ManagedPolicy.fromAwsManagedPolicyName('foo/bar'); + + expect(mp1.managedPolicyArn).toEqual(mp2.managedPolicyArn); +}); diff --git a/packages/@aws-cdk/core/lib/arn.ts b/packages/@aws-cdk/core/lib/arn.ts index ac10aef173535..a04f03baf466f 100644 --- a/packages/@aws-cdk/core/lib/arn.ts +++ b/packages/@aws-cdk/core/lib/arn.ts @@ -130,10 +130,16 @@ export class Arn { * the 'scope' is attached to. If all ARN pieces are supplied, the supplied scope * can be 'undefined'. */ - public static format(components: ArnComponents, stack: Stack): string { - const partition = components.partition ?? stack.partition; - const region = components.region ?? stack.region; - const account = components.account ?? stack.account; + public static format(components: ArnComponents, stack?: Stack): string { + const partition = components.partition ?? stack?.partition; + const region = components.region ?? stack?.region; + const account = components.account ?? stack?.account; + + // Catch both 'null' and 'undefined' + if (partition == null || region == null || account == null) { + throw new Error(`Arn.format: partition (${partition}), region (${region}), and account (${account}) must all be passed if stack is not passed.`); + } + const sep = components.sep ?? (components.arnFormat === ArnFormat.COLON_RESOURCE_NAME ? ':' : '/'); const values = [ diff --git a/packages/@aws-cdk/core/test/arn.test.ts b/packages/@aws-cdk/core/test/arn.test.ts index 28d0ebf22a446..6da31f11f9b22 100644 --- a/packages/@aws-cdk/core/test/arn.test.ts +++ b/packages/@aws-cdk/core/test/arn.test.ts @@ -20,6 +20,14 @@ describe('arn', () => { }); + test('cannot rely on defaults when stack not known', () => { + expect(() => + Arn.format({ + service: 'sqs', + resource: 'myqueuename', + })).toThrow(/must all be passed if stack is not/); + }); + test('create from components with specific values for the various components', () => { const stack = new Stack(); From 90eadcb752dc863a6d8e3b533666f007bc7a311f Mon Sep 17 00:00:00 2001 From: Cory Hall <43035978+corymhall@users.noreply.github.com> Date: Fri, 10 Dec 2021 06:31:57 -0500 Subject: [PATCH 3/5] chore: add integration test to test a construct with a builtin lambda (#17571) This adds a new integration test that deploys an s3.Bucket with autoDeleteObjects set to true. The autoDeleteObjects feature deploys a Nodejs Lambda backed Custom Resource. Lambda backed custom resources that are included as part of CDK constructs are compiled and bundled as part of the construct library. There are scenarios where this compiled source code (e.g. __entrypoint__.js) could be modified by the build process and cause the lambda execution to fail. This integration test should catch those instances. If the lambda function throws errors during execution the CustomResource will eventually fail. In the integration test this will result in a test timeout and failure. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/aws-cdk/test/integ/cli/app/app.js | 19 +++++++++++++++++-- .../aws-cdk/test/integ/cli/cli.integtest.ts | 15 ++++++++++++++- packages/aws-cdk/test/integ/helpers/cdk.ts | 5 +++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/aws-cdk/test/integ/cli/app/app.js b/packages/aws-cdk/test/integ/cli/app/app.js index f0e332eb351fa..834b6369079c9 100644 --- a/packages/aws-cdk/test/integ/cli/app/app.js +++ b/packages/aws-cdk/test/integ/cli/app/app.js @@ -4,6 +4,7 @@ var constructs = require('constructs'); if (process.env.PACKAGE_LAYOUT_VERSION === '1') { var cdk = require('@aws-cdk/core'); var ec2 = require('@aws-cdk/aws-ec2'); + var s3 = require('@aws-cdk/aws-s3'); var ssm = require('@aws-cdk/aws-ssm'); var iam = require('@aws-cdk/aws-iam'); var sns = require('@aws-cdk/aws-sns'); @@ -13,6 +14,7 @@ if (process.env.PACKAGE_LAYOUT_VERSION === '1') { var cdk = require('aws-cdk-lib'); var { aws_ec2: ec2, + aws_s3: s3, aws_ssm: ssm, aws_iam: iam, aws_sns: sns, @@ -109,7 +111,7 @@ class OutputsStack extends cdk.Stack { constructor(parent, id, props) { super(parent, id, props); - const topic = new sns.Topic(this, 'MyOutput', { + const topic = new sns.Topic(this, 'MyOutput', { topicName: `${cdk.Stack.of(this).stackName}MyTopic` }); @@ -299,6 +301,17 @@ class StageUsingContext extends cdk.Stage { } } +class BuiltinLambdaStack extends cdk.Stack { + constructor(parent, id, props) { + super(parent, id, props); + + new s3.Bucket(this, 'Bucket', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, // will deploy a Nodejs lambda backed custom resource + }); + } +} + const app = new cdk.App(); const defaultEnv = { @@ -339,7 +352,7 @@ switch (stackSet) { if (process.env.ENABLE_VPC_TESTING === 'DEFINE') new DefineVpcStack(app, `${stackPrefix}-define-vpc`, { env }); if (process.env.ENABLE_VPC_TESTING === 'IMPORT') - new ImportVpcStack(app, `${stackPrefix}-import-vpc`, { env }); + new ImportVpcStack(app, `${stackPrefix}-import-vpc`, { env }); } new ConditionalResourceStack(app, `${stackPrefix}-conditional-resource`) @@ -352,6 +365,8 @@ switch (stackSet) { }); new SomeStage(app, `${stackPrefix}-stage`); + + new BuiltinLambdaStack(app, `${stackPrefix}-builtin-lambda-function`); break; case 'stage-using-context': diff --git a/packages/aws-cdk/test/integ/cli/cli.integtest.ts b/packages/aws-cdk/test/integ/cli/cli.integtest.ts index 127734a136491..658bb7d355120 100644 --- a/packages/aws-cdk/test/integ/cli/cli.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/cli.integtest.ts @@ -19,6 +19,19 @@ integTest('VPC Lookup', withDefaultFixture(async (fixture) => { await fixture.cdkDeploy('import-vpc', { modEnv: { ENABLE_VPC_TESTING: 'IMPORT' } }); })); +// testing a construct with a builtin Nodejs Lambda Function. +// In this case we are testing the s3.Bucket construct with the +// autoDeleteObjects prop set to true, which creates a Lambda backed +// CustomResource. Since the compiled Lambda code (e.g. __entrypoint__.js) +// is bundled as part of the CDK package, we want to make sure we don't +// introduce changes to the compiled code that could prevent the Lambda from +// executing. If we do, this test will timeout and fail. +integTest('Construct with builtin Lambda function', withDefaultFixture(async (fixture) => { + await fixture.cdkDeploy('builtin-lambda-function'); + fixture.log('Setup complete!'); + await fixture.cdkDestroy('builtin-lambda-function'); +})); + integTest('Two ways of shoing the version', withDefaultFixture(async (fixture) => { const version1 = await fixture.cdk(['version'], { verbose: false }); const version2 = await fixture.cdk(['--version'], { verbose: false }); @@ -259,7 +272,7 @@ integTest('update to stack in ROLLBACK_COMPLETE state will delete stack and crea }); // THEN - expect (stackArn).not.toEqual(newStackArn); // new stack was created + expect(stackArn).not.toEqual(newStackArn); // new stack was created expect(newStackResponse.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE'); expect(newStackResponse.Stacks?.[0].Parameters).toContainEqual( { diff --git a/packages/aws-cdk/test/integ/helpers/cdk.ts b/packages/aws-cdk/test/integ/helpers/cdk.ts index 12e60efe243f7..cc04fba7394f4 100644 --- a/packages/aws-cdk/test/integ/helpers/cdk.ts +++ b/packages/aws-cdk/test/integ/helpers/cdk.ts @@ -88,6 +88,7 @@ export function withCdkApp(block: (context: '@aws-cdk/aws-ecr-assets': installationVersion, '@aws-cdk/aws-cloudformation': installationVersion, '@aws-cdk/aws-ec2': installationVersion, + '@aws-cdk/aws-s3': installationVersion, 'constructs': '^3', }); } else { @@ -283,7 +284,7 @@ export class TestFixture { this.output.write(`${s}\n`); } - public async shell(command: string[], options: Omit = {}): Promise { + public async shell(command: string[], options: Omit = {}): Promise { return shell(command, { output: this.output, cwd: this.integTestDir, @@ -701,4 +702,4 @@ const installNpm7 = memoize0(async (): Promise => { return path.join(installDir, 'node_modules', '.bin', 'npm'); }); -const ALREADY_BOOTSTRAPPED_IN_THIS_RUN = new Set(); \ No newline at end of file +const ALREADY_BOOTSTRAPPED_IN_THIS_RUN = new Set(); From 794e7cd63c83aac3c6ace933f4d953fea0b909ad Mon Sep 17 00:00:00 2001 From: Harry Guillermo Date: Fri, 10 Dec 2021 04:15:38 -0800 Subject: [PATCH 4/5] feat(ec2): add vpcName property to the VPC (#17940) **Issue** When creating a VPC you can not define the VPC name. The current way to set the name is using the `Tags` class **VPC Example:** ```javascript const vpc = new ec2.Vpc(this, 'vpc-id', { maxAzs: 2, subnetConfiguration: [ { name: 'private-subnet-1', subnetType: ec2.SubnetType.PRIVATE, cidrMask: 24, }, { name: 'public-subnet-1', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24, }, ] }); cdk.Tags.of(vpc).add('Name', 'CustomVPCName'); ``` **Proposal:** ```javascript const vpc = new ec2.Vpc(this, 'vpc-id', { maxAzs: 2, subnetConfiguration: [ { name: 'private-subnet-1', subnetType: ec2.SubnetType.PRIVATE, cidrMask: 24, }, { name: 'public-subnet-1', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24, mapPublicIpOnLaunch: false, // or true }, ], vpcName: 'CustomVPCName', }); ``` *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-ec2/lib/vpc.ts | 9 +++- packages/@aws-cdk/aws-ec2/test/vpc.test.ts | 51 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index 14eebaaee45c0..a9033ef8da94d 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -953,6 +953,13 @@ export interface VpcProps { * @default - No flow logs. */ readonly flowLogs?: { [id: string]: FlowLogOptions } + + /** + * The VPC name. + * + * @default this.node.path + */ + readonly vpcName?: string; } /** @@ -1298,7 +1305,7 @@ export class Vpc extends VpcBase { this.vpcDefaultSecurityGroup = this.resource.attrDefaultSecurityGroup; this.vpcIpv6CidrBlocks = this.resource.attrIpv6CidrBlocks; - Tags.of(this).add(NAME_TAG, this.node.path); + Tags.of(this).add(NAME_TAG, props.vpcName || this.node.path); this.availabilityZones = stack.availabilityZones; diff --git a/packages/@aws-cdk/aws-ec2/test/vpc.test.ts b/packages/@aws-cdk/aws-ec2/test/vpc.test.ts index 53942f9199a50..1ef3e8094a5e7 100644 --- a/packages/@aws-cdk/aws-ec2/test/vpc.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/vpc.test.ts @@ -510,6 +510,55 @@ describe('vpc', () => { }); }).toThrow(/subnet cannot include mapPublicIpOnLaunch parameter/); }); + test('verify the Default VPC name', () => { + const stack = getTestStack(); + const tagName = { Key: 'Name', Value: `${stack.node.path}/VPC` }; + new Vpc(stack, 'VPC', { + maxAzs: 1, + subnetConfiguration: [ + { + name: 'public', + subnetType: SubnetType.PUBLIC, + }, + { + name: 'private', + subnetType: SubnetType.PRIVATE_WITH_NAT, + }, + ], + }); + expect(stack).toCountResources('AWS::EC2::Subnet', 2); + expect(stack).toHaveResource('AWS::EC2::NatGateway'); + expect(stack).toHaveResource('AWS::EC2::Subnet', { + MapPublicIpOnLaunch: true, + }); + expect(stack).toHaveResource('AWS::EC2::VPC', hasTags([tagName])); + }); + test('verify the assigned VPC name passing the "vpcName" prop', () => { + const stack = getTestStack(); + const tagNameDefault = { Key: 'Name', Value: `${stack.node.path}/VPC` }; + const tagName = { Key: 'Name', Value: 'CustomVPCName' }; + new Vpc(stack, 'VPC', { + maxAzs: 1, + subnetConfiguration: [ + { + name: 'public', + subnetType: SubnetType.PUBLIC, + }, + { + name: 'private', + subnetType: SubnetType.PRIVATE_WITH_NAT, + }, + ], + vpcName: 'CustomVPCName', + }); + expect(stack).toCountResources('AWS::EC2::Subnet', 2); + expect(stack).toHaveResource('AWS::EC2::NatGateway'); + expect(stack).toHaveResource('AWS::EC2::Subnet', { + MapPublicIpOnLaunch: true, + }); + expect(stack).not.toHaveResource('AWS::EC2::VPC', hasTags([tagNameDefault])); + expect(stack).toHaveResource('AWS::EC2::VPC', hasTags([tagName])); + }); test('maxAZs defaults to 3 if unset', () => { const stack = getTestStack(); new Vpc(stack, 'VPC'); @@ -524,8 +573,6 @@ describe('vpc', () => { DestinationCidrBlock: '0.0.0.0/0', NatGatewayId: {}, }); - - }); test('with maxAZs set to 2', () => { From f02fcb4cf49e6d34f0038c4baf120ccc8dff2abe Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Fri, 10 Dec 2021 13:00:28 +0000 Subject: [PATCH 5/5] fix(aws-cdk-migration): Construct imports not rewritten (#17931) The `rewrite-imports-v2` tool is used to rewrite imports from CDK v1 apps and libraries to CDK v2 compliant imports. The initial launch of this tool focused solely on the conversion of CDKv1 to CDKv2 imports, but ignored the complexity of 'constructs` now being used as its own independent library and the lack of the Construct compatibility layer from v2. This fix introduces rewrites for Constructs. All `IConstruct` and `Construct` imports will be converted from `@aws-cdk/core` to `constructs`, and any qualified references (e.g., `cdk.Construct`) will be renamed as well (e.g., `constructs.Construct`). Imports of the construct library will be added as needed. fixes #17826 _Implementation note:_ Apologies for the diff. The best way to be able to recursively visit the tree involved converting the existing, simple `ts.visitNode()` approach to a `TransformerFactory`-based approach so `ts.visitEachChild()` could be used. This required a few method moves and the creation of a class to hold some context. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../bin/rewrite-imports-v2.ts | 4 +- packages/aws-cdk-migration/lib/rewrite.ts | 302 +++++++++++++++--- .../aws-cdk-migration/test/rewrite.test.ts | 207 +++++++++++- 3 files changed, 449 insertions(+), 64 deletions(-) diff --git a/packages/aws-cdk-migration/bin/rewrite-imports-v2.ts b/packages/aws-cdk-migration/bin/rewrite-imports-v2.ts index 13eb72f0f3eea..3b56cd569e1be 100644 --- a/packages/aws-cdk-migration/bin/rewrite-imports-v2.ts +++ b/packages/aws-cdk-migration/bin/rewrite-imports-v2.ts @@ -23,7 +23,9 @@ async function main() { const files = await glob(arg, { ignore, matchBase: true }); for (const file of files) { const input = await fs.promises.readFile(file, { encoding: 'utf8' }); - const output = rewriteMonoPackageImports(input, 'aws-cdk-lib', file); + const output = rewriteMonoPackageImports(input, 'aws-cdk-lib', file, { + rewriteConstructsImports: true, + }); if (output.trim() !== input.trim()) { await fs.promises.writeFile(file, output); } diff --git a/packages/aws-cdk-migration/lib/rewrite.ts b/packages/aws-cdk-migration/lib/rewrite.ts index a902318b0c629..8f875f932249b 100644 --- a/packages/aws-cdk-migration/lib/rewrite.ts +++ b/packages/aws-cdk-migration/lib/rewrite.ts @@ -23,6 +23,13 @@ export interface RewriteOptions { * The unscoped name of the package, e.g. 'aws-kinesisfirehose'. */ readonly packageUnscopedName?: string; + + /** + * When true, imports to known types from the 'constructs' library will be rewritten + * to explicitly import from 'constructs', rather than '@aws-cdk/core'. + * @default false + */ + readonly rewriteConstructsImports?: boolean; } /** @@ -48,7 +55,12 @@ export interface RewriteOptions { * @returns the updated source code. */ export function rewriteMonoPackageImports(sourceText: string, libName: string, fileName: string = 'index.ts', options: RewriteOptions = {}): string { - return rewriteImports(sourceText, (modPath, importedElements) => updatedExternalLocation(modPath, libName, options, importedElements), fileName); + return rewriteImports( + sourceText, + (modPath, importedElements) => updatedExternalLocation(modPath, libName, options, importedElements), + fileName, + options.rewriteConstructsImports, + ); } /** @@ -76,7 +88,12 @@ export function rewriteMonoPackageImports(sourceText: string, libName: string, f export function rewriteReadmeImports(sourceText: string, libName: string, fileName: string = 'index.ts', options: RewriteOptions = {}): string { return sourceText.replace(/(```(?:ts|typescript|text)[^\n]*\n)(.*?)(\n\s*```)/gs, (_m, prefix, body, suffix) => { return prefix + - rewriteImports(body, (modPath, importedElements) => updatedExternalLocation(modPath, libName, options, importedElements), fileName) + + rewriteImports( + body, + (modPath, importedElements) => updatedExternalLocation(modPath, libName, options, importedElements), + fileName, + options.rewriteConstructsImports, + ) + suffix; }); } @@ -107,79 +124,258 @@ export function rewriteImports( sourceText: string, updatedLocation: (modulePath: string, importedElements?: ts.NodeArray) => string | undefined, fileName: string = 'index.ts', + rewriteConstructsImports: boolean = false, ): string { - const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.ES2018); + const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.ES2018, true); + const rewriter = new ImportRewriter(sourceFile, updatedLocation, rewriteConstructsImports); + ts.transform(sourceFile, [rewriter.rewriteTransformer()]); + return rewriter.rewriteImports(); +} + +class ImportRewriter { + private static CONSTRUCTS_TYPES = ['Construct', 'IConstruct']; + + private readonly replacements = new Array<{ original: ts.Node, updatedLocation: string, quoted: boolean }>(); + // Constructs rewrites + private readonly constructsNamedImports: Set = new Set(); + private readonly constructsId = 'constructs'; + private firstImportNode?: ts.Node; + private constructsNamespaceImportRequired: boolean = false; + + public constructor( + private readonly sourceFile: ts.SourceFile, + private readonly updatedLocation: (modulePath: string, importedElements?: ts.NodeArray) => string | undefined, + private readonly rewriteConstructsImports: boolean, + ) { } - const replacements = new Array<{ original: ts.Node, updatedLocation: string }>(); + public rewriteTransformer(): ts.TransformerFactory { + const coreNamespaceImports: Set = new Set(); - const visitor = (node: T): ts.VisitResult => { - const moduleSpecifier = getModuleSpecifier(node); - const newTarget = moduleSpecifier && updatedLocation(moduleSpecifier.text, getImportedElements(node)); + return (context) => { + return (sourceFile) => { + const visitor = (node: T): ts.VisitResult => { + const moduleSpecifier = getModuleSpecifier(node); + if (moduleSpecifier) { + return this.visitImportNode(node, coreNamespaceImports, moduleSpecifier); + } - if (moduleSpecifier != null && newTarget != null) { - replacements.push({ original: moduleSpecifier, updatedLocation: newTarget }); + // Rewrite any access or type references with a format `foo.Construct`, + // where `foo` matches the name of a namespace import for '@aws-cdk/core' + // Simple identifiers (e.g., readonly foo: Construct) do not need to be written, + // only qualified identifiers (e.g., cdk.Construct). + if (ts.isIdentifier(node) && ImportRewriter.CONSTRUCTS_TYPES.includes(node.text)) { + if (ts.isPropertyAccessExpression(node.parent) + && ts.isIdentifier(node.parent.expression) + && coreNamespaceImports.has(node.parent.expression.text)) { + this.replacements.push({ original: node.parent, updatedLocation: `${this.constructsId}.${node.text}`, quoted: false }); + this.constructsNamespaceImportRequired = true; + } else if (ts.isQualifiedName(node.parent) + && ts.isIdentifier(node.parent.left) + && coreNamespaceImports.has(node.parent.left.text)) { + this.replacements.push({ original: node.parent, updatedLocation: `${this.constructsId}.${node.text}`, quoted: false }); + this.constructsNamespaceImportRequired = true; + } + } + + return ts.visitEachChild(node, visitor, context); + }; + + return ts.visitNode(sourceFile, visitor); + }; + }; + } + + /** + * Visit import nodes where a module specifier of some kind has been found. + * + * For most nodes, this simply involves rewritting the location of the module via `this.updatedLocation`. + * + * Assumes the current node is an import (of some type) that imports '@aws-cdk/core'. + * + * The following import types are suported: + * - import * as core1 from '@aws-cdk/core'; + * - import core2 = require('@aws-cdk/core'); + * - import { Type1, Type2 as CoreType2 } from '@aws-cdk/core'; + * - import { Type1, Type2 as CoreType2 } = require('@aws-cdk/core'); + * + * For all namespace imports, capture the namespace used so any references later can be updated. + * For example, 'core1.Construct' needs to be renamed to 'constructs.Construct'. + * For all named imports: + * - If all named imports are constructs types, simply rename the import from core to constructs. + * - If there's a split, the constructs types are removed and captured for later to go into a new import. + * + * @returns true iff all other transforms should be skipped for this node. + */ + private visitImportNode(node: T, coreNamespaceImports: Set, moduleSpecifier: ts.StringLiteral) { + // Used later for constructs imports generation, to mark location and get indentation + if (!this.firstImportNode) { this.firstImportNode = node; } + + // Special-case @aws-cdk/core for the case of constructs imports. + if (this.rewriteConstructsImports && moduleSpecifier.text === '@aws-cdk/core') { + if (ts.isImportEqualsDeclaration(node)) { + // import core = require('@aws-cdk/core'); + coreNamespaceImports.add(node.name.text); + } else if (ts.isImportDeclaration(node) && node.importClause?.namedBindings) { + const bindings = node.importClause?.namedBindings; + if (ts.isNamespaceImport(bindings)) { + // import * as core from '@aws-cdk/core'; + coreNamespaceImports.add(bindings.name.text); + } else if (ts.isNamedImports(bindings)) { + // import { Type1, Type2 as CoreType2 } from '@aws-cdk/core'; + // import { Type1, Type2 as CoreType2 } = require('@aws-cdk/core'); + + // Segment the types into core vs construct types + const constructsImports: ts.ImportSpecifier[] = []; + const coreImports: ts.ImportSpecifier[] = []; + bindings.elements.forEach((e) => { + if (ImportRewriter.CONSTRUCTS_TYPES.includes(e.name.text) || + (e.propertyName && ImportRewriter.CONSTRUCTS_TYPES.includes(e.propertyName.text))) { + constructsImports.push(e); + } else { + coreImports.push(e); + } + }); + + // Three cases: + // 1. There are no constructs imports. No special-casing to do. + // 2. There are ONLY constructs imports. The whole import can be replaced. + // 3. There is a mix. We must remove the constructs imports, and add them to a dedicated line. + if (constructsImports.length > 0) { + if (coreImports.length === 0) { + // Rewrite the module to constructs, skipping the normal updateLocation replacement. + this.replacements.push({ original: moduleSpecifier, updatedLocation: this.constructsId, quoted: true }); + return node; + } else { + // Track these named imports to add to a dedicated import statement later. + constructsImports.forEach((i) => this.constructsNamedImports.add(i)); + + // This replaces the interior of the import statement, between the braces: + // import { Stack as CdkStack, StackProps } ... + const coreBindings = ' ' + coreImports.map((e) => e.getText()).join(', ') + ' '; + this.replacements.push({ original: bindings, updatedLocation: coreBindings, quoted: true }); + } + } + } + } } + const newTarget = this.updatedLocation(moduleSpecifier.text, getImportedElements(node)); + if (newTarget != null) { + this.replacements.push({ original: moduleSpecifier, updatedLocation: newTarget, quoted: true }); + } return node; - }; + } - sourceFile.statements.forEach(node => ts.visitNode(node, visitor)); + /** + * Rewrites the imports -- and possibly some qualified identifiers -- in the source file, + * based on the replacement information gathered via transforming the source through `rewriteTransformer()`. + */ + public rewriteImports(): string { + let updatedSourceText = this.sourceFile.text; + // Applying replacements in reverse order, so node positions remain valid. + const sortedReplacements = this.replacements.sort( + ({ original: l }, { original: r }) => r.getStart(this.sourceFile) - l.getStart(this.sourceFile)); + for (const replacement of sortedReplacements) { + const offset = replacement.quoted ? 1 : 0; + const prefix = updatedSourceText.substring(0, replacement.original.getStart(this.sourceFile) + offset); + const suffix = updatedSourceText.substring(replacement.original.getEnd() - offset); + + updatedSourceText = prefix + replacement.updatedLocation + suffix; + } - let updatedSourceText = sourceText; - // Applying replacements in reverse order, so node positions remain valid. - for (const replacement of replacements.sort(({ original: l }, { original: r }) => r.getStart(sourceFile) - l.getStart(sourceFile))) { - const prefix = updatedSourceText.substring(0, replacement.original.getStart(sourceFile) + 1); - const suffix = updatedSourceText.substring(replacement.original.getEnd() - 1); + // Lastly, prepend the source with any new constructs imports, as needed. + const constructsImports = this.getConstructsImportsPrefix(); + if (constructsImports) { + const insertionPoint = this.firstImportNode + // Start of the line, past any leading comments or shebang lines + ? (this.firstImportNode.getStart() - this.getNodeIndentation(this.firstImportNode)) + : 0; + updatedSourceText = updatedSourceText.substring(0, insertionPoint) + + constructsImports + + updatedSourceText.substring(insertionPoint); + } - updatedSourceText = prefix + replacement.updatedLocation + suffix; + return updatedSourceText; } - return updatedSourceText; + /** + * If constructs imports are needed (either namespaced or named types), + * this returns a string with one (or both) imports that can be prepended to the source. + */ + private getConstructsImportsPrefix(): string | undefined { + if (!this.constructsNamespaceImportRequired && this.constructsNamedImports.size === 0) { return undefined; } + + const indentation = ' '.repeat(this.getNodeIndentation(this.firstImportNode)); + let constructsImportPrefix = ''; + if (this.constructsNamespaceImportRequired) { + constructsImportPrefix += `${indentation}import * as ${this.constructsId} from 'constructs';\n`; + } + if (this.constructsNamedImports.size > 0) { + const namedImports = [...this.constructsNamedImports].map(i => i.getText()).join(', '); + constructsImportPrefix += `${indentation}import { ${namedImports} } from 'constructs';\n`; + } + return constructsImportPrefix; + } /** - * Returns the module specifier (location) of an import statement in one of the following forms: - * import from 'location'; - * import * as name from 'location'; - * import { Type } = require('location'); - * import name = require('location'); - * require('location'); + * For a given node, attempts to determine and return how many spaces of indentation are used. */ - function getModuleSpecifier(node: ts.Node): ts.StringLiteral | undefined { - if (ts.isImportDeclaration(node)) { - // import style - const moduleSpecifier = node.moduleSpecifier; - if (ts.isStringLiteral(moduleSpecifier)) { - // import from 'location'; - // import * as name from 'location'; - return moduleSpecifier; - } else if (ts.isBinaryExpression(moduleSpecifier) && ts.isCallExpression(moduleSpecifier.right)) { - // import { Type } = require('location'); - return getModuleSpecifier(moduleSpecifier.right); - } - } else if ( - ts.isImportEqualsDeclaration(node) + private getNodeIndentation(node?: ts.Node): number { + if (!node) { return 0; } + + // Get leading spaces for the final line in the node's trivia + const fullText = node.getFullText(); + const trivia = fullText.substring(0, fullText.length - node.getWidth()); + const m = /( *)$/.exec(trivia); + return m ? m[1].length : 0; + } +} + +/** + * Returns the module specifier (location) of an import statement in one of the following forms: + * import from 'location'; + * import * as name from 'location'; + * import { Type } from 'location'; + * import { Type } = require('location'); + * import name = require('location'); + * require('location'); + */ +function getModuleSpecifier(node: ts.Node): ts.StringLiteral | undefined { + if (ts.isImportDeclaration(node)) { + // import style + const moduleSpecifier = node.moduleSpecifier; + if (ts.isStringLiteral(moduleSpecifier)) { + // import from 'location'; + // import * as name from 'location'; + // import { Foo } from 'location'; + return moduleSpecifier; + } else if (ts.isBinaryExpression(moduleSpecifier) && ts.isCallExpression(moduleSpecifier.right)) { + // import { Type } = require('location'); + return getModuleSpecifier(moduleSpecifier.right); + } + } else if ( + ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression) - ) { - // import name = require('location'); - return node.moduleReference.expression; - } else if ( - (ts.isCallExpression(node)) + ) { + // import name = require('location'); + return node.moduleReference.expression; + } else if ( + (ts.isCallExpression(node)) && ts.isIdentifier(node.expression) && node.expression.escapedText === 'require' && node.arguments.length === 1 - ) { - // require('location'); - const argument = node.arguments[0]; - if (ts.isStringLiteral(argument)) { - return argument; - } - } else if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) { - // require('location'); // This is an alternate AST version of it - return getModuleSpecifier(node.expression); + ) { + // require('location'); + const argument = node.arguments[0]; + if (ts.isStringLiteral(argument)) { + return argument; } - return undefined; + } else if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) { + // require('location'); // This is an alternate AST version of it + return getModuleSpecifier(node.expression); } + return undefined; } const EXEMPTIONS = new Set([ diff --git a/packages/aws-cdk-migration/test/rewrite.test.ts b/packages/aws-cdk-migration/test/rewrite.test.ts index 7f98cdde16f4b..36b7dd817dc76 100644 --- a/packages/aws-cdk-migration/test/rewrite.test.ts +++ b/packages/aws-cdk-migration/test/rewrite.test.ts @@ -38,7 +38,7 @@ describe(rewriteMonoPackageImports, () => { // something before import * as s3 from '@aws-cdk/aws-s3'; import * as cfndiff from '@aws-cdk/cloudformation-diff'; - import { Construct } from "@aws-cdk/core"; + import { Stack } from "@aws-cdk/core"; // something after console.log('Look! I did something!');`, 'aws-cdk-lib', 'subject.ts'); @@ -47,7 +47,7 @@ describe(rewriteMonoPackageImports, () => { // something before import * as s3 from 'aws-cdk-lib/aws-s3'; import * as cfndiff from '@aws-cdk/cloudformation-diff'; - import { Construct } from "aws-cdk-lib"; + import { Stack } from "aws-cdk-lib"; // something after console.log('Look! I did something!');`); @@ -58,7 +58,7 @@ describe(rewriteMonoPackageImports, () => { // something before import s3 = require('@aws-cdk/aws-s3'); import cfndiff = require('@aws-cdk/cloudformation-diff'); - import { Construct } = require("@aws-cdk/core"); + import { Stack } = require("@aws-cdk/core"); // something after console.log('Look! I did something!');`, 'aws-cdk-lib', 'subject.ts'); @@ -67,7 +67,7 @@ describe(rewriteMonoPackageImports, () => { // something before import s3 = require('aws-cdk-lib/aws-s3'); import cfndiff = require('@aws-cdk/cloudformation-diff'); - import { Construct } = require("aws-cdk-lib"); + import { Stack } = require("aws-cdk-lib"); // something after console.log('Look! I did something!');`); @@ -144,7 +144,7 @@ describe(rewriteReadmeImports, () => { Some README text. \`\`\`ts import * as s3 from '@aws-cdk/aws-s3'; - import { Construct } from "@aws-cdk/core"; + import { Stack } from "@aws-cdk/core"; \`\`\` Some more README text.`, 'aws-cdk-lib', 'subject.ts'); @@ -152,7 +152,7 @@ describe(rewriteReadmeImports, () => { Some README text. \`\`\`ts import * as s3 from 'aws-cdk-lib/aws-s3'; - import { Construct } from "aws-cdk-lib"; + import { Stack } from "aws-cdk-lib"; \`\`\` Some more README text.`); }); @@ -162,7 +162,7 @@ describe(rewriteReadmeImports, () => { Some README text. \`\`\`typescript import * as s3 from '@aws-cdk/aws-s3'; - import { Construct } from "@aws-cdk/core"; + import { Stack } from "@aws-cdk/core"; \`\`\` Some more README text.`, 'aws-cdk-lib', 'subject.ts'); @@ -170,7 +170,7 @@ describe(rewriteReadmeImports, () => { Some README text. \`\`\`typescript import * as s3 from 'aws-cdk-lib/aws-s3'; - import { Construct } from "aws-cdk-lib"; + import { Stack } from "aws-cdk-lib"; \`\`\` Some more README text.`); }); @@ -180,7 +180,7 @@ describe(rewriteReadmeImports, () => { Some README text. \`\`\`text import * as s3 from '@aws-cdk/aws-s3'; - import { Construct } from "@aws-cdk/core"; + import { Stack } from "@aws-cdk/core"; \`\`\` Some more README text.`, 'aws-cdk-lib', 'subject.ts'); @@ -188,7 +188,7 @@ describe(rewriteReadmeImports, () => { Some README text. \`\`\`text import * as s3 from 'aws-cdk-lib/aws-s3'; - import { Construct } from "aws-cdk-lib"; + import { Stack } from "aws-cdk-lib"; \`\`\` Some more README text.`); }); @@ -231,3 +231,190 @@ describe(rewriteReadmeImports, () => { \`\`\``); }); }); + +describe('constructs imports', () => { + describe('namespace imports', () => { + test('import declaration', () => { + const output = rewriteMonoPackageImports(` + import * as core from '@aws-cdk/core'; + class FooBar extends core.Construct { + private readonly foo: core.Construct; + private doStuff() { return new core.Construct(); } + }`, 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + import * as constructs from 'constructs'; + import * as core from 'aws-cdk-lib'; + class FooBar extends constructs.Construct { + private readonly foo: constructs.Construct; + private doStuff() { return new constructs.Construct(); } + }`); + }); + + test('import equals declaration', () => { + const output = rewriteMonoPackageImports(` + import core = require('@aws-cdk/core'); + class FooBar extends core.Construct { + private readonly foo: core.Construct; + private doStuff() { return new core.Construct(); } + }`, 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + import * as constructs from 'constructs'; + import core = require('aws-cdk-lib'); + class FooBar extends constructs.Construct { + private readonly foo: constructs.Construct; + private doStuff() { return new constructs.Construct(); } + }`); + }); + }); + + describe('named imports', () => { + test('no constructs imports', () => { + const output = rewriteMonoPackageImports(` + import { Stack, StackProps } from '@aws-cdk/core'; + class FooBar extends Stack { }`, + 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + import { Stack, StackProps } from 'aws-cdk-lib'; + class FooBar extends Stack { }`); + }); + + test('all constructs imports', () => { + const output = rewriteMonoPackageImports(` + import { IConstruct, Construct } from '@aws-cdk/core'; + class FooBar implements IConstruct extends Construct { }`, + 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + import { IConstruct, Construct } from 'constructs'; + class FooBar implements IConstruct extends Construct { }`); + }); + + test('mixed constructs and core imports', () => { + const output = rewriteMonoPackageImports(` + import { Stack, Construct, IConstruct, StackProps } from '@aws-cdk/core'; + class FooBar implements IConstruct extends Construct { }`, + 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + import { Construct, IConstruct } from 'constructs'; + import { Stack, StackProps } from 'aws-cdk-lib'; + class FooBar implements IConstruct extends Construct { }`); + }); + }); + + test('exhaustive test', () => { + const output = rewriteMonoPackageImports(` + import * as core1 from '@aws-cdk/core'; + // a comment of some kind + import core2 = require('@aws-cdk/core'); + import { Stack } from '@aws-cdk/core'; + // more comments + import { Construct as CoreConstruct } from '@aws-cdk/core'; + import { IConstruct, Stack, StackProps } from '@aws-cdk/core'; + import * as s3 from '@aws-cdk/aws-s3'; + + class FooBar implements core1.IConstruct { + readonly foo1: core2.Construct; + public static bar1() { return CoreConstruct(); } + public static bar2() { return new class implements IConstruct {}; } + }`, 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + import * as constructs from 'constructs'; + import { IConstruct } from 'constructs'; + import * as core1 from 'aws-cdk-lib'; + // a comment of some kind + import core2 = require('aws-cdk-lib'); + import { Stack } from 'aws-cdk-lib'; + // more comments + import { Construct as CoreConstruct } from 'constructs'; + import { Stack, StackProps } from 'aws-cdk-lib'; + import * as s3 from 'aws-cdk-lib/aws-s3'; + + class FooBar implements constructs.IConstruct { + readonly foo1: constructs.Construct; + public static bar1() { return CoreConstruct(); } + public static bar2() { return new class implements IConstruct {}; } + }`); + }); + + test('does not rewrite constructs imports unless the option is explicitly set', () => { + const output = rewriteMonoPackageImports(` + import * as core1 from '@aws-cdk/core'; + // a comment of some kind + import { Stack } from '@aws-cdk/core'; + // more comments + import { Construct as CoreConstruct } from '@aws-cdk/core'; + import { IConstruct, Stack, StackProps } from '@aws-cdk/core'; + import * as s3 from '@aws-cdk/aws-s3'; + + class FooBar implements core1.IConstruct { + readonly foo1: CoreConstruct; + public static bar2() { return new class implements IConstruct {}; } + }`, 'aws-cdk-lib', 'subject.ts'); + + expect(output).toBe(` + import * as core1 from 'aws-cdk-lib'; + // a comment of some kind + import { Stack } from 'aws-cdk-lib'; + // more comments + import { Construct as CoreConstruct } from 'aws-cdk-lib'; + import { IConstruct, Stack, StackProps } from 'aws-cdk-lib'; + import * as s3 from 'aws-cdk-lib/aws-s3'; + + class FooBar implements core1.IConstruct { + readonly foo1: CoreConstruct; + public static bar2() { return new class implements IConstruct {}; } + }`); + }); + + test('puts constructs imports after shebang lines', () => { + const output = rewriteMonoPackageImports(` + #!/usr/bin/env node + import * as core from '@aws-cdk/core'; + class FooBar extends core.Construct { + private readonly foo: core.Construct; + private doStuff() { return new core.Construct(); } + }`, 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + #!/usr/bin/env node + import * as constructs from 'constructs'; + import * as core from 'aws-cdk-lib'; + class FooBar extends constructs.Construct { + private readonly foo: constructs.Construct; + private doStuff() { return new constructs.Construct(); } + }`); + }); + + test('supports rewriteReadmeImports', () => { + const output = rewriteReadmeImports(` + Some README text. + \`\`\`ts + import * as s3 from '@aws-cdk/aws-s3'; + import * as core from "@aws-cdk/core"; + import { Construct, Stack } from "@aws-cdk/core"; + class Foo extends core.Construct { + public bar() { return new Construct(); } + } + \`\`\` + Some more README text.`, 'aws-cdk-lib', 'subject.ts', { rewriteConstructsImports: true }); + + expect(output).toBe(` + Some README text. + \`\`\`ts + import * as constructs from 'constructs'; + import { Construct } from 'constructs'; + import * as s3 from 'aws-cdk-lib/aws-s3'; + import * as core from "aws-cdk-lib"; + import { Stack } from "aws-cdk-lib"; + class Foo extends constructs.Construct { + public bar() { return new Construct(); } + } + \`\`\` + Some more README text.`); + }); +});