Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

aws-cdk-lib: jest.resetModules causes tests to fail starting with 2.94.0 #27312

Open
trobert2 opened this issue Sep 27, 2023 · 11 comments
Open
Labels
aws-cdk-lib Related to the aws-cdk-lib package bug This issue is a bug. effort/small Small work item – less than a day of effort p2

Comments

@trobert2
Copy link

Describe the bug

When upgrading from 2.88.0 to 2.98.0 we get this error when running the same code:

TypeError: Class extends value undefined is not a constructor or null

No code changes have been introduced. just package updates.

diff --git a/package-lock.json b/package-lock.json
index fe055dc..9ce311f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,9 @@
       "name": "@npm-shared-services/cdk-common-components",
       "version": "0.54.0",
       "dependencies": {
-        "@aws-cdk/aws-apigatewayv2-alpha": "2.88.0-alpha.0",
-        "@aws-cdk/aws-apigatewayv2-authorizers-alpha": "2.88.0-alpha.0",
-        "@aws-cdk/aws-apigatewayv2-integrations-alpha": "2.88.0-alpha.0",
+        "@aws-cdk/aws-apigatewayv2-alpha": "2.98.0-alpha.0",
+        "@aws-cdk/aws-apigatewayv2-authorizers-alpha": "2.98.0-alpha.0",
+        "@aws-cdk/aws-apigatewayv2-integrations-alpha": "2.98.0-alpha.0",
         "@aws-lambda-powertools/logger": "1.13.1",
         "aws-lambda": "1.0.7",
         "source-map-support": "^0.5.21"
@@ -21,8 +21,8 @@
         "@types/aws-lambda": "8.10.122",
         "@types/jest": "^29.5.5",
         "@types/node": "20.7.0",
-        "aws-cdk": "2.88.0",
-        "aws-cdk-lib": "2.88.0",
+        "aws-cdk": "2.98.0",
+        "aws-cdk-lib": "2.98.0",
         "constructs": "^10.2.70",
         "cross-env": "7.0.3",
         "eslint": "8.50.0",

Expected Behavior

tests pass, deployment works

Current Behavior

(node:75173) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
 FAIL  test/base-dns.test.ts
  ● testing stack › Domains stack

    TypeError: Class extends value undefined is not a constructor or null

      43 |     )
      44 |
    > 45 |     new CrossAccountZoneDelegationRecord(this, 'delegatecSubdomain', {
         |     ^
      46 |       delegatedZone: GuideZone,
      47 |       parentHostedZoneName: config.main_hosted_zone_name,
      48 |       delegationRole

      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/custom-resource-provider/cross-region-export-providers/export-writer-provider.js:1:717)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/private/refs.js:1:561)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/private/prepare-app.js:1:245)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/private/synthesis.js:1:333)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/app.js:1:425)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/annotations.js:1:260)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/stack.js:1:450)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/names.js:1:497)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/asset-staging.js:1:551)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.js:1:532)
      at aws_cdk_lib_CustomResourceProviderRuntime (node_modules/aws-cdk-lib/.warnings.jsii.js:2:344450)
      at Object.aws_cdk_lib_CustomResourceProviderProps (node_modules/aws-cdk-lib/.warnings.jsii.js:2:344155)
      at Function.getOrCreateProvider (node_modules/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.js:1:1966)
      at new CrossAccountZoneDelegationRecord (node_modules/aws-cdk-lib/aws-route53/lib/record-set.js:1:11869)
      at new DNSStack (lib/stacks/base-dns-stack.ts:45:5)
      at Object.<anonymous> (test/base-dns.test.ts:37:19)

(node:75174) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

Reproduction Steps

