cli: add ESM support to Jest config + tests
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 || [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user