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

feat: Jest ESM support #3256

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dist/

test/**/www/*
test/**/hydrate/*
test/**/components.d.ts
.stencil
/dependencies.json
coverage/**
19 changes: 19 additions & 0 deletions src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,17 @@ export interface JestConfig {
coverageThreshold?: any;

errorOnDeprecated?: boolean;

/**
* Jest will run .mjs and .js files with nearest package.json's type field set to `module` as ECMAScript Modules. If you have
* any other files that should run with native ESM, you need to specify their file extension here.
*
* Default is `['.ts', '.tsx', '.jsx']`.
*
* Note: Jest's ESM support is still experimental, see [its docs for more details]{@link https://jestjs.io/docs/ecmascript-modules}.
*/
extensionsToTreatAsEsm?: string[];

forceCoverageMatch?: any[];
globals?: any;
globalSetup?: string;
Expand Down Expand Up @@ -1697,6 +1708,14 @@ export interface TestingConfig extends JestConfig {
*/
screenshotConnector?: string;

/**
* If `true`, Jest is run with ES Modules enabled. Requires node be run with `--experimental-vm-modules` enabled.
* If `false` or not set, Jest is run in CommonJS mode, which uses CommonJS imports - this is the standard behavior
* for Jest. In CommonJS mode, all ESM files must be transpiled to CommonJS.
* @see https://jestjs.io/docs/ecmascript-modules
*/
useESModules?: boolean;

/**
* Amount of time in milliseconds to wait before a screenshot is taken.
*/
Expand Down
35 changes: 29 additions & 6 deletions src/testing/jest/jest-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ function getLegacyJestOptions(): Record<string, boolean | number | string> {
};
}

/** @returns true if the argument sets max-workers or runInBand. */
function argSetsWorkers(arg: string) {
const lowerCased = arg.toLowerCase();
return (
lowerCased === '-i' ||
lowerCased === '--runinband' ||
lowerCased.startsWith('--max-workers') ||
lowerCased.startsWith('--maxworkers')
);
}

