diff --git a/README.md b/README.md index 9c62b47e18..b27b3ba9b2 100644 --- a/README.md +++ b/README.md @@ -329,9 +329,19 @@ By default Jest ignores everything in `node_modules`. This setting prevents Jest } ``` ### TS compiler && error reporting -- ts-jest only returns syntax errors from [tsc](https://github.com/Microsoft/TypeScript/issues/4864#issuecomment-141567247) -- Non syntactic errors do not show up in [jest](https://github.com/facebook/jest/issues/2168) -- If you only want to run jest if tsc does not output any errors, a workaround is `tsc --noEmit -p . && jest` +If you want to enable Syntactic & Semantic TypeScript error reporting you can enable this through `enableTsDiagnostics` flag; + +```json +{ + "jest": { + "globals": { + "ts-jest": { + "enableTsDiagnostics": true + } + } + } +} +``` ### Known Limitations for hoisting If the `jest.mock()` calls is placed after actual code, (e.g. after functions or classes) and `skipBabel` is not set, diff --git a/src/jest-types.ts b/src/jest-types.ts index 63b13fa8cf..3925fc7a46 100644 --- a/src/jest-types.ts +++ b/src/jest-types.ts @@ -74,4 +74,5 @@ export interface TsJestConfig { babelConfig?: BabelTransformOpts; tsConfigFile?: string; enableInternalCache?: boolean; + enableTsDiagnostics?: boolean; } diff --git a/src/preprocessor.ts b/src/preprocessor.ts index 55130f2fbe..82e4182615 100644 --- a/src/preprocessor.ts +++ b/src/preprocessor.ts @@ -7,6 +7,7 @@ import { cacheFile, getTSConfig, getTSJestConfig, + runTsDiagnostics, injectSourcemapHook, } from './utils'; @@ -41,14 +42,18 @@ export function process( return src; } + const tsJestConfig = getTSJestConfig(jestConfig.globals); + logOnce('tsJestConfig: ', tsJestConfig); + + if (tsJestConfig.enableTsDiagnostics) { + runTsDiagnostics(filePath, compilerOptions); + } + const tsTranspiled = tsc.transpileModule(src, { compilerOptions, fileName: filePath, }); - const tsJestConfig = getTSJestConfig(jestConfig.globals); - logOnce('tsJestConfig: ', tsJestConfig); - const postHook = getPostProcessHook( compilerOptions, jestConfig, diff --git a/src/utils.ts b/src/utils.ts index 8fe9ffbacb..a9911bf406 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,209 +1,238 @@ -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as fsExtra from 'fs-extra'; -import * as path from 'path'; -import * as tsc from 'typescript'; -import { JestConfig, TsJestConfig } from './jest-types'; -import { logOnce } from './logger'; - -export function getTSJestConfig(globals: any): TsJestConfig { - return globals && globals['ts-jest'] ? globals['ts-jest'] : {}; -} - -function formatTscParserErrors(errors: tsc.Diagnostic[]) { - return errors.map(s => JSON.stringify(s, null, 4)).join('\n'); -} - -function readCompilerOptions(configPath: string) { - configPath = path.resolve(configPath); - - // First step: Let tsc pick up the config. - const loaded = tsc.readConfigFile(configPath, file => { - const read = tsc.sys.readFile(file); - // See - // https://github.com/Microsoft/TypeScript/blob/a757e8428410c2196886776785c16f8f0c2a62d9/src/compiler/sys.ts#L203 : - // `readFile` returns `undefined` in case the file does not exist! - if (!read) { - throw new Error( - `ENOENT: no such file or directory, open '${configPath}'`, - ); - } - return read; - }); - // In case of an error, we cannot go further - the config is malformed. - if (loaded.error) { - throw new Error(JSON.stringify(loaded.error, null, 4)); - } - - // Second step: Parse the config, resolving all potential references. - const basePath = path.dirname(configPath); // equal to "getDirectoryPath" from ts, at least in our case. - const parsedConfig = tsc.parseJsonConfigFileContent( - loaded.config, - tsc.sys, - basePath, - ); - // In case the config is present, it already contains possibly merged entries from following the - // 'extends' entry, thus it is not required to follow it manually. - // This procedure does NOT throw, but generates a list of errors that can/should be evaluated. - if (parsedConfig.errors.length > 0) { - const formattedErrors = formatTscParserErrors(parsedConfig.errors); - throw new Error( - `Some errors occurred while attempting to read from ${configPath}: ${formattedErrors}`, - ); - } - return parsedConfig.options; -} - -function getStartDir(): string { - // This is needed because of the way our tests are structured. - // If this is being executed as a library (under node_modules) - // we want to start with the project directory that's three - // levels above. - // If t his is being executed from the test suite, we want to start - // in the directory of the test - - const grandparent = path.resolve(__dirname, '..', '..'); - if (grandparent.endsWith('/node_modules')) { - return process.cwd(); - } - - return '.'; -} - -function getPathToClosestTSConfig( - startDir?: string, - previousDir?: string, -): string { - // Starting with the startDir directory and moving on to the - // parent directory recursively (going no further than the root directory) - // find and return the path to the first encountered tsconfig.json file - - if (!startDir) { - return getPathToClosestTSConfig(getStartDir()); - } - - const tsConfigPath = path.join(startDir, 'tsconfig.json'); - - const startDirPath = path.resolve(startDir); - const previousDirPath = path.resolve(previousDir || '/'); - - if (startDirPath === previousDirPath || fs.existsSync(tsConfigPath)) { - return tsConfigPath; - } - - return getPathToClosestTSConfig(path.join(startDir, '..'), startDir); -} - -// TODO: This can take something more specific than globals -function getTSConfigPathFromConfig(globals: any): string { - const defaultTSConfigFile = getPathToClosestTSConfig(); - if (!globals) { - return defaultTSConfigFile; - } - - const tsJestConfig = getTSJestConfig(globals); - - if (tsJestConfig.tsConfigFile) { - return tsJestConfig.tsConfigFile; - } - - return defaultTSConfigFile; -} - -export function mockGlobalTSConfigSchema(globals: any) { - const configPath = getTSConfigPathFromConfig(globals); - return { 'ts-jest': { tsConfigFile: configPath } }; -} - -const tsConfigCache: { [key: string]: any } = {}; -// TODO: Perhaps rename collectCoverage to here, as it seems to be the official jest name now: -// https://github.com/facebook/jest/issues/3524 -export function getTSConfig(globals, collectCoverage: boolean = false) { - let configPath = getTSConfigPathFromConfig(globals); - logOnce(`Reading tsconfig file from path ${configPath}`); - const skipBabel = getTSJestConfig(globals).skipBabel; - - // check cache before resolving configuration - // NB: We use JSON.stringify() to create a consistent, unique signature. Although it lacks a uniform - // shape, this is simpler and faster than using the crypto package to generate a hash signature. - const tsConfigCacheKey = JSON.stringify([ - skipBabel, - collectCoverage, - configPath, - ]); - if (tsConfigCacheKey in tsConfigCache) { - return tsConfigCache[tsConfigCacheKey]; - } - - const config = readCompilerOptions(configPath); - logOnce('Original typescript config before modifications: ', config); - - // ts-jest will map lines numbers properly if inlineSourceMap and - // inlineSources are set to true. For testing, we don't need the - // sourceMap configuration - delete config.sourceMap; - config.inlineSourceMap = true; - config.inlineSources = true; - - // the coverage report is broken if `.outDir` is set - // see https://github.com/kulshekhar/ts-jest/issues/201 - // `.outDir` is removed even for test files as it affects with breakpoints - // see https://github.com/kulshekhar/ts-jest/issues/309 - delete config.outDir; - - if (configPath === path.join(getStartDir(), 'tsconfig.json')) { - // hardcode module to 'commonjs' in case the config is being loaded - // from the default tsconfig file. This is to ensure that coverage - // works well. If there's a need to override, it can be done using - // a custom tsconfig for testing - config.module = tsc.ModuleKind.CommonJS; - } - - config.module = config.module || tsc.ModuleKind.CommonJS; - config.jsx = config.jsx || tsc.JsxEmit.React; - - if (config.allowSyntheticDefaultImports && !skipBabel) { - // compile ts to es2015 and transform with babel afterwards - config.module = tsc.ModuleKind.ES2015; - } - - // cache result for future requests - tsConfigCache[tsConfigCacheKey] = config; - return config; -} - -export function cacheFile( - jestConfig: JestConfig, - filePath: string, - src: string, -) { - // store transpiled code contains source map into cache, except test cases - if (!jestConfig.testRegex || !filePath.match(jestConfig.testRegex)) { - const outputFilePath = path.join( - jestConfig.cacheDirectory, - '/ts-jest/', - crypto - .createHash('md5') - .update(filePath) - .digest('hex'), - ); - - fsExtra.outputFileSync(outputFilePath, src); - } -} - -export function injectSourcemapHook( - filePath: string, - typeScriptCode: string, - src: string, -): string { - const start = src.length > 12 ? src.substr(1, 10) : ''; - - const filePathParam = JSON.stringify(filePath); - const codeParam = JSON.stringify(typeScriptCode); - const sourceMapHook = `require('ts-jest').install(${filePathParam}, ${codeParam})`; - - return start === 'use strict' - ? `'use strict';${sourceMapHook};${src}` - : `${sourceMapHook};${src}`; -} +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as fsExtra from 'fs-extra'; +import * as path from 'path'; +import * as tsc from 'typescript'; +import { JestConfig, TsJestConfig } from './jest-types'; +import { logOnce } from './logger'; + +export function getTSJestConfig(globals: any): TsJestConfig { + return globals && globals['ts-jest'] ? globals['ts-jest'] : {}; +} + +function formatTscParserErrors(errors: tsc.Diagnostic[]) { + return errors.map(s => JSON.stringify(s, null, 4)).join('\n'); +} + +function readCompilerOptions(configPath: string) { + configPath = path.resolve(configPath); + + // First step: Let tsc pick up the config. + const loaded = tsc.readConfigFile(configPath, file => { + const read = tsc.sys.readFile(file); + // See + // https://github.com/Microsoft/TypeScript/blob/a757e8428410c2196886776785c16f8f0c2a62d9/src/compiler/sys.ts#L203 : + // `readFile` returns `undefined` in case the file does not exist! + if (!read) { + throw new Error( + `ENOENT: no such file or directory, open '${configPath}'`, + ); + } + return read; + }); + // In case of an error, we cannot go further - the config is malformed. + if (loaded.error) { + throw new Error(JSON.stringify(loaded.error, null, 4)); + } + + // Second step: Parse the config, resolving all potential references. + const basePath = path.dirname(configPath); // equal to "getDirectoryPath" from ts, at least in our case. + const parsedConfig = tsc.parseJsonConfigFileContent( + loaded.config, + tsc.sys, + basePath, + ); + // In case the config is present, it already contains possibly merged entries from following the + // 'extends' entry, thus it is not required to follow it manually. + // This procedure does NOT throw, but generates a list of errors that can/should be evaluated. + if (parsedConfig.errors.length > 0) { + const formattedErrors = formatTscParserErrors(parsedConfig.errors); + throw new Error( + `Some errors occurred while attempting to read from ${configPath}: ${formattedErrors}`, + ); + } + return parsedConfig.options; +} + +function getStartDir(): string { + // This is needed because of the way our tests are structured. + // If this is being executed as a library (under node_modules) + // we want to start with the project directory that's three + // levels above. + // If t his is being executed from the test suite, we want to start + // in the directory of the test + + const grandparent = path.resolve(__dirname, '..', '..'); + if (grandparent.endsWith('/node_modules')) { + return process.cwd(); + } + + return '.'; +} + +function getPathToClosestTSConfig( + startDir?: string, + previousDir?: string, +): string { + // Starting with the startDir directory and moving on to the + // parent directory recursively (going no further than the root directory) + // find and return the path to the first encountered tsconfig.json file + + if (!startDir) { + return getPathToClosestTSConfig(getStartDir()); + } + + const tsConfigPath = path.join(startDir, 'tsconfig.json'); + + const startDirPath = path.resolve(startDir); + const previousDirPath = path.resolve(previousDir || '/'); + + if (startDirPath === previousDirPath || fs.existsSync(tsConfigPath)) { + return tsConfigPath; + } + + return getPathToClosestTSConfig(path.join(startDir, '..'), startDir); +} + +// TODO: This can take something more specific than globals +function getTSConfigPathFromConfig(globals: any): string { + const defaultTSConfigFile = getPathToClosestTSConfig(); + if (!globals) { + return defaultTSConfigFile; + } + + const tsJestConfig = getTSJestConfig(globals); + + if (tsJestConfig.tsConfigFile) { + return tsJestConfig.tsConfigFile; + } + + return defaultTSConfigFile; +} + +export function mockGlobalTSConfigSchema(globals: any) { + const configPath = getTSConfigPathFromConfig(globals); + return { 'ts-jest': { tsConfigFile: configPath } }; +} + +const tsConfigCache: { [key: string]: any } = {}; +// TODO: Perhaps rename collectCoverage to here, as it seems to be the official jest name now: +// https://github.com/facebook/jest/issues/3524 +export function getTSConfig(globals, collectCoverage: boolean = false) { + let configPath = getTSConfigPathFromConfig(globals); + logOnce(`Reading tsconfig file from path ${configPath}`); + const skipBabel = getTSJestConfig(globals).skipBabel; + + // check cache before resolving configuration + // NB: We use JSON.stringify() to create a consistent, unique signature. Although it lacks a uniform + // shape, this is simpler and faster than using the crypto package to generate a hash signature. + const tsConfigCacheKey = JSON.stringify([ + skipBabel, + collectCoverage, + configPath, + ]); + if (tsConfigCacheKey in tsConfigCache) { + return tsConfigCache[tsConfigCacheKey]; + } + + const config = readCompilerOptions(configPath); + logOnce('Original typescript config before modifications: ', config); + + // ts-jest will map lines numbers properly if inlineSourceMap and + // inlineSources are set to true. For testing, we don't need the + // sourceMap configuration + delete config.sourceMap; + config.inlineSourceMap = true; + config.inlineSources = true; + + // the coverage report is broken if `.outDir` is set + // see https://github.com/kulshekhar/ts-jest/issues/201 + // `.outDir` is removed even for test files as it affects with breakpoints + // see https://github.com/kulshekhar/ts-jest/issues/309 + delete config.outDir; + + if (configPath === path.join(getStartDir(), 'tsconfig.json')) { + // hardcode module to 'commonjs' in case the config is being loaded + // from the default tsconfig file. This is to ensure that coverage + // works well. If there's a need to override, it can be done using + // a custom tsconfig for testing + config.module = tsc.ModuleKind.CommonJS; + } + + config.module = config.module || tsc.ModuleKind.CommonJS; + config.jsx = config.jsx || tsc.JsxEmit.React; + + if (config.allowSyntheticDefaultImports && !skipBabel) { + // compile ts to es2015 and transform with babel afterwards + config.module = tsc.ModuleKind.ES2015; + } + + // cache result for future requests + tsConfigCache[tsConfigCacheKey] = config; + return config; +} + +export function cacheFile( + jestConfig: JestConfig, + filePath: string, + src: string, +) { + // store transpiled code contains source map into cache, except test cases + if (!jestConfig.testRegex || !filePath.match(jestConfig.testRegex)) { + const outputFilePath = path.join( + jestConfig.cacheDirectory, + '/ts-jest/', + crypto + .createHash('md5') + .update(filePath) + .digest('hex'), + ); + + fsExtra.outputFileSync(outputFilePath, src); + } +} + +export function injectSourcemapHook( + filePath: string, + typeScriptCode: string, + src: string, +): string { + const start = src.length > 12 ? src.substr(1, 10) : ''; + + const filePathParam = JSON.stringify(filePath); + const codeParam = JSON.stringify(typeScriptCode); + const sourceMapHook = `require('ts-jest').install(${filePathParam}, ${codeParam})`; + + return start === 'use strict' + ? `'use strict';${sourceMapHook};${src}` + : `${sourceMapHook};${src}`; +} + +export function runTsDiagnostics( + filePath: string, + compilerOptions: tsc.CompilerOptions, +) { + const program = tsc.createProgram([filePath], compilerOptions); + const allDiagnostics = tsc.getPreEmitDiagnostics(program); + const formattedDiagnostics = allDiagnostics.map(diagnostic => { + if (diagnostic.file) { + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition( + diagnostic.start, + ); + const message = tsc.flattenDiagnosticMessageText( + diagnostic.messageText, + '\n', + ); + return `${path.relative( + process.cwd(), + diagnostic.file.fileName, + )} (${line + 1},${character + 1}): ${message}\n`; + } + + return `${tsc.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`; + }); + + if (formattedDiagnostics.length) { + throw new Error(formattedDiagnostics.join('')); + } +} diff --git a/tests/__tests__/ts-diagnostics.spec.ts b/tests/__tests__/ts-diagnostics.spec.ts new file mode 100644 index 0000000000..c65ef539ee --- /dev/null +++ b/tests/__tests__/ts-diagnostics.spec.ts @@ -0,0 +1,13 @@ +import runJest from '../__helpers__/runJest'; + +describe('TypeScript Diagnostics errors', () => { + it('should show the correct error locations in the typescript files', () => { + const result = runJest('../ts-diagnostics', ['--no-cache']); + expect(result.stderr).toContain( + `Hello.ts (2,10): Property 'push' does not exist on type`, + ); + expect(result.stderr).toContain( + `Hello.ts (13,10): Property 'unexcuted' does not exist on type`, + ); + }); +}); diff --git a/tests/ts-diagnostics/Hello.ts b/tests/ts-diagnostics/Hello.ts new file mode 100644 index 0000000000..e37511ff0a --- /dev/null +++ b/tests/ts-diagnostics/Hello.ts @@ -0,0 +1,17 @@ +export class Hello { + x = ''.push(); + + constructor() { + const greeting = ` + this + is + a + multiline + greeting + `; + + this.unexcuted(() => {}); + + throw new Error('Hello error!'); + } +} diff --git a/tests/ts-diagnostics/__tests__/Hello.test.ts b/tests/ts-diagnostics/__tests__/Hello.test.ts new file mode 100644 index 0000000000..407c675049 --- /dev/null +++ b/tests/ts-diagnostics/__tests__/Hello.test.ts @@ -0,0 +1,7 @@ +import { Hello } from '../Hello'; + +describe('Hello Class', () => { + it('should throw an error', () => { + const hello = new Hello(); + }); +}); diff --git a/tests/ts-diagnostics/package.json b/tests/ts-diagnostics/package.json new file mode 100644 index 0000000000..569aeaa778 --- /dev/null +++ b/tests/ts-diagnostics/package.json @@ -0,0 +1,24 @@ +{ + "jest": { + "transform": { + "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" + }, + "moduleDirectories": [ + "node_modules", + "src" + ], + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "jsx", + "json" + ], + "globals": { + "ts-jest": { + "enableTsDiagnostics": true + } + } + } +} diff --git a/tests/ts-diagnostics/tsconfig.json b/tests/ts-diagnostics/tsconfig.json new file mode 100644 index 0000000000..086ac83ced --- /dev/null +++ b/tests/ts-diagnostics/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES5", + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": false + } +}