cucumber / cucumber-js

Cucumber for JavaScript
https://cucumber.io
MIT License
5.02k stars 1.09k forks source link

Cannot use import statement outside a module when referencing from private module #2338

Closed zoiman closed 9 months ago

zoiman commented 9 months ago

πŸ‘“ What did you see?

βœ… What did you expect to see?

When running a feature file I get the following error:

/Users/../node_modules/@privateModule/schemas/lib/constants.js:1
import { z } from 'zod';
^^^^^^

SyntaxError: Cannot use import statement outside a module

πŸ“¦ Which tool/library version are you using?

"@cucumber/cucumber": "^9.5.1",
    "@cucumber/pretty-formatter": "^1.0.0",
    "@types/chai": "^4.3.6",
    "@types/cucumber": "^7.0.0",
    "chai": "^4.3.10",
    "cucumber": "^6.0.7",
    "cucumber-tsflow": "^4.1.1",
    "ts-node": "^10.9.1",
    "typescript": "^5.2.2"

πŸ”¬ How could we reproduce it?

I have no idea how to reproduce this but I will try to give all relevant information. First of all, this error only happens whenever I want to use any definition from any private module. When I for example want to use myClass in a stepdefinition which is defined in this module in myClass.js then the first import of this file will throw this error.

This is how the folder structure looks like:

|-- root
|.   |-- components
|.   |    |-- componentA
|.   |    |-- componentB
|.   |    |-- testing

The root folder and each component have their package.json and tsconfig.json. The private modules are in the root/package.json

This is how the testing/tsconfig.json looks like:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "componentA/*": ["../componentA/src/*"],
      "componentB/*": ["../componentB/src/*"]
    }
  }
}

The root/tsconfig.json looks like this:

{
  "extends": "@tsconfig/node18/tsconfig.json"
}
// testing/cucumber.js
let common = [
  'src/features/**/*.feature', // Specify our feature files
  '--require-module ts-node/register', // Load TypeScript module
  '--require src/step-definitions/**/*.ts', // Load step definitions
  '--format progress-bar', // Load custom formatter
  '--format @cucumber/pretty-formatter', // Load custom formatter
].join(' ');

module.exports = {
  default: common,
};

This is the testing/babel.config.js

module.exports = require('../../babel.config');

And the corresponding babel.config.js in the root:

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current',
        },
      },
    ],
  ],
};

This is what is in the package.json of a private module: ( I removed all the dependecies, author etc)

{
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
  "./functions": "./dist/functions/index.js",
    "./schemas": "./dist/schemas/index.js",
    ".": "./dist/index.js"
},
  "typesVersions": {
  "*": {
    "functions": [
      "dist/functions/index.d.ts"
    ],
      "schemas": [
      "dist/schemas/index.d.ts"
    ],
      "*": [
      "dist/index.d.ts"
    ]
  }
},

}

Also, not sure if of relevance but when I build the test component via tsc --noEmit there is no error.

I will happily provide any more details if requested

davidjgoss commented 9 months ago

Can you show what's in the package.json under /Users/../node_modules/@privateModule - that will be what's used to determine whether the file is in a module context or not. If it doesn't have "type": "module" then Node.js would not expect to encounter import statements so that error would be expected.

zoiman commented 9 months ago

Can you show what's in the package.json under /Users/../node_modules/@privateModule - that will be what's used to determine whether the file is in a module context or not. If it doesn't have "type": "module" then Node.js would not expect to encounter import statements so that error would be expected.

I updated the question.

pk commented 9 months ago

I may be wrong here but:

I'm not familiar with Babel, however is the root package.json also having type set to module? If so then the ESM environment would be expected and therefore the: '--require src/step-definitions/**/*.ts', // Load step definitions

Should be changed to --import. I've been fighting with the ESM/CommonJS interoperability for some time and resorted to use ESM in all the test suites.

zoiman commented 9 months ago

no in the root package.json there is not type defined

pk commented 9 months ago

Would that not mean, in that case that it defaults to CommonJS modules. Therefore the root package will require loading the support code with require while child packages will be ESM, needing import....

I had (maybe not the same issue) with our E2E where Angular dropped CommonJS support. As Angular was dependency requiring the ESM I had to make our E2E test suite also ESM in order to make everything work reasonably smoothly.

I may be wrong, of course.

zoiman commented 9 months ago

so you mean I should use --import in my cucumber.js ?

When I try that I get: TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"