/**
* Builds the `argv` to be used when programmatically invoking the Jest CLI
* @param config the Stencil config to use while generating Jest CLI arguments
Expand All @@ -48,12 +59,12 @@ export function buildJestArgv(config: d.Config): Config.Argv {

const args = [...config.flags.unknownArgs.slice(), ...config.flags.knownArgs.slice()];

if (!args.some((a) => a.startsWith('--max-workers') || a.startsWith('--maxWorkers'))) {
args.push(`--max-workers=${config.maxConcurrentWorkers}`);
}

if (config.flags.devtools) {
args.push('--runInBand');
if (!args.some(argSetsWorkers)) {
if (config.flags.devtools) {
args.push('--runInBand');
} else {
args.push(`--max-workers=${config.maxConcurrentWorkers}`);
}
}

config.logger.info(config.logger.magenta(`jest args: ${args.join(' ')}`));
Expand Down Expand Up @@ -109,6 +120,18 @@ export function buildJestConfig(config: d.Config): string {
if (stencilConfigTesting.coverageThreshold) {
jestConfig.coverageThreshold = stencilConfigTesting.coverageThreshold;
}
jestConfig.globals = {
stencil: {
testing: {
useESModules: !!stencilConfigTesting.useESModules,
},
},
};
if (stencilConfigTesting.useESModules) {
if (!jestConfig.extensionsToTreatAsEsm) {
jestConfig.extensionsToTreatAsEsm = ['.ts', '.tsx', '.jsx'];
}
}
if (isString(stencilConfigTesting.globalSetup)) {
jestConfig.globalSetup = stencilConfigTesting.globalSetup;
}
Expand Down
38 changes: 26 additions & 12 deletions src/testing/jest/jest-preprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { loadTypeScriptDiagnostic, normalizePath } from '@utils';
import { transpile } from '../test-transpile';
import { ts } from '@stencil/core/compiler';

type StencilTestingOptions = { useESModules: boolean };
// TODO(STENCIL-306): Remove support for earlier versions of Jest
type Jest26CacheKeyOptions = { instrument: boolean; rootDir: string };
type Jest26Config = { instrument: boolean; rootDir: string };
interface Jest26Config {
instrument: boolean;
rootDir: string;
globals?: { stencil?: { testing?: StencilTestingOptions } };
}
type Jest27TransformOptions = { config: Jest26Config };

/**
Expand Down Expand Up @@ -69,10 +74,13 @@ export const jestPreprocessor = {
transformOptions = jestConfig.config;
}

if (shouldTransform(sourcePath, sourceText)) {
const useESModules = transformOptions.globals?.stencil?.testing?.useESModules ?? false;

if (shouldTransform(sourcePath, sourceText, useESModules)) {
const opts: TranspileOptions = {
file: sourcePath,
currentDirectory: transformOptions.rootDir,
module: useESModules ? 'esm' : 'cjs',
};

const tsCompilerOptions: ts.CompilerOptions = getCompilerOptions(transformOptions.rootDir);
Expand Down Expand Up @@ -217,27 +225,33 @@ function getCompilerOptions(rootDir: string): ts.CompilerOptions | null {
* Determines if a file should be transformed prior to being consumed by Jest, based on the file name and its contents
* @param filePath the path of the file
* @param sourceText the contents of the file
* @param useESModules when `true`, transform output uses ES Modules; files that are already ESM are
* not transformed.
* @returns `true` if the file should be transformed, `false` otherwise
*/
export function shouldTransform(filePath: string, sourceText: string): boolean {
export function shouldTransform(filePath: string, sourceText: string, useESModules: boolean): boolean {
const ext = filePath.split('.').pop().toLowerCase().split('?')[0];

if (ext === 'ts' || ext === 'tsx' || ext === 'jsx') {
// typescript extensions (to include .d.ts)
return true;
}
if (ext === 'mjs') {
// es module extensions
return true;
// es module extensions - transform when useESModules is false
return !useESModules;
}
if (ext === 'js') {
// there may be false positives here
// but worst case scenario a commonjs file is transpiled to commonjs
if (sourceText.includes('import ') || sourceText.includes('import.') || sourceText.includes('import(')) {
return true;
}
if (sourceText.includes('export ')) {
return true;
if (useESModules) {
return sourceText.includes('require(');
} else {
// there may be false positives here
// but worst case scenario a commonjs file is transpiled to commonjs
if (sourceText.includes('import ') || sourceText.includes('import.') || sourceText.includes('import(')) {
return true;
}
if (sourceText.includes('export ')) {
return true;
}
}
}
if (ext === 'css') {
Expand Down
2 changes: 1 addition & 1 deletion src/testing/jest/jest-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export async function runJest(config: d.Config, env: d.E2EProcessEnv) {
}
config.logger.debug(`default timeout: ${env.__STENCIL_DEFAULT_TIMEOUT__}`);

// build up our args from our already know list of args in the config
// build up our args from our already known list of args in the config
const jestArgv = buildJestArgv(config);
// build up the project paths, which is basically the app's root dir
const projects = getProjectListFromCLIArgs(config, jestArgv);
Expand Down
20 changes: 12 additions & 8 deletions src/testing/jest/jest-setup-test-framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ export function jestSetupTestFramework() {
global.resourcesUrl = '/build';
});

const jasmineEnv = (jasmine as any).getEnv();
if (jasmineEnv != null) {
jasmineEnv.addReporter({
specStarted: (spec: any) => {
global.currentSpec = spec;
},
});
if (globalThis.jasmine) {
const jasmineEnv = (jasmine as any).getEnv();
if (jasmineEnv != null) {
jasmineEnv.addReporter({
specStarted: (spec: any) => {
global.currentSpec = spec;
},
});
}
}

global.screenshotDescriptions = new Set();
Expand All @@ -59,7 +61,9 @@ export function jestSetupTestFramework() {
if (typeof env.__STENCIL_DEFAULT_TIMEOUT__ === 'string') {
const time = parseInt(env.__STENCIL_DEFAULT_TIMEOUT__, 10);
jest.setTimeout(time * 1.5);
jasmine.DEFAULT_TIMEOUT_INTERVAL = time;
if (globalThis.jasmine) {
jasmine.DEFAULT_TIMEOUT_INTERVAL = time;
}
}
if (typeof env.__STENCIL_ENV__ === 'string') {
const stencilEnv = JSON.parse(env.__STENCIL_ENV__);
Expand Down
50 changes: 50 additions & 0 deletions src/testing/jest/test/jest-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ describe('jest-config', () => {
expect(jestArgv.maxWorkers).toBe(2);
});

it('--maxWorkers arg is set from config', () => {
const config = mockConfig();
config.testing = {};
config.maxConcurrentWorkers = 3;
config.flags = parseFlags(['test'], config.sys);

const jestArgv = buildJestArgv(config);
expect(jestArgv.maxWorkers).toBe(3);
});

it('--maxWorkers arg is not set from config when --runInBand is used', () => {
const config = mockConfig();
config.testing = {};
config.maxConcurrentWorkers = 3;

const args = ['test', '--runInBand'];
config.flags = parseFlags(args, config.sys);

const jestArgv = buildJestArgv(config);
expect(jestArgv.maxWorkers).toBeUndefined();
});

it('pass --ci arg to jest', () => {
const args = ['test', '--ci'];
const config = mockConfig();
Expand Down Expand Up @@ -178,4 +200,32 @@ describe('jest-config', () => {
expect(parsedConfig.collectCoverageFrom).toHaveLength(1);
expect(parsedConfig.collectCoverageFrom[0]).toBe('**/*.+(ts|tsx)');
});

it('useESModules: true sets ESM jest config', () => {
const config = mockConfig();
config.testing = {
useESModules: true,
};
config.flags = parseFlags(['test'], config.sys);

const jestArgv = buildJestArgv(config);
const parsedConfig = JSON.parse(jestArgv.config) as d.JestConfig;

expect(parsedConfig.extensionsToTreatAsEsm).toEqual(['.ts', '.tsx', '.jsx']);
expect(parsedConfig.globals.stencil.testing.useESModules).toBe(true);
});

it('useESModules: false does not set ESM jest config', () => {
const config = mockConfig();
config.testing = {
useESModules: false,
};
config.flags = parseFlags(['test'], config.sys);

const jestArgv = buildJestArgv(config);
const parsedConfig = JSON.parse(jestArgv.config) as d.JestConfig;

expect(parsedConfig.extensionsToTreatAsEsm).toBeUndefined();
expect(parsedConfig.globals.stencil.testing.useESModules).toBe(false);
});
});
52 changes: 38 additions & 14 deletions src/testing/jest/test/jest-preprocessor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
import { shouldTransform } from '../jest-preprocessor';

describe('jest preprocessor', () => {
it('shouldTransform', () => {
expect(shouldTransform('file.ts', '')).toBe(true);
expect(shouldTransform('file.d.ts', '')).toBe(true);
expect(shouldTransform('file.tsx', '')).toBe(true);
expect(shouldTransform('file.jsx', '')).toBe(true);
expect(shouldTransform('file.mjs', '')).toBe(true);
expect(shouldTransform('file.css', '')).toBe(true);
expect(shouldTransform('file.CsS', '')).toBe(true);
expect(shouldTransform('file.css?tag=my-cmp', '')).toBe(true);
describe('in CJS mode', () => {
it('shouldTransform', () => {
expect(shouldTransform('file.ts', '', false)).toBe(true);
expect(shouldTransform('file.d.ts', '', false)).toBe(true);
expect(shouldTransform('file.tsx', '', false)).toBe(true);
expect(shouldTransform('file.jsx', '', false)).toBe(true);
expect(shouldTransform('file.mjs', '', false)).toBe(true);
expect(shouldTransform('file.cjs', '', false)).toBe(false);
expect(shouldTransform('file.css', '', false)).toBe(true);
expect(shouldTransform('file.CsS', '', false)).toBe(true);
expect(shouldTransform('file.css?tag=my-cmp', '', false)).toBe(true);
});

it('shouldTransform js ext with es module imports/exports', () => {
expect(shouldTransform('file.js', 'import {} from "./file";', false)).toBe(true);
expect(shouldTransform('file.js', 'import.meta.url', false)).toBe(true);
expect(shouldTransform('file.js', 'export * from "./file";', false)).toBe(true);
expect(shouldTransform('file.js', 'console.log("hi")', false)).toBe(false);
});
});

it('shouldTransform js ext with es module imports/exports', () => {
expect(shouldTransform('file.js', 'import {} from "./file";')).toBe(true);
expect(shouldTransform('file.js', 'import.meta.url')).toBe(true);
expect(shouldTransform('file.js', 'export * from "./file";')).toBe(true);
expect(shouldTransform('file.js', 'console.log("hi")')).toBe(false);
describe('in ESM mode', () => {
it('shouldTransform', () => {
expect(shouldTransform('file.ts', '', true)).toBe(true);
expect(shouldTransform('file.d.ts', '', true)).toBe(true);
expect(shouldTransform('file.tsx', '', true)).toBe(true);
expect(shouldTransform('file.jsx', '', true)).toBe(true);
expect(shouldTransform('file.mjs', '', true)).toBe(false);
expect(shouldTransform('file.cjs', '', true)).toBe(false);
expect(shouldTransform('file.css', '', true)).toBe(true);
expect(shouldTransform('file.CsS', '', true)).toBe(true);
expect(shouldTransform('file.css?tag=my-cmp', '', true)).toBe(true);
});

it('shouldTransform returns false for js ext with es module imports/exports', () => {
expect(shouldTransform('file.js', 'import {} from "./file";', true)).toBe(false);
expect(shouldTransform('file.js', 'import.meta.url', true)).toBe(false);
expect(shouldTransform('file.js', 'export * from "./file";', true)).toBe(false);
expect(shouldTransform('file.js', 'console.log("hi")', true)).toBe(false);
});
});
});
1 change: 0 additions & 1 deletion src/testing/test-transpile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export function transpile(input: string, opts: TranspileOptions = {}): Transpile
componentMetadata: 'compilerstatic',
coreImportPath: isString(opts.coreImportPath) ? opts.coreImportPath : '@stencil/core/internal/testing',
currentDirectory: opts.currentDirectory || process.cwd(),
module: 'cjs', // always use commonjs since we're in a node environment
proxy: null,
sourceMap: 'inline',
style: null,
Expand Down
21 changes: 21 additions & 0 deletions test/jest-esm/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Jest config for running jest directly (using `node jest -c .../jest.config.js`, not using `node stencil test`).
* This approach can be used when stencil component tests are used alongside other framework tests, eg
* using [Jest projects]{@link https://jestjs.io/docs/configuration#projects-arraystring--projectconfig}.
*/

module.exports = {
displayName: 'Jest tests using ESM',
preset: '../../testing/jest-preset.js',
globals: {
stencil: {
testing: {
useESModules: true
}
}
},
moduleDirectories: [
'../../node_modules'
],
extensionsToTreatAsEsm: ['.ts', '.tsx', '.jsx'],
};
10 changes: 10 additions & 0 deletions test/jest-esm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@stencil/jest-esm",
"private": true,
"type": "module",
"scripts": {
"build": "node ../../bin/stencil build",
"test": "node --experimental-vm-modules --no-warnings ../../bin/stencil test --spec",
"jest": "node --experimental-vm-modules --no-warnings ../../node_modules/jest/bin/jest.js --maxWorkers=3"
}
}
Loading