The code is redacted for obvious purposes. The code works fine with 2.88 and no changes have been added to the code or the context values (see diff above, that's all that changed)

Write a stack that include this:

...
    const delegationRoleArn = Stack.of(this).formatArn({
      region: '', // IAM is global in each partition
      service: 'iam',
      account: SHARED_SERVICES_ACCOUNT,
      resource: 'role',
      resourceName: CROSS_ACCOUNT_DELEGATION_ROLE_NAME
    })

    const delegationRole = Role.fromRoleArn(
      this,
      'DelegationRole',
      delegationRoleArn
    )

    // NEW ROAMY DELEGATION
    const roamyGuideZone = new PublicHostedZone(
      this,
      'GuideSubdomainHostedZone',
      {
        zoneName: config.subdomain_hosted_zone_name
      }
    )

    new CrossAccountZoneDelegationRecord(this, 'delegateSubdomain', {
      delegatedZone: GuideZone,
      parentHostedZoneName: config.main_hosted_zone_name,
      delegationRole
    })
...

Write a test for it

import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

jest.useFakeTimers()

describe('testing stack', () => {
  const OLD_ENV_STAGE = process.env.STAGE
  const testingStage = 'testing'

  beforeEach(() => {
    jest.resetModules()
    process.env.STAGE = testingStage
  })

  afterAll(() => {
    process.env.STAGE = OLD_ENV_STAGE
  })

  test('Domains stack', () => {
    const app = new App({
      context: {
        testing: {
          main_hosted_zone_name: 'xxxxxxxx.guide',
          subdomain_hosted_zone_name: 'staging.xxxxxxxx.guide',
          sharing_subdomain_hosted_zone_name:
            'staging.xxxxxxxx.guide',
          sharing_subdomain_entry: 'sharing.staging.xxxxxxxx.guide'
        }
      }
    })
    // WHEN
    const envEU = { account: '000000000000', region: 'eu-central-1' }

    const stack = new DNSStack(app, 'MyTestDNSStack', {
      env: envEU,
      crossRegionReferences: true
    })
    // THEN
    expectCDK(stack).to(countResources('AWS::Route53::HostedZone', 1))
  })
})

Run test:

STAGE=testing NODE_OPTIONS=--experimental-vm-modules npx jest

Possible Solution

No response

Additional Information/Context

No response

CDK CLI Version

2.98.0 (build b04f852)

Framework Version

2.98.0

Node.js Version

v20.2.0

OS

mac arm

Language

Typescript

Language Version

5.2.2

Other information

No response

@trobert2 trobert2 added bug This issue is a bug. needs-triage This issue or PR still needs to be triaged. labels Sep 27, 2023
@github-actions github-actions bot added the aws-cdk-lib Related to the aws-cdk-lib package label Sep 27, 2023
@The-Zona-Zoo
Copy link

The-Zona-Zoo commented Sep 27, 2023

+1 also seeing this when doing new VPC(...). Version 2.94.0 appears to be what broke it as 2.93.0 works fine.

@peterwoodworth
Copy link
Contributor

You're right, thanks for the reproduction steps. I've been able to reproduce this and see that it is breaking going from 2.93.0 to 2.94.0.

I noticed that if you remove the jest.resetModules() line, then the test succeeds.

May be related to this change #26854

@peterwoodworth peterwoodworth removed the needs-triage This issue or PR still needs to be triaged. label Sep 27, 2023
@peterwoodworth peterwoodworth changed the title aws-cdk-lib: breaking change - probable cycle dependency aws-cdk-lib: jest.resetModules causes tests to fail starting with 2.94.0 Sep 27, 2023
@peterwoodworth peterwoodworth added p0 effort/small Small work item – less than a day of effort labels Sep 27, 2023
@rix0rrr
Copy link
Contributor

rix0rrr commented Sep 28, 2023

I see that there was a Jest-related change in 2.94.0 which @peterwoodworth found, but I'm 99% confident that should only affect the CDK repository itself, not any downstream users of the aws-cdk-lib library.

This is mystery. I'm looking through the set of changes and I can't even find anything else that looks suspicious 🤔

@rix0rrr
Copy link
Contributor

rix0rrr commented Sep 28, 2023

To be honest I'm unsure about the purpose of resetModules inside a beforeEach() statement.

As per the documentation, it's going to affect all subsequent require() calls. In the example code you showed, some imports (which turn into require()s) have already been done at the top of the file, and then subsequent require()s that will be executed in the middle of a test will return a fresh in-memory copy of the module:

// These run at module load time, will not be affected by 'resetModules()'
import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

jest.useFakeTimers()

describe('testing stack', () => {
  beforeEach(() => {
    jest.resetModules()
    // Every require() or import performed AFTER this point will get a new copy of the module
  })
});

What might work is doing the following:

import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

let expectCDK: typeof import('@aws-cdk/assert')['expect'];
let countResources: typeof import('@aws-cdk/assert')['countResources'];
let App: typeof import('aws-cdk-lib)['App'];
let DNSStack: typeof import('../lib/stacks')['DNSStack'];

beforeEach(() => {
  jest.resetModules();
  expectCDK = require('@aws-cdk/assert').expect;
  countResources = require('@aws-cdk/assert').countResources;
  App = require('aws-cdk-lib').App;
  DNSStack = require('../lib/stacks').DNSStack;
});

To make sure that the require() happens after resetModules is called, and symbols from different module copies aren't intermixed (they would all be from the same "load generation", if that makes sense).

@rix0rrr
Copy link
Contributor

rix0rrr commented Sep 28, 2023

By the way, the code that is failing looks to be:

const constructs_1=require("constructs");

class ExportWriter extends constructs_1.Construct
                                          ^^^^ value undefined is not a constructor or null

So it looks like constructs.Construct is undefined. Still don't understand how that would happen.

@trobert2
Copy link
Author

To be honest I'm unsure about the purpose of resetModules inside a beforeEach() statement.

As per the documentation, it's going to affect all subsequent require() calls. In the example code you showed, some imports (which turn into require()s) have already been done at the top of the file, and then subsequent require()s that will be executed in the middle of a test will return a fresh in-memory copy of the module:

// These run at module load time, will not be affected by 'resetModules()'
import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

jest.useFakeTimers()

describe('testing stack', () => {
  beforeEach(() => {
    jest.resetModules()
    // Every require() or import performed AFTER this point will get a new copy of the module
  })
});

What might work is doing the following:

import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

let expectCDK: typeof import('@aws-cdk/assert')['expect'];
let countResources: typeof import('@aws-cdk/assert')['countResources'];
let App: typeof import('aws-cdk-lib)['App'];
let DNSStack: typeof import('../lib/stacks')['DNSStack'];

beforeEach(() => {
  jest.resetModules();
  expectCDK = require('@aws-cdk/assert').expect;
  countResources = require('@aws-cdk/assert').countResources;
  App = require('aws-cdk-lib').App;
  DNSStack = require('../lib/stacks').DNSStack;
});

To make sure that the require() happens after resetModules is called, and symbols from different module copies aren't intermixed (they would all be from the same "load generation", if that makes sense).

Thanks for the suggestion. I don't think that would work with ESM modules.
It's worth mentioning that my package.json contains "type": "module",

So I think this results in:

    ReferenceError: require is not defined

I also think your import statement needs changing because:

Import declaration conflicts with local declaration of 'expectCDK'.ts(2440)
so you can't have:

import { expect as expectCDK, countResources } from '@aws-cdk/assert'

and

let expectCDK: typeof import('@aws-cdk/assert')['expect']

Furthermore, in order for this to work, in case you are using eslint, you would need to add:

    // eslint-disable-next-line @typescript-eslint/no-var-requires

@trobert2
Copy link
Author

@peterwoodworth, for this particular case removing resetModules indeed fixed my issue. These tests pass.
I would like to add that this solution doesn't apply to some of my other modules where I care about the context more. I would appreciate if this is still treated as a bug. What do you think?

@rix0rrr
Copy link
Contributor

rix0rrr commented Oct 2, 2023

Thanks for the suggestion. I don't think that would work with ESM modules.
It's worth mentioning that my package.json contains "type": "module",
So I think this results in: ReferenceError: require is not defined

In that case, I think you would write:

beforeEach(async () => {
  jest.resetModules();
  expectCDK = (await import('@aws-cdk/assert')).expect;
  countResources = (await import('@aws-cdk/assert')).countResources;
  App = (await import('aws-cdk-lib')).App;
  DNSStack = (await import('../lib/stacks')).DNSStack;
});

I also think your import statement needs changing because:

My mistake, I meant to remove the top-level import but apparently forgot to do so. Here it is again for ESM syntax:

import { jest } from '@jest/globals'

let expectCDK: typeof import('@aws-cdk/assert')['expect'];
let countResources: typeof import('@aws-cdk/assert')['countResources'];
let App: typeof import('aws-cdk-lib)['App'];
let DNSStack: typeof import('../lib/stacks')['DNSStack'];

beforeEach(async () => {
  jest.resetModules();
  expectCDK = (await import('@aws-cdk/assert')).expect;
  countResources = (await import('@aws-cdk/assert')).countResources;
  App = (await import('aws-cdk-lib')).App;
  DNSStack = (await import('../lib/stacks')).DNSStack;
});

I would appreciate if this is still treated as a bug. What do you think?

I'm happy to keep this open as a bug for people to collect insights on. I have no idea what would be causing this issue though, and no idea of how to start tracking it.

It would help if you could come up with a minimal reproducing example.

@rix0rrr rix0rrr added p2 and removed p0 labels Oct 2, 2023
@The-Zona-Zoo
Copy link

beforeEach(async () => {
  jest.resetModules();
  expectCDK = (await import('@aws-cdk/assert')).expect;
  countResources = (await import('@aws-cdk/assert')).countResources;
  App = (await import('aws-cdk-lib')).App;
  DNSStack = (await import('../lib/stacks')).DNSStack;
});

Confirming this work around does work.

To be honest I'm unsure about the purpose of resetModules inside a beforeEach() statement.

For some more insight, the reason we use resetModules is to test logic that uses environment variables. Obviously there are other ways to go about this but modifying the environment and then using resetModules was the easiest way based on the code.

@rix0rrr
Copy link
Contributor

rix0rrr commented Oct 3, 2023

@The-Zona-Zoo, thanks, that's helpful. Are you referring to CDK code that is caching those environment variables into global variables somewhere?

Again, a reproducing example would be helpful.

@The-Zona-Zoo
Copy link

The-Zona-Zoo commented Oct 3, 2023

@rix0rrr While I can't post the full example, it's essentially something to the effect of:

let App: (typeof import('aws-cdk-lib/core'))['App'];
let Template: (typeof import('aws-cdk-lib/assertions'))['Template'];

describe('SomeCdkStack', () => {
  const previousEnv = { ...process.env };
  beforeEach(async () => {
    if (process.env.USER) {
      delete process.env.USER;
      jest.resetModules();
    }
    App = (await import('aws-cdk-lib/core')).App;
    Template = (await import('aws-cdk-lib/assertions')).Template;
  });
  afterEach(() => {
    process.env = previousEnv;
  });
});

This example doesn't work with importing App and Template directly (as of 2.94.0).

The stack being tested in question has a statement similar to env.USER ?? 'someDefault' or env.USER ? 'userDefault' : 'nonUserDefault'. We use this mechanism to allow developers to have their own AWS resources with their username's appended while defaulting for the case of say beta and production environments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
aws-cdk-lib Related to the aws-cdk-lib package bug This issue is a bug. effort/small Small work item – less than a day of effort p2
Projects
None yet
Development

No branches or pull requests

4 participants