Also I have no clue if this is related but as you can see above I am using alias paths in my tsconfig.json but whenever I use them instead of the full path I also get an error that the module can not be found as soon as I run a feature

I now also tried to import a simple function from another component (componentA) and there I got this error:

Error: Cucumber expected a CommonJS module at '/Users/.../servicetest/src/step-definitions/mytest.steps.ts' but found an ES module.
      Either change the file to CommonJS syntax or use the --import directive instead of --require.

I have no clue what to do...

davidjgoss commented 9 months ago

Based on the config you have provided, your TypeScript code will be compiled down to CommonJS, meaning although you write import {foo} from './foo.js' this will compile down to const {foo} = require('./foo.js') (you should be able to validate by doing tsc and viewing the compiled JS). This is probably okay until you try to bring in something that is a native ES module, like perhaps one of your private libraries. You can't require an ES module because require is sync and modules are async, so that will fail. It's surprising to see it manifest as a syntax error like that though, so it does seem like something else is going on there.

The thing I'd recommend you try is to change your tsconfig to emit ES modules (see docs for compilerOptions.module) and then use Cucumber's --import option along with ts-node's ESM loader (via NODE_OPTIONS, see this doc) instead of --require and --require-module respectively.

zoiman commented 9 months ago

thank you for your help, just a quick follow up question:

I know have a simpler reproducible error. In my steps I just import a const from @mycompany/schema/whatever and then I receive the following error:

SyntaxError: Cannot use import statement outside a module

so looking at the tsconfig from this package it looks like this:

{
  "extends": "@tsconfig/node18/tsconfig.json",
  "compilerOptions": {
    "resolveJsonModule": true,
    "esModuleInterop": true
  }
}

maybe the esModuleInterop is an issue? should I still try to change my tsconfig to emit ES modules, and do I need to add this package as a support code when I use esm as a loader?

davidjgoss commented 9 months ago

AFAIK esModuleInterop is meant to deal with inconsistencies around default imports rather than the module format.

It would be good to add a minimal reproducible example to work against here.

zoiman commented 9 months ago

I have no clue how to make a minimal reproducible example since this happens only when I import from a private module.

But I found out when I import a const MYCONST from @mycompany/constants/myconst then I will get the above error from the first import of myconst.js which is import {fu} from../bar/fu`

but when I then try to import fu directly in my steps I get this error:

Error: Cucumber expected a CommonJS module at '/Users/.../servicetest/src/step-definitions/mysteps.steps.ts' but found an ES module.
      Either change the file to CommonJS syntax or use the --import directive instead of --require.

so I was looking into the package.json of another private package which is used by the one I am using and it has "type": "module",

so I guess that is the issue here... Maybe I can import those private packages in my test component somehow? I tried to change to esm module but I failed horribly (there were issues with cucumber ts flow)

zoiman commented 9 months ago

I now managed to switch to ES modules but I am facing the same issue. However I now have a clearer picture what is causing the issue.

There are two private npm packages where packageA has type:commonjs in the package.json and packageB which is imported in packageA has type:module in the package.json

@mycompany/packageA/schema. // commonjs
@mycompany/packageB/valueObjects. // module

so when I import something from packageA in my step like this:

import pkg from `@mycompany/packageA/schema/someSchema/index.js`
const {MYSCHEMA} = pkg

const foo = MYSCHEMA

I get the following error:

import { SOMEVALUEOBJECT } from '@mycomany/packageB/value-objects';
^^^^^^

SyntaxError: Cannot use import statement outside a module

this is how my tsconfig.json now looks like:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "allowImportingTsExtensions": true,
    "module": "es2022",
    "target": "es2021",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true,
    "baseUrl": ".",
   },
}

and this is how I start my test: "test:e2e": "NODE_OPTIONS=\"--loader ts-node/esm\" cucumber-js -p default",

I am sorry for this mess, but I have no idea how to fix this.

davidjgoss commented 9 months ago

I think I can now say with confidence this isn't a Cucumber issue, and your setup for your main project seems good to me.

Your issue seems to be in package A, where Node.js is throwing the error because it encounters an import statement in what it expects to be CommonJS code. I'd suggest you need to either change the type to module in your package.json there, or change the code to be CommonJS format to match what the package declares.

It might save you some time to isolate this from Cucumber - perhaps add a file test.mjs to your main package that just imports package A, and run with node ./test.mjs.

I'm going to close this now as it's not an issue with Cucumber, but feel free to take this into a thread on Slack.