cli: add ESM support to Jest config + tests

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-12-23 11:33:44 +01:00
parent f01b5739e6
commit 47407fc34c
10 changed files with 278 additions and 81 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ jobs:
env:
CI: true
NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot
NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot --experimental-vm-modules
INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }}
INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }}
INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
env:
CI: true
NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot
NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot --experimental-vm-modules
INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }}
INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }}
INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }}
+5 -5
View File
@@ -49,8 +49,8 @@
"storybook": "yarn ./storybook run storybook",
"techdocs-cli": "node scripts/techdocs-cli.js",
"techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js",
"test": "NODE_OPTIONS=--no-node-snapshot backstage-cli repo test",
"test:all": "NODE_OPTIONS=--no-node-snapshot backstage-cli repo test --coverage",
"test": "NODE_OPTIONS='--no-node-snapshot --experimental-vm-modules' backstage-cli repo test",
"test:all": "NODE_OPTIONS='--no-node-snapshot --experimental-vm-modules' backstage-cli repo test --coverage",
"test:e2e": "NODE_OPTIONS=--no-node-snapshot playwright test",
"tsc": "tsc",
"tsc:full": "backstage-cli repo clean && tsc --skipLibCheck false --incremental false"
@@ -84,6 +84,9 @@
]
},
"prettier": "@backstage/cli/config/prettier",
"jest": {
"rejectFrontendNetworkRequests": true
},
"resolutions": {
"@changesets/assemble-release-plan@^6.0.0": "patch:@changesets/assemble-release-plan@npm%3A6.0.0#./.yarn/patches/@changesets-assemble-release-plan-npm-6.0.0-f7b3005037.patch",
"@material-ui/pickers@^3.2.10": "patch:@material-ui/pickers@npm%3A3.3.11#./.yarn/patches/@material-ui-pickers-npm-3.3.11-1c8f68ea20.patch",
@@ -134,9 +137,6 @@
"sort-package-json": "^2.8.0",
"typescript": "~5.2.0"
},
"jest": {
"rejectFrontendNetworkRequests": true
},
"packageManager": "yarn@3.8.1",
"engines": {
"node": "20 || 22"
+98 -62
View File
@@ -31,6 +31,14 @@ const FRONTEND_ROLES = [
'frontend-plugin-module',
];
const NODE_ROLES = [
'backend',
'cli',
'node-library',
'backend-plugin',
'backend-plugin-module',
];
const envOptions = {
oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS),
};
@@ -130,11 +138,97 @@ const transformIgnorePattern = [
].join('|');
// Provides additional config that's based on the role of the target package
function getRoleConfig(role) {
function getRoleConfig(role, pkgJson) {
// Only Node.js package roles support native ESM modules, frontend and common
// packages are always transpiled to CommonJS.
const moduleOpts = NODE_ROLES.includes(role)
? {
module: {
ignoreDynamic: true,
exportInteropAnnotation: true,
},
}
: undefined;
const transform = {
'\\.(mjs|cjs|js)$': [
require.resolve('./jestSwcTransform'),
{
...moduleOpts,
jsc: {
parser: {
syntax: 'ecmascript',
},
},
},
],
'\\.jsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(mts|cts|ts)$': [
require.resolve('./jestSwcTransform'),
{
...moduleOpts,
jsc: {
parser: {
syntax: 'typescript',
},
},
},
],
'\\.tsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$':
require.resolve('./jestFileTransform.js'),
'\\.(yaml)$': require.resolve('./jestYamlTransform'),
};
if (FRONTEND_ROLES.includes(role)) {
return { testEnvironment: require.resolve('jest-environment-jsdom') };
return {
testEnvironment: require.resolve('jest-environment-jsdom'),
transform,
};
}
return { testEnvironment: require.resolve('jest-environment-node') };
return {
testEnvironment: require.resolve('jest-environment-node'),
moduleFileExtensions: [...SRC_EXTS, 'json', 'node'],
// Jest doesn't let us dynamically detect type=module per transformed file,
// so we have to assume that if the entry point is ESM, all TS files are
// ESM.
//
// This means you can't switch a package to type=module until all of its
// monorepo dependencies are also type=module or does not contain any .ts
// files.
extensionsToTreatAsEsm:
pkgJson.type === 'module' ? ['.ts', '.mts'] : ['.mts'],
transform,
};
}
async function getProjectConfig(targetPath, extraConfig, extraOptions) {
@@ -160,64 +254,6 @@ async function getProjectConfig(targetPath, extraConfig, extraOptions) {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
},
transform: {
'\\.(mjs|cjs|js)$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'ecmascript',
},
},
},
],
'\\.jsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.ts$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'typescript',
},
},
},
],
'\\.tsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$':
require.resolve('./jestFileTransform.js'),
'\\.(yaml)$': require.resolve('./jestYamlTransform'),
},
// A bit more opinionated
testMatch: [`**/*.test.{${SRC_EXTS.join(',')}}`],
@@ -226,7 +262,7 @@ async function getProjectConfig(targetPath, extraConfig, extraOptions) {
: require.resolve('./jestCachingModuleLoader'),
transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`],
...getRoleConfig(pkgJson.backstage?.role),
...getRoleConfig(pkgJson.backstage?.role, pkgJson),
};
options.setupFilesAfterEnv = options.setupFilesAfterEnv || [];
+2 -1
View File
@@ -18,12 +18,13 @@ const { createTransformer: createSwcTransformer } = require('@swc/jest');
const ESM_REGEX = /\b(?:import|export)\b/;
function createTransformer(config) {
const useModules = Boolean(config?.module);
const swcTransformer = createSwcTransformer({
inputSourceMap: false,
...config,
});
const process = (source, filePath, jestOptions) => {
if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) {
if (filePath.endsWith('.js') && (useModules || !ESM_REGEX.test(source))) {
return { code: source };
}
+1 -1
View File
@@ -257,7 +257,7 @@ export async function load(url, context, nextLoad) {
const transformed = await transformFile(fileURLToPath(url), {
sourceMaps: 'inline',
module: {
type: format === 'module' ? 'nodenext' : 'commonjs',
type: format === 'module' ? 'es6' : 'commonjs',
ignoreDynamic: true,
// This helps the Node.js CommonJS compat layer identify named exports.
@@ -0,0 +1,16 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default 'a';
@@ -0,0 +1,16 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const value = 'a';
@@ -0,0 +1,73 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-nocheck
import * as depCommonJs from 'dep-commonjs';
import * as depModule from 'dep-module';
import * as depDefault from 'dep-default';
import { value as namedA } from './a-named-explicit';
import { value as namedB } from './b-named';
import cNamed from './c-named';
import defaultA from './a-default-explicit';
import defaultB from './b-default';
import cDefault from './c-default';
const { default: defaultC } = cDefault;
const { value: namedC } = cNamed;
async function resolveAll(obj): Promise<unknown> {
const val = await obj;
if (typeof val !== 'object' || val === null) {
return val;
}
if (Array.isArray(val)) {
return await Promise.all(val.map(resolveAll));
}
return Object.fromEntries(
await Promise.all(
Object.entries(obj).map(async ([key, value]) => [
key,
await resolveAll(await value),
]),
),
);
}
export const values = resolveAll({
depCommonJs,
depModule,
depDefault,
dynCommonJs: import('dep-commonjs'),
dynModule: import('dep-module'),
dynDefault: import('dep-default'),
dep: {
namedA,
namedB,
namedC,
defaultA,
defaultB,
defaultC,
},
dyn: {
namedA: import('./a-named-explicit').then(m => m.value),
namedB: import('./b-named').then(m => m.value),
namedC: import('./c-named').then(m => m.default.value),
defaultA: import('./a-default-explicit').then(m => m.default),
defaultB: import('./b-default').then(m => m.default),
defaultC: import('./c-default').then(m => m.default.default),
},
});
@@ -50,17 +50,16 @@ const expectedExports = {
};
function loadFixture(fixture: string) {
return JSON.parse(
execFileSync(
'node',
[
'--import',
'@backstage/cli/config/nodeTransform.cjs',
resolvePath(__dirname, `__fixtures__/${fixture}`),
],
{ encoding: 'utf8' },
),
const output = execFileSync(
'node',
[
'--import',
'@backstage/cli/config/nodeTransform.cjs',
resolvePath(__dirname, `__fixtures__/${fixture}`),
],
{ encoding: 'utf8' },
);
return JSON.parse(output);
}
describe('node runtime module transforms', () => {
@@ -101,3 +100,59 @@ describe('node runtime module transforms', () => {
});
});
});
describe('Jest runtime module transforms', () => {
it('should load from commonjs format', async () => {
const values = await import('./__fixtures__/pkg-commonjs/main').then(
m => m.values,
);
expect(values).toEqual({
depCommonJs: expectedExports.commonJs,
depDefault: expectedExports.commonJs,
dynCommonJs: expectedExports.commonJs,
dynDefault: expectedExports.commonJs,
dynModule: expectedExports.module,
dep: exportValues.commonJs,
dyn: exportValues.all,
});
});
it('should load from default format', async () => {
const values = await import('./__fixtures__/pkg-default/main').then(
m => m.values,
);
expect(values).toEqual({
depCommonJs: expectedExports.commonJs,
depDefault: expectedExports.commonJs,
dynCommonJs: expectedExports.commonJs,
dynDefault: expectedExports.commonJs,
dynModule: expectedExports.module,
dep: exportValues.commonJs,
dyn: exportValues.all,
});
});
it('should load from module format', async () => {
// This uses a separate entry point with an explicit .mts extension. This is
// because we can't cleanly switch the Jest behavior based on type=module in
// package.json for .ts files in Jest. If a module type is detected we
// instead need to switch the transforms for the entire Jest project, which
// we can't do for this test. We instead use the explicit .mts extension to
// verify the transform behavior.
// @ts-expect-error Cannot find module './__fixtures__/pkg-module/main-explicit' or its corresponding type declarations.
const values = await import('./__fixtures__/pkg-module/main-explicit').then(
m => m.values,
);
expect(values).toEqual({
depCommonJs: expectedExports.commonJs,
depDefault: expectedExports.commonJs,
depModule: expectedExports.module,
dynCommonJs: expectedExports.commonJs,
dynDefault: expectedExports.commonJs,
dynModule: expectedExports.module,
dep: exportValues.all,
dyn: exportValues.all,
});
});
});