diff --git a/e2e/__cases__/allow-js/tsconfig.json b/e2e/__cases__/allow-js/tsconfig.json index 26f31aa261..c052135751 100644 --- a/e2e/__cases__/allow-js/tsconfig.json +++ b/e2e/__cases__/allow-js/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es5", - "allowJs": true + "allowJs": true, + "outDir": "$$ts-jest$$" } } diff --git a/e2e/__monorepos__/simple/with-dependency/package.json b/e2e/__monorepos__/simple/with-dependency/package.json index d0c64b6486..1f4d55b21a 100644 --- a/e2e/__monorepos__/simple/with-dependency/package.json +++ b/e2e/__monorepos__/simple/with-dependency/package.json @@ -31,7 +31,8 @@ "globals": { "ts-jest": { "diagnostics": true, - "tsConfig": "/tsconfig.json" + "tsConfig": "/tsconfig.json", + "compilerHost": true } } }, diff --git a/e2e/__monorepos__/simple/with-dependency/tsconfig.json b/e2e/__monorepos__/simple/with-dependency/tsconfig.json index e2a32abfa8..65f41ccd82 100644 --- a/e2e/__monorepos__/simple/with-dependency/tsconfig.json +++ b/e2e/__monorepos__/simple/with-dependency/tsconfig.json @@ -13,7 +13,8 @@ "downlevelIteration": true, "strict": true, "moduleResolution": "node", - "esModuleInterop": true + "esModuleInterop": true, + "incremental": true }, "include": [ "./src" diff --git a/e2e/__tests__/__snapshots__/logger.test.ts.snap b/e2e/__tests__/__snapshots__/logger.test.ts.snap index 82c1d4ab21..6ffc58bce4 100644 --- a/e2e/__tests__/__snapshots__/logger.test.ts.snap +++ b/e2e/__tests__/__snapshots__/logger.test.ts.snap @@ -19,21 +19,21 @@ Array [ "[level:20] readTsConfig(): reading /tsconfig.json", "[level:20] normalized typescript config", "[level:20] processing /Hello.spec.ts", - "[level:20] creating typescript compiler (language service)", "[level:20] file caching disabled", - "[level:20] creating language service", + "[level:20] compileUsingLanguageService(): creating language service", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", + "[level:20] compiler rebuilt Program instance when getting output", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", "[level:20] computing cache key for /Hello.ts", "[level:20] processing /Hello.ts", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", ] `; @@ -60,22 +60,22 @@ Array [ "[level:20] loaded module babel-jest", "[level:20] patching babel-jest", "[level:20] checking version of babel-jest: OK", - "[level:20] creating typescript compiler (language service)", "[level:20] file caching disabled", - "[level:20] creating language service", + "[level:20] compileUsingLanguageService(): creating language service", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", + "[level:20] compiler rebuilt Program instance when getting output", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", "[level:20] calling babel-jest processor", "[level:20] computing cache key for /Hello.ts", "[level:20] processing /Hello.ts", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", "[level:20] calling babel-jest processor", ] `; @@ -104,22 +104,22 @@ Array [ "[level:20] loaded module babel-jest", "[level:20] patching babel-jest", "[level:20] checking version of babel-jest: OK", - "[level:20] creating typescript compiler (language service)", "[level:20] file caching disabled", - "[level:20] creating language service", + "[level:20] compileUsingLanguageService(): creating language service", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", + "[level:20] compiler rebuilt Program instance when getting output", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", "[level:20] calling babel-jest processor", "[level:20] computing cache key for /Hello.ts", "[level:20] processing /Hello.ts", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", "[level:20] calling babel-jest processor", ] `; @@ -143,21 +143,21 @@ Array [ "[level:20] readTsConfig(): reading /tsconfig.json", "[level:20] normalized typescript config", "[level:20] processing /Hello.spec.ts", - "[level:20] creating typescript compiler (language service)", "[level:20] file caching disabled", - "[level:20] creating language service", + "[level:20] compileUsingLanguageService(): creating language service", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", + "[level:20] compiler rebuilt Program instance when getting output", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", "[level:20] computing cache key for /Hello.ts", "[level:20] processing /Hello.ts", "[level:20] readThrough(): no cache", - "[level:20] getOutput(): compiling using language service", - "[level:20] updateMemoryCache()", + "[level:20] updateMemoryCache() for language service", "[level:20] visitSourceFileNode(): hoisting", - "[level:20] getOutput(): computing diagnostics", + "[level:20] getOutput(): computing diagnostics for language service", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true", ] `; diff --git a/package.json b/package.json index e437ec792a..41e64c1b86 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,6 @@ ] }, "engines": { - "node": ">= 6" + "node": ">= 8" } } diff --git a/src/__helpers__/fakers.ts b/src/__helpers__/fakers.ts index a803c481ac..c2e23369a6 100644 --- a/src/__helpers__/fakers.ts +++ b/src/__helpers__/fakers.ts @@ -1,6 +1,8 @@ import { Config } from '@jest/types' import { resolve } from 'path' +import { createCompiler } from '../compiler/instance' +import { ConfigSet } from '../config/config-set' import { BabelConfig, TsJestConfig, TsJestGlobalOptions } from '../types' import { ImportReasons } from '../util/messages' @@ -10,52 +12,10 @@ export function filePath(relPath: string): string { export const rootDir = filePath('') -export function transpiledTsSource() { - return ` -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -var upper_1 = __importDefault(require("./upper")); -var lower_1 = __importDefault(require("./lower")); -jest.mock('./upper', function () { return function (s) { return s.toUpperCase(); }; }); -describe('hello', function () { - test('my test', function () { - expect(upper_1.default('hello')).toBe('HELLO'); - expect(lower_1.default('HELLO')).toBe('hello'); - jest.mock('./lower', function () { return function (s) { return s.toLowerCase(); }; }); - }); -}); -` -} - -export function htmlSource() { - return ` -
- some text with \`backtick\` -
-` -} - -export function typescriptSource() { - return ` -import upper from './upper'; -import lower from './lower'; - -jest.mock('./upper', () => (s) => s.toUpperCase()); - -describe('hello', () => { - test('my test', () => { - expect(upper('hello')).toBe('HELLO'); - expect(lower('HELLO')).toBe('hello'); - jest.mock('./lower', () => (s) => s.toLowerCase()); - }); -}); -` -} - export function tsJestConfig(options?: Partial): TsJestConfig { return { + compilerHost: false, + emit: false, isolatedModules: false, compiler: 'typescript', transformers: [], @@ -68,7 +28,7 @@ export function tsJestConfig(options?: Partial): TsJestConfig { } } -export function jestConfig( +export function getJestConfig( options?: Partial, tsJestOptions?: TsJestGlobalOptions, ): T { @@ -93,3 +53,23 @@ export function babelConfig(options?: BabelConfig): T { export function importReason(text = '[[BECAUSE]]'): ImportReasons { return text as any } + +// not really unit-testing here, but it's hard to mock all those values :-D +export function makeCompiler({ + jestConfig, + tsJestConfig, + parentConfig, +}: { + jestConfig?: Partial + tsJestConfig?: TsJestGlobalOptions + parentConfig?: TsJestGlobalOptions +} = {}) { + tsJestConfig = { ...tsJestConfig } + tsJestConfig.diagnostics = { + ...(tsJestConfig.diagnostics as any), + pretty: false, + } + const cs = new ConfigSet(getJestConfig(jestConfig, tsJestConfig), parentConfig) + + return createCompiler(cs) +} diff --git a/src/__snapshots__/compiler.spec.ts.snap b/src/__snapshots__/compiler.spec.ts.snap deleted file mode 100644 index f6496bce1d..0000000000 --- a/src/__snapshots__/compiler.spec.ts.snap +++ /dev/null @@ -1,53 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`allowJs should compile js file 1`] = ` - ===[ FILE: src/compiler.spec.ts.test.js ]======================================= - "use strict"; - Object.defineProperty(exports, "__esModule", { value: true }); - exports.default = 42; - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoiPGN3ZD4vc3JjL2NvbXBpbGVyLnNwZWMudHMudGVzdC5qcyIsIm1hcHBpbmdzIjoiOztBQUFBLGtCQUFlLEVBQUUsQ0FBQSIsIm5hbWVzIjpbXSwic291cmNlcyI6WyI8Y3dkPi9zcmMvY29tcGlsZXIuc3BlYy50cy50ZXN0LmpzIl0sInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCBkZWZhdWx0IDQyIl0sInZlcnNpb24iOjN9 - ===[ INLINE SOURCE MAPS ]======================================================= - file: /src/compiler.spec.ts.test.js - mappings: ';;AAAA,kBAAe,EAAE,CAAA' - names: [] - sources: - - /src/compiler.spec.ts.test.js - sourcesContent: - - export default 42 - version: 3 - ================================================================================ -`; - -exports[`cache should use the cache 3`] = ` - ===[ FILE: src/compiler.spec.ts ]=============================================== - console.log("hello"); - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoiPGN3ZD4vc3JjL2NvbXBpbGVyLnNwZWMudHMiLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxDQUFDLEdBQUcsQ0FBQyxPQUFPLENBQUMsQ0FBQSIsIm5hbWVzIjpbXSwic291cmNlcyI6WyI8Y3dkPi9zcmMvY29tcGlsZXIuc3BlYy50cyJdLCJzb3VyY2VzQ29udGVudCI6WyJjb25zb2xlLmxvZyhcImhlbGxvXCIpIl0sInZlcnNpb24iOjN9 - ===[ INLINE SOURCE MAPS ]======================================================= - file: /src/compiler.spec.ts - mappings: 'AAAA,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA' - names: [] - sources: - - /src/compiler.spec.ts - sourcesContent: - - console.log("hello") - version: 3 - ================================================================================ -`; - -exports[`isolatedModules should compile using transpileModule 1`] = ` - ===[ FILE: src/compiler.spec.ts ]=============================================== - "use strict"; - Object.defineProperty(exports, "__esModule", { value: true }); - exports.default = 42; - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoiPGN3ZD4vc3JjL2NvbXBpbGVyLnNwZWMudHMiLCJtYXBwaW5ncyI6Ijs7QUFBQSxrQkFBZSxFQUFFLENBQUEiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiPGN3ZD4vc3JjL2NvbXBpbGVyLnNwZWMudHMiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgNDIiXSwidmVyc2lvbiI6M30= - ===[ INLINE SOURCE MAPS ]======================================================= - file: /src/compiler.spec.ts - mappings: ';;AAAA,kBAAe,EAAE,CAAA' - names: [] - sources: - - /src/compiler.spec.ts - sourcesContent: - - export default 42 - version: 3 - ================================================================================ -`; diff --git a/src/compiler.spec.ts b/src/compiler.spec.ts deleted file mode 100644 index 4217605c88..0000000000 --- a/src/compiler.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -// tslint:disable:max-line-length -import { Config } from '@jest/types' -import { LogLevels } from 'bs-logger' -import { removeSync, writeFileSync } from 'fs-extra' - -import * as fakers from './__helpers__/fakers' -import { logTargetMock } from './__helpers__/mocks' -import { tempDir } from './__helpers__/path' -import ProcessedSource from './__helpers__/processed-source' -import { createCompiler } from './compiler' -import { ConfigSet } from './config/config-set' -import { TsJestGlobalOptions } from './types' - -const logTarget = logTargetMock() - -// not really unit-testing here, but it's hard to mock all those values :-D -function makeCompiler({ - jestConfig, - tsJestConfig, - parentConfig, -}: { - jestConfig?: Partial - tsJestConfig?: TsJestGlobalOptions - parentConfig?: TsJestGlobalOptions -} = {}) { - tsJestConfig = { ...tsJestConfig } - tsJestConfig.diagnostics = { - ...(tsJestConfig.diagnostics as any), - pretty: false, - } - const cs = new ConfigSet(fakers.jestConfig(jestConfig, tsJestConfig), parentConfig) - return createCompiler(cs) -} - -beforeEach(() => { - logTarget.clear() -}) - -describe('typings', () => { - const compiler = makeCompiler({ tsJestConfig: { tsConfig: false } }) - it('should report diagnostics related to typings', () => { - expect(() => - compiler.compile( - ` -const f = (v: number) => v -const t: string = f(5) -const v: boolean = t -`, - 'foo.ts', - ), - ).toThrowErrorMatchingInlineSnapshot(` -"TypeScript diagnostics (customize using \`[jest-config].globals.ts-jest.diagnostics\` option): -foo.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'. -foo.ts(4,7): error TS2322: Type 'string' is not assignable to type 'boolean'." -`) - }) -}) - -describe('source-maps', () => { - const compiler = makeCompiler({ tsJestConfig: { tsConfig: false } }) - it('should have correct source maps', () => { - const source = 'const f = (v: number) => v\nconst t: number = f(5)' - const compiled = compiler.compile(source, __filename) - const processed = new ProcessedSource(compiled, __filename) - // const expectedFileName = relativeToRoot(__filename) - const expectedFileName = __filename - expect(processed.outputSourceMaps).toMatchObject({ - file: expectedFileName, - sources: [expectedFileName], - sourcesContent: [source], - }) - }) -}) - -describe('cache', () => { - const tmp = tempDir('compiler') - const compiler = makeCompiler({ - jestConfig: { cache: true, cacheDirectory: tmp }, - tsJestConfig: { tsConfig: false }, - }) - const source = 'console.log("hello")' - - it('should use the cache', () => { - const compiled1 = compiler.compile(source, __filename) - expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchInlineSnapshot(` -Array [ - "[level:20] readThrough(): cache miss -", - "[level:20] getOutput(): compiling using language service -", - "[level:20] updateMemoryCache() -", - "[level:20] visitSourceFileNode(): hoisting -", - "[level:20] getOutput(): computing diagnostics -", - "[level:20] readThrough(): writing caches -", -] -`) - - logTarget.clear() - const compiled2 = compiler.compile(source, __filename) - expect(logTarget.lines).toMatchInlineSnapshot(` -Array [ - "[level:20] readThrough(): cache hit -", -] -`) - - expect(new ProcessedSource(compiled1, __filename)).toMatchSnapshot() - expect(compiled2).toBe(compiled1) - }) -}) - -describe('isolatedModules', () => { - const compiler = makeCompiler({ tsJestConfig: { isolatedModules: true, tsConfig: false } }) - const spy = jest.spyOn(require('typescript'), 'transpileModule') - afterAll(() => { - spy.mockRestore() - }) - it('should compile using transpileModule', () => { - const compiled = compiler.compile('export default 42', __filename) - expect(new ProcessedSource(compiled, __filename)).toMatchSnapshot() - expect(spy).toHaveBeenCalled() - }) -}) - -describe('allowJs', () => { - const compiler = makeCompiler({ tsJestConfig: { tsConfig: { allowJs: true } } }) - const fileName = `${__filename}.test.js` - afterAll(() => { - removeSync(fileName) - }) - it('should compile js file', () => { - const source = 'export default 42' - writeFileSync(fileName, source, 'utf8') - const compiled = compiler.compile(source, fileName) - const processed = new ProcessedSource(compiled, fileName) - expect(processed).toMatchSnapshot() - }) -}) - -describe('getTypeInfo', () => { - const compiler = makeCompiler({ tsJestConfig: { tsConfig: false } }) - const source = ` -type MyType { - /** the prop 1! */ - p1: boolean -} -const val: MyType = {} as any -console.log(val.p1/* <== that */) -` - it('should get correct type info', () => { - const ti = compiler.getTypeInfo(source, __filename, source.indexOf('/* <== that */') - 1) - // before TS 3.1 the comment had an extra tailing space - ti.comment = ti.comment.trim() - expect(ti).toEqual({ - comment: 'the prop 1!', - name: '(property) p1: boolean', - }) - }) -}) diff --git a/src/compiler.ts b/src/compiler.ts deleted file mode 100644 index 484ab17414..0000000000 --- a/src/compiler.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * This code is heavilly inspired from - * https://github.com/JsCommunity/make-error/blob/v1.3.4/index.js - * ...but more modified than expected :-D - * Below is the original license anyway: - * - * --- - * - * The MIT License (MIT) - * - * Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -import { LogContexts, LogLevels, Logger } from 'bs-logger' -import bufferFrom = require('buffer-from') -import stableStringify = require('fast-json-stable-stringify') -import { readFileSync, writeFileSync } from 'fs' -import { defaults as JestDefaults, replaceRootDirInPath } from 'jest-config/build' -import memoize = require('lodash.memoize') -import mkdirp = require('mkdirp') -import { basename, extname, join, normalize, relative } from 'path' -import { LanguageServiceHost } from 'typescript' - -import { ConfigSet } from './config/config-set' -import { MemoryCache, TsCompiler, TypeInfo } from './types' -import { Errors, interpolate } from './util/messages' -import { sha1 } from './util/sha1' - -const hasOwn = Object.prototype.hasOwnProperty - -/** - * Register TypeScript compiler. - * @internal - */ -export function createCompiler(configs: ConfigSet): TsCompiler { - const logger = configs.logger.child({ namespace: 'ts-compiler' }) - logger.debug( - 'creating typescript compiler', - configs.tsJest.isolatedModules ? '(isolated modules)' : '(language service)', - ) - const cachedir = configs.tsCacheDir - - const memoryCache: MemoryCache = { - contents: Object.create(null), - versions: Object.create(null), - outputs: Object.create(null), - } - - // Require the TypeScript compiler and configuration. - const ts = configs.compilerModule - const cwd = configs.cwd - - const extensions = ['.ts', '.tsx'] - const { - typescript: { options: compilerOptions, fileNames }, - } = configs - - // Enable `allowJs` when flag is set. - if (compilerOptions.allowJs) { - extensions.push('.js') - extensions.push('.jsx') - } - - // Initialize files from TypeScript into project. - for (const path of fileNames) memoryCache.versions[path] = 1 - - /** - * Get the extension for a transpiled file. - */ - const getExtension = - compilerOptions.jsx === ts.JsxEmit.Preserve - ? (path: string) => (/\.[tj]sx$/.test(path) ? '.jsx' : '.js') - : (_: string) => '.js' - - const transformers = configs.tsCustomTransformers - - /** - * Create the basic required function using transpile mode. - */ - let getOutput = (code: string, fileName: string /* , lineOffset = 0 */): SourceOutput => { - logger.debug({ fileName }, 'getOutput(): compiling as isolated module') - const result = ts.transpileModule(code, { - fileName, - transformers, - compilerOptions, - reportDiagnostics: configs.shouldReportDiagnostic(fileName), - }) - - if (result.diagnostics) configs.raiseDiagnostics(result.diagnostics, fileName, logger) - - return [result.outputText, result.sourceMapText as string] - } - - let getTypeInfo = (_code: string, _fileName: string, _position: number): TypeInfo => { - throw new TypeError(Errors.TypesUnavailableWithoutTypeCheck) - } - - // Use full language services when the fast option is disabled. - if (!configs.tsJest.isolatedModules) { - // Set the file contents into cache. - const updateMemoryCache = (code: string, fileName: string) => { - logger.debug({ fileName }, `updateMemoryCache()`) - if (memoryCache.contents[fileName] !== code) { - memoryCache.contents[fileName] = code - memoryCache.versions[fileName] = (memoryCache.versions[fileName] || 0) + 1 - } - } - - // Create the compiler host for type checking. - const serviceHostDebugCtx = { - [LogContexts.logLevel]: LogLevels.debug, - namespace: 'ts:serviceHost', - call: null, - } - const serviceHostTraceCtx = { - ...serviceHostDebugCtx, - [LogContexts.logLevel]: LogLevels.trace, - } - - const transformIgnorePattern = (configs.jest.transformIgnorePatterns || JestDefaults.transformIgnorePatterns) - .map(pattern => replaceRootDirInPath(configs.rootDir, pattern)) - .join('|') - const transformIgnoreRegExp = new RegExp(transformIgnorePattern) - - const serviceHost: LanguageServiceHost = { - getScriptFileNames: () => Object.keys(memoryCache.versions), - getScriptVersion: (fileName: string) => { - const normalizedFileName = normalize(fileName) - const version = memoryCache.versions[normalizedFileName] - - // We need to return `undefined` and not a string here because TypeScript will use - // `getScriptVersion` and compare against their own version - which can be `undefined`. - // If we don't return `undefined` it results in `undefined === "undefined"` and run - // `createProgram` again (which is very slow). Using a `string` assertion here to avoid - // TypeScript errors from the function signature (expects `(x: string) => string`). - return version === undefined ? ((undefined as any) as string) : String(version) - }, - getScriptSnapshot(fileName: string) { - const normalizedFileName = normalize(fileName) - const hit = hasOwn.call(memoryCache.contents, normalizedFileName) - logger.trace({ normalizedFileName, cacheHit: hit }, `getScriptSnapshot():`, 'cache', hit ? 'hit' : 'miss') - // Read contents from TypeScript memory cache. - if (!hit) { - memoryCache.contents[normalizedFileName] = ts.sys.readFile(normalizedFileName) - } - - const contents = memoryCache.contents[normalizedFileName] - if (contents === undefined) { - return - } - return ts.ScriptSnapshot.fromString(contents) - }, - fileExists: memoize(ts.sys.fileExists), - readFile: logger.wrap(serviceHostTraceCtx, 'readFile', memoize(ts.sys.readFile)), - readDirectory: memoize(ts.sys.readDirectory), - getDirectories: memoize(ts.sys.getDirectories), - directoryExists: memoize(ts.sys.directoryExists), - realpath: memoize(ts.sys.realpath!), - getNewLine: () => '\n', - getCurrentDirectory: () => cwd, - getCompilationSettings: () => compilerOptions, - getDefaultLibFileName: () => ts.getDefaultLibFilePath(compilerOptions), - getCustomTransformers: () => transformers, - } - - logger.debug('creating language service') - const service = ts.createLanguageService(serviceHost) - - getOutput = (code: string, fileName: string /*, lineOffset = 0 */) => { - logger.debug({ fileName }, 'getOutput(): compiling using language service') - // Must set memory cache before attempting to read file. - updateMemoryCache(code, fileName) - - let output = service.getEmitOutput(fileName) - - if (configs.shouldReportDiagnostic(fileName)) { - logger.debug({ fileName }, 'getOutput(): computing diagnostics') - // Get the relevant diagnostics - this is 3x faster than `getPreEmitDiagnostics`. - const diagnostics = service - .getCompilerOptionsDiagnostics() - .concat(service.getSyntacticDiagnostics(fileName)) - .concat(service.getSemanticDiagnostics(fileName)) - - // will raise or just warn diagnostics depending on config - configs.raiseDiagnostics(diagnostics, fileName, logger) - } - - /* istanbul ignore next (this should never happen but is kept for security) */ - if (output.emitSkipped) { - throw new TypeError(`${relative(cwd, fileName)}: Emit skipped`) - } - - // if this block executes we have attempted to compile a resolved dependency - // that typescript ignored, likely because the file was located in node_modules. - // we can force the compiler to retry by invalidating its internal module - // resolution cache. - // - // @see https://github.com/Microsoft/TypeScript/issues/12358 - // @see https://github.com/microsoft/TypeScript/issues/11946 - if (output.outputFiles.length === 0 && !transformIgnoreRegExp.test(fileName)) { - const normalizedFileName = normalize(fileName) - - // our implementation of getScriptFileNames() depends on the keys - // of memoryCache.versions so we must make sure that the file is - // in there. - if (!hasOwn.call(memoryCache.versions, normalizedFileName)) { - memoryCache.versions[normalizedFileName] = 0 - } - - output = service.getEmitOutput(fileName) - } - - // Throw an error when requiring `.d.ts` files. - /* istanbul ignore next (this should never happen but is kept for security) */ - if (output.outputFiles.length === 0) { - throw new TypeError( - interpolate(Errors.UnableToRequireDefinitionFile, { - file: basename(fileName), - }), - ) - } - - return [output.outputFiles[1].text, output.outputFiles[0].text] - } - - getTypeInfo = (code: string, fileName: string, position: number) => { - updateMemoryCache(code, fileName) - - const info = service.getQuickInfoAtPosition(fileName, position) - const name = ts.displayPartsToString(info ? info.displayParts : []) - const comment = ts.displayPartsToString(info ? info.documentation : []) - - return { name, comment } - } - } - - const compile = readThrough(cachedir, memoryCache, getOutput, getExtension, cwd, logger) - return { cwd, compile, getTypeInfo, extensions, cachedir, ts } -} - -/** - * Internal source output. - */ -type SourceOutput = [string, string] - -/** - * Wrap the function with caching. - */ -function readThrough( - cachedir: string | undefined, - memoryCache: MemoryCache, - compile: (code: string, fileName: string, lineOffset?: number) => SourceOutput, - getExtension: (fileName: string) => string, - cwd: string, - logger: Logger, -) { - if (!cachedir) { - return (code: string, fileName: string, lineOffset?: number) => { - logger.debug({ fileName }, 'readThrough(): no cache') - - const [value, sourceMap] = compile(code, fileName, lineOffset) - const output = updateOutput(value, fileName, sourceMap, getExtension, cwd) - - memoryCache.outputs[fileName] = output - - return output - } - } - - // Make sure the cache directory exists before continuing. - mkdirp.sync(cachedir) - - return (code: string, fileName: string, lineOffset?: number) => { - const cachePath = join(cachedir, getCacheName(code, fileName)) - const extension = getExtension(fileName) - const outputPath = `${cachePath}${extension}` - - try { - const output = readFileSync(outputPath, 'utf8') - if (isValidCacheContent(output)) { - logger.debug({ fileName }, 'readThrough(): cache hit') - memoryCache.outputs[fileName] = output - return output - } - } catch (err) {} - - logger.debug({ fileName }, 'readThrough(): cache miss') - const [value, sourceMap] = compile(code, fileName, lineOffset) - const output = updateOutput(value, fileName, sourceMap, getExtension, cwd) - - logger.debug({ fileName, outputPath }, 'readThrough(): writing caches') - memoryCache.outputs[fileName] = output - writeFileSync(outputPath, output) - - return output - } -} - -/** - * Update the output remapping the source map. - */ -function updateOutput( - outputText: string, - fileName: string, - sourceMap: string, - getExtension: (fileName: string) => string, - sourceRoot: string, -) { - const base = basename(fileName) - const base64Map = bufferFrom(updateSourceMap(sourceMap, fileName, sourceRoot), 'utf8').toString('base64') - const sourceMapContent = `data:application/json;charset=utf-8;base64,${base64Map}` - const sourceMapLength = `${base}.map`.length + (getExtension(fileName).length - extname(fileName).length) - - return outputText.slice(0, -sourceMapLength) + sourceMapContent -} - -/** - * Update the source map contents for improved output. - */ -function updateSourceMap(sourceMapText: string, fileName: string, _sourceRoot: string) { - const sourceMap = JSON.parse(sourceMapText) - // const relativeFilePath = posix.normalize(relative(sourceRoot, fileName)) - // sourceMap.file = relativeFilePath - // sourceMap.sources = [relativeFilePath] - // sourceMap.sourceRoot = normalize(sourceRoot) - sourceMap.file = fileName - sourceMap.sources = [fileName] - delete sourceMap.sourceRoot - return stableStringify(sourceMap) -} - -/** - * Get the file name for the cache entry. - */ -function getCacheName(sourceCode: string, fileName: string) { - return sha1(fileName, '\x00', sourceCode) -} - -/** - * Ensure the given cached content is valid by sniffing for a base64 encoded '}' - * at the end of the content, which should exist if there is a valid sourceMap present. - */ -function isValidCacheContent(contents: string) { - return /(?:9|0=|Q==)$/.test(contents.slice(-3)) -} diff --git a/src/compiler/__snapshots__/language-service.spec.ts.snap b/src/compiler/__snapshots__/language-service.spec.ts.snap new file mode 100644 index 0000000000..f12e0f2048 --- /dev/null +++ b/src/compiler/__snapshots__/language-service.spec.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`language service should compile js file for allowJs true 1`] = ` + ===[ FILE: foo.test.js ]======================================================== + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = 42; + //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoiZm9vLnRlc3QuanMiLCJtYXBwaW5ncyI6Ijs7QUFBQSxrQkFBZSxFQUFFLENBQUEiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiZm9vLnRlc3QuanMiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgNDIiXSwidmVyc2lvbiI6M30= + ===[ INLINE SOURCE MAPS ]======================================================= + file: foo.test.js + mappings: ';;AAAA,kBAAe,EAAE,CAAA' + names: [] + sources: + - foo.test.js + sourcesContent: + - export default 42 + version: 3 + ================================================================================ +`; + +exports[`language service should report diagnostics related to typings with pathRegex config matches file name 1`] = ` +"TypeScript diagnostics (customize using \`[jest-config].globals.ts-jest.diagnostics\` option): +foo.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'." +`; + +exports[`language service should use the cache 3`] = ` + ===[ FILE: src/compiler/language-service.spec.ts ]============================== + console.log("hello"); + //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoiPGN3ZD4vc3JjL2NvbXBpbGVyL2xhbmd1YWdlLXNlcnZpY2Uuc3BlYy50cyIsIm1hcHBpbmdzIjoiQUFBQSxPQUFPLENBQUMsR0FBRyxDQUFDLE9BQU8sQ0FBQyxDQUFBIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIjxjd2Q+L3NyYy9jb21waWxlci9sYW5ndWFnZS1zZXJ2aWNlLnNwZWMudHMiXSwic291cmNlc0NvbnRlbnQiOlsiY29uc29sZS5sb2coXCJoZWxsb1wiKSJdLCJ2ZXJzaW9uIjozfQ== + ===[ INLINE SOURCE MAPS ]======================================================= + file: /src/compiler/language-service.spec.ts + mappings: 'AAAA,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA' + names: [] + sources: + - /src/compiler/language-service.spec.ts + sourcesContent: + - console.log("hello") + version: 3 + ================================================================================ +`; diff --git a/src/compiler/__snapshots__/program.spec.ts.snap b/src/compiler/__snapshots__/program.spec.ts.snap new file mode 100644 index 0000000000..9dad9a5468 --- /dev/null +++ b/src/compiler/__snapshots__/program.spec.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`allowJs should compile js file for allowJs true with incremental program 1`] = ` + ===[ FILE: test-allowJs.test.js ]=============================================== + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = 42; + //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoidGVzdC1hbGxvd0pzLnRlc3QuanMiLCJtYXBwaW5ncyI6Ijs7QUFBQSxrQkFBZSxFQUFFLENBQUEiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsidGVzdC1hbGxvd0pzLnRlc3QuanMiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgNDIiXSwidmVyc2lvbiI6M30= + ===[ INLINE SOURCE MAPS ]======================================================= + file: test-allowJs.test.js + mappings: ';;AAAA,kBAAe,EAAE,CAAA' + names: [] + sources: + - test-allowJs.test.js + sourcesContent: + - export default 42 + version: 3 + ================================================================================ +`; + +exports[`allowJs should compile js file for allowJs true with normal program 1`] = ` + ===[ FILE: test-allowJs.test.js ]=============================================== + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = 42; + //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoidGVzdC1hbGxvd0pzLnRlc3QuanMiLCJtYXBwaW5ncyI6Ijs7QUFBQSxrQkFBZSxFQUFFLENBQUEiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsidGVzdC1hbGxvd0pzLnRlc3QuanMiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgNDIiXSwidmVyc2lvbiI6M30= + ===[ INLINE SOURCE MAPS ]======================================================= + file: test-allowJs.test.js + mappings: ';;AAAA,kBAAe,EAAE,CAAA' + names: [] + sources: + - test-allowJs.test.js + sourcesContent: + - export default 42 + version: 3 + ================================================================================ +`; + +exports[`typings incremental program should report diagnostics with pathRegex config matches file name 1`] = `"test-typings.ts: Emit skipped"`; + +exports[`typings normal program should report diagnostics with pathRegex config matches file name 1`] = ` +"TypeScript diagnostics (customize using \`[jest-config].globals.ts-jest.diagnostics\` option): +test-typings.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'." +`; diff --git a/src/compiler/__snapshots__/transpile-module.spec.ts.snap b/src/compiler/__snapshots__/transpile-module.spec.ts.snap new file mode 100644 index 0000000000..de6e87b69b --- /dev/null +++ b/src/compiler/__snapshots__/transpile-module.spec.ts.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`transpile module with isolatedModule: true should compile js file for allowJs true 1`] = ` + ===[ FILE: src/compiler/transpile-module.spec.ts.test.js ]====================== + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = 42; + //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoiPGN3ZD4vc3JjL2NvbXBpbGVyL3RyYW5zcGlsZS1tb2R1bGUuc3BlYy50cy50ZXN0LmpzIiwibWFwcGluZ3MiOiI7O0FBQUEsa0JBQWUsRUFBRSxDQUFBIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIjxjd2Q+L3NyYy9jb21waWxlci90cmFuc3BpbGUtbW9kdWxlLnNwZWMudHMudGVzdC5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQgZGVmYXVsdCA0MiJdLCJ2ZXJzaW9uIjozfQ== + ===[ INLINE SOURCE MAPS ]======================================================= + file: /src/compiler/transpile-module.spec.ts.test.js + mappings: ';;AAAA,kBAAe,EAAE,CAAA' + names: [] + sources: + - /src/compiler/transpile-module.spec.ts.test.js + sourcesContent: + - export default 42 + version: 3 + ================================================================================ +`; + +exports[`transpile module with isolatedModule: true should compile using transpileModule and not use cache 1`] = ` + ===[ FILE: src/compiler/transpile-module.spec.ts ]============================== + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = 42; + //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJmaWxlIjoiPGN3ZD4vc3JjL2NvbXBpbGVyL3RyYW5zcGlsZS1tb2R1bGUuc3BlYy50cyIsIm1hcHBpbmdzIjoiOztBQUFBLGtCQUFlLEVBQUUsQ0FBQSIsIm5hbWVzIjpbXSwic291cmNlcyI6WyI8Y3dkPi9zcmMvY29tcGlsZXIvdHJhbnNwaWxlLW1vZHVsZS5zcGVjLnRzIl0sInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCBkZWZhdWx0IDQyIl0sInZlcnNpb24iOjN9 + ===[ INLINE SOURCE MAPS ]======================================================= + file: /src/compiler/transpile-module.spec.ts + mappings: ';;AAAA,kBAAe,EAAE,CAAA' + names: [] + sources: + - /src/compiler/transpile-module.spec.ts + sourcesContent: + - export default 42 + version: 3 + ================================================================================ +`; + +exports[`transpile module with isolatedModule: true should report diagnostics related to codes with pathRegex config is undefined 1`] = ` +"TypeScript diagnostics (customize using \`[jest-config].globals.ts-jest.diagnostics\` option): +foo.ts(2,23): error TS1005: '=>' expected." +`; + +exports[`transpile module with isolatedModule: true should report diagnostics related to codes with pathRegex config matches file name 1`] = ` +"TypeScript diagnostics (customize using \`[jest-config].globals.ts-jest.diagnostics\` option): +foo.ts(2,23): error TS1005: '=>' expected." +`; diff --git a/src/compiler/instance.ts b/src/compiler/instance.ts new file mode 100644 index 0000000000..b168c1ee2e --- /dev/null +++ b/src/compiler/instance.ts @@ -0,0 +1,189 @@ +/** + * This code is heavily inspired from + * https://github.com/JsCommunity/make-error/blob/v1.3.4/index.js + * ...but more modified than expected :-D + * Below is the original license anyway: + * + * --- + * + * The MIT License (MIT) + * + * Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { Logger } from 'bs-logger' +import { readFileSync, writeFileSync } from 'fs' +import mkdirp = require('mkdirp') +import { basename, extname, join, normalize } from 'path' + +import { ConfigSet } from '../config/config-set' +import { CompileResult, MemoryCache, TsCompiler } from '../types' +import { sha1 } from '../util/sha1' + +import { compileUsingLanguageService } from './language-service' +import { compileUsingProgram } from './program' +import { compileUsingTranspileModule } from './transpile-module' + +/** + * Update the output remapping the source map. + */ +function updateOutput( + outputText: string, + normalizedFileName: string, + sourceMap: string, + getExtension: (fileName: string) => string, +) { + const base = basename(normalizedFileName) + const base64Map = Buffer.from(updateSourceMap(sourceMap, normalizedFileName), 'utf8').toString('base64') + const sourceMapContent = `data:application/json;charset=utf-8;base64,${base64Map}` + const sourceMapLength = + `${base}.map`.length + (getExtension(normalizedFileName).length - extname(normalizedFileName).length) + + return outputText.slice(0, -sourceMapLength) + sourceMapContent +} + +/** + * Update the source map contents for improved output. + */ +const updateSourceMap = (sourceMapText: string, normalizedFileName: string): string => { + const sourceMap = JSON.parse(sourceMapText) + sourceMap.file = normalizedFileName + sourceMap.sources = [normalizedFileName] + delete sourceMap.sourceRoot + + return JSON.stringify(sourceMap) +} + +/** + * Get the file name for the cache entry. + */ +const getCacheName = (sourceCode: string, normalizedFileName: string): string => { + return sha1(normalizedFileName, '\x00', sourceCode) +} + +/** + * Ensure the given cached content is valid by sniffing for a base64 encoded '}' + * at the end of the content, which should exist if there is a valid sourceMap present. + */ +const isValidCacheContent = (contents: string): boolean => { + return /(?:9|0=|Q==)$/.test(contents.slice(-3)) +} + +/** + * Wrap the function with caching. + */ +const readThrough = ( + cachedir: string | undefined, + memoryCache: MemoryCache, + compile: CompileResult, + getExtension: (fileName: string) => string, + logger: Logger, +) => { + if (!cachedir) { + return (code: string, fileName: string, lineOffset?: number) => { + const normalizedFileName = normalize(fileName) + logger.debug({ normalizedFileName }, 'readThrough(): no cache') + const [value, sourceMap] = compile(code, normalizedFileName, lineOffset) + const output = updateOutput(value, fileName, sourceMap, getExtension) + memoryCache.outputs.set(normalizedFileName, output) + + return output + } + } + + // Make sure the cache directory exists before continuing. + mkdirp.sync(cachedir) + + return (code: string, fileName: string, lineOffset?: number) => { + const normalizedFileName = normalize(fileName) + const cachePath = join(cachedir, getCacheName(code, normalizedFileName)) + const extension = getExtension(normalizedFileName) + const outputPath = `${cachePath}${extension}` + + try { + const output = readFileSync(outputPath, 'utf8') + if (isValidCacheContent(output)) { + logger.debug({ normalizedFileName }, 'readThrough(): cache hit') + memoryCache.outputs.set(normalizedFileName, output) + + return output + } + } catch (err) {} + + logger.debug({ fileName }, 'readThrough(): cache miss') + const [value, sourceMap] = compile(code, normalizedFileName, lineOffset) + const output = updateOutput(value, normalizedFileName, sourceMap, getExtension) + + logger.debug({ normalizedFileName, outputPath }, 'readThrough(): writing caches') + memoryCache.outputs.set(normalizedFileName, output) + writeFileSync(outputPath, output) + + return output + } +} + +/** + * Register TypeScript compiler. + * @internal + */ +export const createCompiler = (configs: ConfigSet): TsCompiler => { + const logger = configs.logger.child({ namespace: 'ts-compiler' }) + const { + typescript: { options: compilerOptions, fileNames }, + tsJest, + } = configs + const cachedir = configs.tsCacheDir, + ts = configs.compilerModule, // Require the TypeScript compiler and configuration. + extensions = ['.ts', '.tsx'], + memoryCache: MemoryCache = { + contents: new Map(), + versions: new Map(), + outputs: new Map(), + } + // Enable `allowJs` when flag is set. + if (compilerOptions.allowJs) { + extensions.push('.js') + extensions.push('.jsx') + } + // Initialize files from TypeScript into project. + for (const path of fileNames) memoryCache.versions.set(normalize(path), 1) + /** + * Get the extension for a transpiled file. + */ + const getExtension = + compilerOptions.jsx === ts.JsxEmit.Preserve + ? (path: string) => (/\.[tj]sx$/.test(path) ? '.jsx' : '.js') + : (_: string) => '.js' + let compileResult: CompileResult + if (!tsJest.isolatedModules) { + // Use language services by default + if (!tsJest.compilerHost) { + compileResult = compileUsingLanguageService(configs, logger, memoryCache) + } else { + compileResult = compileUsingProgram(configs, logger, memoryCache) + } + } else { + compileResult = compileUsingTranspileModule(configs, logger) + } + const compile = readThrough(cachedir, memoryCache, compileResult, getExtension, logger) + + return { cwd: configs.cwd, compile, extensions, cachedir, ts } +} diff --git a/src/compiler/language-service.spec.ts b/src/compiler/language-service.spec.ts new file mode 100644 index 0000000000..87e3bf5e07 --- /dev/null +++ b/src/compiler/language-service.spec.ts @@ -0,0 +1,119 @@ +import { LogLevels } from 'bs-logger' +import { removeSync, writeFileSync } from 'fs-extra' + +import { makeCompiler } from '../__helpers__/fakers' +import { logTargetMock } from '../__helpers__/mocks' +import { tempDir } from '../__helpers__/path' +import ProcessedSource from '../__helpers__/processed-source' + +const logTarget = logTargetMock() + +describe('language service', () => { + beforeEach(() => { + logTarget.clear() + }) + + it('should use the cache', () => { + const tmp = tempDir('compiler'), + compiler = makeCompiler({ + jestConfig: { cache: true, cacheDirectory: tmp }, + tsJestConfig: { tsConfig: false }, + }), + source = 'console.log("hello")' + + logTarget.clear() + const compiled1 = compiler.compile(source, __filename) + + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchInlineSnapshot(` + Array [ + "[level:20] readThrough(): cache miss + ", + "[level:20] updateMemoryCache() for language service + ", + "[level:20] compiler rebuilt Program instance when getting output + ", + "[level:20] visitSourceFileNode(): hoisting + ", + "[level:20] getOutput(): computing diagnostics for language service + ", + "[level:20] invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) true + ", + "[level:20] readThrough(): writing caches + ", + ] + `) + + logTarget.clear() + const compiled2 = compiler.compile(source, __filename) + + expect(logTarget.lines).toMatchInlineSnapshot(` + Array [ + "[level:20] readThrough(): cache hit + ", + ] + `) + + expect(new ProcessedSource(compiled1, __filename)).toMatchSnapshot() + expect(compiled2).toBe(compiled1) + }) + + it('should compile js file for allowJs true', () => { + const fileName = `foo.test.js`, + compiler = makeCompiler({ + tsJestConfig: { tsConfig: { allowJs: true, outDir: '$$ts-jest$$' } }, + }), + source = 'export default 42' + + writeFileSync(fileName, source, 'utf8') + const compiled = compiler.compile(source, fileName) + + expect(new ProcessedSource(compiled, fileName)).toMatchSnapshot() + + removeSync(fileName) + }) + + it('should have correct source maps', () => { + const compiler = makeCompiler({ tsJestConfig: { tsConfig: false } }), + source = 'const g = (v: number) => v\nconst h: number = g(5)' + + const compiled = compiler.compile(source, 'foo.ts') + + expect(new ProcessedSource(compiled, 'foo.ts').outputSourceMaps).toMatchObject({ + file: 'foo.ts', + sources: ['foo.ts'], + sourcesContent: [source], + }) + }) + + it('should report diagnostics related to typings with pathRegex config matches file name', () => { + const fileName = 'foo.ts', + source = ` +const g = (v: number) => v +const x: string = g(5) +`, + compiler = makeCompiler({ + tsJestConfig: { tsConfig: false, diagnostics: { pathRegex: 'foo.ts' } }, + }) + writeFileSync(fileName, source, 'utf8') + + expect(() => compiler.compile(source, fileName)).toThrowErrorMatchingSnapshot() + + removeSync(fileName) + }) + + it('should not report diagnostics related to typings with pathRegex config does not match file name', () => { + const fileName = 'foo.ts', + source = ` +const f = (v: number) => v +const t: string = f(5) +`, + compiler = makeCompiler({ + tsJestConfig: { tsConfig: false, diagnostics: { pathRegex: 'bar.ts' } }, + }) + writeFileSync(fileName, source, 'utf8') + + expect(() => compiler.compile(source, fileName)).not.toThrowError() + + removeSync(fileName) + }) +}) diff --git a/src/compiler/language-service.ts b/src/compiler/language-service.ts new file mode 100644 index 0000000000..2f2261519d --- /dev/null +++ b/src/compiler/language-service.ts @@ -0,0 +1,138 @@ +import { LogContexts, LogLevels, Logger } from 'bs-logger' +import memoize = require('lodash.memoize') +import { basename, normalize, relative } from 'path' +import * as _ts from 'typescript' + +import { ConfigSet } from '../config/config-set' +import { CompileResult, MemoryCache, SourceOutput } from '../types' +import { Errors, interpolate } from '../util/messages' + +/** + * @internal + */ +export const compileUsingLanguageService = ( + configs: ConfigSet, + logger: Logger, + memoryCache: MemoryCache, +): CompileResult => { + const ts = configs.compilerModule, + cwd = configs.cwd, + { options, fileNames } = configs.typescript, + serviceHostTraceCtx = { + namespace: 'ts:serviceHost', + call: null, + [LogContexts.logLevel]: LogLevels.trace, + } + let projectVersion = 1 + const serviceHost: _ts.LanguageServiceHost = { + getProjectVersion: () => String(projectVersion), + getScriptFileNames: () => fileNames, + getScriptVersion: (fileName: string) => { + const normalizedFileName = normalize(fileName) + const version = memoryCache.versions.get(normalizedFileName) + + return version === undefined ? '' : version.toString() + }, + getScriptSnapshot(fileName: string) { + const normalizedFileName = normalize(fileName) + const hit = memoryCache.contents.has(normalizedFileName) + logger.trace({ normalizedFileName, cacheHit: hit }, `getScriptSnapshot():`, 'cache', hit ? 'hit' : 'miss') + // Read contents from TypeScript memory cache. + if (!hit) { + memoryCache.contents.set(normalizedFileName, ts.sys.readFile(normalizedFileName)) + } + const contents = memoryCache.contents.get(normalizedFileName) + if (contents === undefined) { + return + } + + return ts.ScriptSnapshot.fromString(contents) + }, + fileExists: memoize(ts.sys.fileExists), + readFile: logger.wrap(serviceHostTraceCtx, 'readFile', memoize(ts.sys.readFile)), + readDirectory: memoize(ts.sys.readDirectory), + getDirectories: memoize(ts.sys.getDirectories), + directoryExists: memoize(ts.sys.directoryExists), + realpath: ts.sys.realpath ? memoize(ts.sys.realpath) : undefined, + getNewLine: () => '\n', + getCurrentDirectory: () => cwd, + getCompilationSettings: () => options, + getDefaultLibFileName: () => ts.getDefaultLibFilePath(options), + getCustomTransformers: () => configs.tsCustomTransformers, + } + + logger.debug('compileUsingLanguageService(): creating language service') + + const service: _ts.LanguageService = ts.createLanguageService( + serviceHost, + ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, cwd), + ) + const updateMemoryCache = (contents: string, normalizedFileName: string): void => { + logger.debug({ normalizedFileName }, `updateMemoryCache() for language service`) + + const fileVersion = memoryCache.versions.get(normalizedFileName) ?? 0, + isFileInCache = fileVersion !== 0 + if (!isFileInCache) { + fileNames.push(normalizedFileName) + // Modifying rootFileNames means a project change + projectVersion++ + } + if (memoryCache.contents.get(normalizedFileName) !== contents) { + memoryCache.versions.set(normalizedFileName, fileVersion + 1) + memoryCache.contents.set(normalizedFileName, contents) + // Only bump project version when file is modified in cache, not when discovered for the first time + if (isFileInCache) { + projectVersion++ + } + } + } + let previousProgram: _ts.Program | undefined + + return (code: string, fileName: string): SourceOutput => { + const normalizedFileName = normalize(fileName) + // Must set memory cache before attempting to read file. + updateMemoryCache(code, normalizedFileName) + const programBefore = service.getProgram() + + if (programBefore !== previousProgram) { + logger.debug({ normalizedFileName }, `compiler rebuilt Program instance when getting output`) + } + + const output: _ts.EmitOutput = service.getEmitOutput(normalizedFileName) + if (configs.shouldReportDiagnostic(normalizedFileName)) { + logger.debug({ normalizedFileName }, 'getOutput(): computing diagnostics for language service') + // Get the relevant diagnostics - this is 3x faster than `getPreEmitDiagnostics`. + const diagnostics = service + .getCompilerOptionsDiagnostics() + .concat(service.getSyntacticDiagnostics(normalizedFileName)) + .concat(service.getSemanticDiagnostics(normalizedFileName)) + // will raise or just warn diagnostics depending on config + configs.raiseDiagnostics(diagnostics, normalizedFileName, logger) + } + + /* istanbul ignore next (this should never happen but is kept for security) */ + if (output.emitSkipped) { + throw new TypeError(`${relative(cwd, normalizedFileName)}: Emit skipped for language service`) + } + + const programAfter = service.getProgram() + + logger.debug( + 'invariant: Is service.getProject() identical before and after getting emit output and diagnostics? (should always be true) ', + programBefore === programAfter, + ) + + previousProgram = programAfter + // Throw an error when requiring `.d.ts` files. + /* istanbul ignore next (this should never happen but is kept for security) */ + if (!output.outputFiles.length) { + throw new TypeError( + interpolate(Errors.UnableToRequireDefinitionFile, { + file: basename(normalizedFileName), + }), + ) + } + + return [output.outputFiles[1].text, output.outputFiles[0].text] + } +} diff --git a/src/compiler/program.spec.ts b/src/compiler/program.spec.ts new file mode 100644 index 0000000000..91b8e6c988 --- /dev/null +++ b/src/compiler/program.spec.ts @@ -0,0 +1,260 @@ +import { LogLevels } from 'bs-logger' +import { writeFileSync } from 'fs' +import { removeSync } from 'fs-extra' + +import { makeCompiler } from '../__helpers__/fakers' +import { logTargetMock } from '../__helpers__/mocks' +import { tempDir } from '../__helpers__/path' +import ProcessedSource from '../__helpers__/processed-source' + +const logTarget = logTargetMock() + +const baseTsJestConfig = { + compilerHost: true, +} + +describe('typings', () => { + const fileName = 'test-typings.ts', + source = ` +const f = (v: number) => v +const t: string = f(5) +` + + beforeAll(() => { + writeFileSync(fileName, source, 'utf8') + }) + + afterAll(() => { + removeSync(fileName) + }) + + describe('normal program', () => { + it('should report diagnostics with pathRegex config matches file name', () => { + const compiler = makeCompiler({ + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: false }, + diagnostics: { pathRegex: fileName }, + }, + }) + + expect(() => compiler.compile(source, fileName)).toThrowErrorMatchingSnapshot() + }) + + it('should not report diagnostics with pathRegex config matches file name', () => { + const compiler = makeCompiler({ + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: false }, + diagnostics: { pathRegex: 'foo.ts' }, + }, + }) + + try { + compiler.compile(source, fileName) + } catch (e) { + expect(e).not.toContain('TypeScript diagnostics') + } + }) + }) + + describe('incremental program', () => { + it('should report diagnostics with pathRegex config matches file name', () => { + const compiler = makeCompiler({ + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: true }, + diagnostics: { pathRegex: 'typings-error.ts' }, + }, + }) + + expect(() => compiler.compile(source, fileName)).toThrowErrorMatchingSnapshot() + }) + + it('should not report diagnostics with pathRegex config does not match file name', () => { + const compiler = makeCompiler({ + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: true }, + diagnostics: { pathRegex: 'foo.ts' }, + }, + }) + + try { + compiler.compile(source, fileName) + } catch (e) { + expect(e).not.toContain('TypeScript diagnostics') + } + }) + }) +}) + +describe('source-maps', () => { + const fileName = 'source-maps-test.ts', + source = 'console.log("hello")' + + beforeAll(() => { + writeFileSync(fileName, source, 'utf8') + }) + + afterAll(() => { + removeSync(fileName) + }) + + it('should have correct source maps with normal program', () => { + const compiler = makeCompiler({ + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: false }, + }, + }) + + const compiled = compiler.compile(source, fileName) + + expect(new ProcessedSource(compiled, fileName).outputSourceMaps).toMatchObject({ + file: fileName, + sources: [fileName], + sourcesContent: [source], + }) + }) + + it('should have correct source maps with incremental program', () => { + const compiler = makeCompiler({ + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: true }, + }, + }) + + const compiled = compiler.compile(source, fileName) + + expect(new ProcessedSource(compiled, fileName).outputSourceMaps).toMatchObject({ + file: fileName, + sources: [fileName], + sourcesContent: [source], + }) + }) +}) + +describe('cache', () => { + const tmp = tempDir('compiler'), + fileName = 'test-cache.ts', + source = 'console.log("hello")' + + beforeAll(() => { + writeFileSync(fileName, source, 'utf8') + }) + + afterAll(() => { + removeSync(fileName) + }) + + it('should use the cache with normal program', () => { + const compiler = makeCompiler({ + jestConfig: { cache: true, cacheDirectory: tmp }, + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: false }, + }, + }) + + logTarget.clear() + const compiled1 = compiler.compile(source, fileName) + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchInlineSnapshot(` + Array [ + "[level:20] readThrough(): cache miss + ", + "[level:20] updateMemoryCache() for program + ", + "[level:20] visitSourceFileNode(): hoisting + ", + "[level:20] getOutput(): computing diagnostics for program + ", + "[level:20] readThrough(): writing caches + ", + ] + `) + + logTarget.clear() + const compiled2 = compiler.compile(source, fileName) + expect(logTarget.lines).toMatchInlineSnapshot(` + Array [ + "[level:20] readThrough(): cache hit + ", + ] + `) + + expect(compiled2).toBe(compiled1) + }) + + it('should use the cache with normal program', () => { + const compiler = makeCompiler({ + jestConfig: { cache: true, cacheDirectory: tmp }, + tsJestConfig: { + ...baseTsJestConfig, + tsConfig: { incremental: true }, + }, + }) + + logTarget.clear() + const compiled1 = compiler.compile(source, fileName) + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchInlineSnapshot(` +Array [ + "[level:20] readThrough(): cache miss +", + "[level:20] updateMemoryCache() for program +", + "[level:20] visitSourceFileNode(): hoisting +", + "[level:20] getOutput(): computing diagnostics for incremental program +", + "[level:20] readThrough(): writing caches +", +] +`) + + logTarget.clear() + const compiled2 = compiler.compile(source, fileName) + expect(logTarget.lines).toMatchInlineSnapshot(` + Array [ + "[level:20] readThrough(): cache hit + ", + ] + `) + + expect(compiled2).toBe(compiled1) + }) +}) + +describe('allowJs', () => { + const fileName = 'test-allowJs.test.js', + source = 'export default 42' + + beforeAll(() => { + writeFileSync(fileName, source, 'utf8') + }) + + afterAll(() => { + removeSync(fileName) + }) + + it('should compile js file for allowJs true with normal program', () => { + const compiler = makeCompiler({ + tsJestConfig: { ...baseTsJestConfig, tsConfig: { allowJs: true, outDir: '$$ts-jest$$', incremental: false } }, + }) + + const compiled = compiler.compile(source, fileName) + + expect(new ProcessedSource(compiled, fileName)).toMatchSnapshot() + }) + + it('should compile js file for allowJs true with incremental program', () => { + const compiler = makeCompiler({ + tsJestConfig: { ...baseTsJestConfig, tsConfig: { allowJs: true, outDir: '$$ts-jest$$', incremental: true } }, + }) + + const compiled = compiler.compile(source, fileName) + + expect(new ProcessedSource(compiled, fileName)).toMatchSnapshot() + }) +}) diff --git a/src/compiler/program.ts b/src/compiler/program.ts new file mode 100644 index 0000000000..b7822a9c33 --- /dev/null +++ b/src/compiler/program.ts @@ -0,0 +1,178 @@ +import { LogContexts, LogLevels, Logger } from 'bs-logger' +import memoize = require('lodash.memoize') +import { basename, normalize, relative } from 'path' +import * as _ts from 'typescript' + +import { ConfigSet } from '../config/config-set' +import { CompileResult, MemoryCache, SourceOutput } from '../types' +import { Errors, interpolate } from '../util/messages' + +/** + * @internal + */ +export const compileUsingProgram = (configs: ConfigSet, logger: Logger, memoryCache: MemoryCache): CompileResult => { + logger.debug('compileUsingProgram(): create typescript compiler') + + const ts = configs.compilerModule, + cwd = configs.cwd, + { options, fileNames, projectReferences, errors } = configs.typescript + const compilerHostTraceCtx = { + namespace: 'ts:compilerHost', + call: null, + [LogContexts.logLevel]: LogLevels.trace, + }, + sys = { + ...ts.sys, + readFile: logger.wrap(compilerHostTraceCtx, 'readFile', memoize(ts.sys.readFile)), + readDirectory: logger.wrap(compilerHostTraceCtx, 'readDirectory', memoize(ts.sys.readDirectory)), + getDirectories: logger.wrap(compilerHostTraceCtx, 'getDirectories', memoize(ts.sys.getDirectories)), + fileExists: logger.wrap(compilerHostTraceCtx, 'fileExists', memoize(ts.sys.fileExists)), + directoryExists: logger.wrap(compilerHostTraceCtx, 'directoryExists', memoize(ts.sys.directoryExists)), + resolvePath: logger.wrap(compilerHostTraceCtx, 'resolvePath', memoize(ts.sys.resolvePath)), + realpath: ts.sys.realpath ? logger.wrap(compilerHostTraceCtx, 'realpath', memoize(ts.sys.realpath)) : undefined, + getCurrentDirectory: () => cwd, + getNewLine: () => '\n', + getCanonicalFileName: (fileName: string) => + ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), + } + let builderProgram: _ts.EmitAndSemanticDiagnosticsBuilderProgram, program: _ts.Program, host: _ts.CompilerHost + // Fallback for older TypeScript releases without incremental API. + if (options.incremental) { + host = ts.createIncrementalCompilerHost(options, sys) + builderProgram = ts.createIncrementalProgram({ + rootNames: fileNames.slice(), + options, + host, + configFileParsingDiagnostics: errors, + projectReferences, + }) + program = builderProgram.getProgram() + } else { + host = { + ...sys, + getSourceFile: (fileName, languageVersion) => { + const contents = ts.sys.readFile(fileName) + + if (contents === undefined) return + + return ts.createSourceFile(fileName, contents, languageVersion) + }, + getDefaultLibFileName: () => ts.getDefaultLibFilePath(options), + useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames, + } + program = ts.createProgram({ + rootNames: fileNames.slice(), + options, + host, + configFileParsingDiagnostics: errors, + projectReferences, + }) + } + // Read and cache custom transformers. + const customTransformers = configs.tsCustomTransformers, + updateMemoryCache = (contents: string, normalizedFileName: string): void => { + logger.debug({ normalizedFileName }, `updateMemoryCache() for program`) + + const fileVersion = memoryCache.versions.get(normalizedFileName) ?? 0, + isFileInCache = fileVersion !== 0 + // Add to `rootFiles` when discovered for the first time. + if (!isFileInCache) { + fileNames.push(normalizedFileName) + } + // Avoid incrementing cache when nothing has changed. + if (memoryCache.contents.get(normalizedFileName) !== contents) { + memoryCache.versions.set(normalizedFileName, fileVersion + 1) + memoryCache.contents.set(normalizedFileName, contents) + } + const sourceFile = options.incremental + ? builderProgram.getSourceFile(normalizedFileName) + : program.getSourceFile(normalizedFileName) + // Update program when file changes. + if ( + sourceFile === undefined || + sourceFile.text !== contents || + program.isSourceFileFromExternalLibrary(sourceFile) + ) { + const programOptions = { + rootNames: fileNames.slice(), + options, + host, + configFileParsingDiagnostics: errors, + projectReferences, + } + if (options.incremental) { + builderProgram = ts.createIncrementalProgram(programOptions) + program = builderProgram.getProgram() + } else { + program = ts.createProgram(programOptions) + } + } + } + + return (code: string, fileName: string): SourceOutput => { + const normalizedFileName = normalize(fileName), + output: [string, string] = ['', ''] + // Must set memory cache before attempting to read file. + updateMemoryCache(code, normalizedFileName) + const sourceFile = options.incremental + ? builderProgram.getSourceFile(normalizedFileName) + : program.getSourceFile(normalizedFileName) + + if (!sourceFile) throw new TypeError(`Unable to read file: ${fileName}`) + + const result: _ts.EmitResult = options.incremental + ? builderProgram.emit( + sourceFile, + (path, file, _writeByteOrderMark) => { + output[path.endsWith('.map') ? 1 : 0] = file + }, + undefined, + undefined, + customTransformers, + ) + : program.emit( + sourceFile, + (path, file, _writeByteOrderMark) => { + output[path.endsWith('.map') ? 1 : 0] = file + }, + undefined, + undefined, + customTransformers, + ) + if (configs.shouldReportDiagnostic(normalizedFileName)) { + logger.debug( + { normalizedFileName }, + `getOutput(): computing diagnostics for ${options.incremental ? 'incremental program' : 'program'}`, + ) + const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile).slice() + // will raise or just warn diagnostics depending on config + configs.raiseDiagnostics(diagnostics, normalizedFileName, logger) + } + + if (result.emitSkipped) { + throw new TypeError(`${relative(cwd, fileName)}: Emit skipped`) + } + + // Throw an error when requiring files that cannot be compiled. + if (output[0] === '') { + if (program.isSourceFileFromExternalLibrary(sourceFile)) { + throw new TypeError(`Unable to compile file from external library: ${relative(cwd, fileName)}`) + } + + throw new TypeError( + interpolate(Errors.UnableToRequireDefinitionFile, { + file: basename(normalizedFileName), + }), + ) + } + if (configs.tsJest.emit && options.incremental) { + process.on('exit', () => { + // Emits `.tsbuildinfo` to filesystem. + // @ts-ignore + program.emitBuildInfo() + }) + } + + return output + } +} diff --git a/src/compiler/transpile-module.spec.ts b/src/compiler/transpile-module.spec.ts new file mode 100644 index 0000000000..5c1ed108b6 --- /dev/null +++ b/src/compiler/transpile-module.spec.ts @@ -0,0 +1,130 @@ +import { LogLevels } from 'bs-logger' +import { removeSync, writeFileSync } from 'fs-extra' + +import { makeCompiler } from '../__helpers__/fakers' +import { logTargetMock } from '../__helpers__/mocks' +import ProcessedSource from '../__helpers__/processed-source' + +const logTarget = logTargetMock() + +describe('transpile module with isolatedModule: true', () => { + const baseTsJestConfig = { + isolatedModules: true, + } + + beforeEach(() => { + logTarget.clear() + }) + + it('should compile using transpileModule and not use cache', () => { + const compiler = makeCompiler({ tsJestConfig: { ...baseTsJestConfig, tsConfig: false } }), + spy = jest.spyOn(require('typescript'), 'transpileModule') + + logTarget.clear() + const compiled = compiler.compile('export default 42', __filename) + + expect(new ProcessedSource(compiled, __filename)).toMatchSnapshot() + expect(spy).toHaveBeenCalled() + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchInlineSnapshot(` + Array [ + "[level:20] readThrough(): no cache + ", + "[level:20] getOutput(): compiling as isolated module + ", + "[level:20] visitSourceFileNode(): hoisting + ", + ] + `) + + spy.mockRestore() + }) + + it('should compile js file for allowJs true', () => { + const fileName = `${__filename}.test.js`, + compiler = makeCompiler({ + tsJestConfig: { ...baseTsJestConfig, tsConfig: { allowJs: true, outDir: '$$ts-jest$$' } }, + }), + source = 'export default 42' + + writeFileSync(fileName, source, 'utf8') + const compiled = compiler.compile(source, fileName) + + expect(new ProcessedSource(compiled, fileName)).toMatchSnapshot() + + removeSync(fileName) + }) + + it('should have correct source maps', () => { + const compiler = makeCompiler({ tsJestConfig: { ...baseTsJestConfig, tsConfig: false } }), + source = 'const f = (v: number) => v\nconst t: number = f(5)' + + const compiled = compiler.compile(source, __filename) + + expect(new ProcessedSource(compiled, __filename).outputSourceMaps).toMatchObject({ + file: __filename, + sources: [__filename], + sourcesContent: [source], + }) + }) + + it('should not report diagnostics related to typings', () => { + const compiler = makeCompiler({ tsJestConfig: { ...baseTsJestConfig, tsConfig: false } }) + + expect(() => + compiler.compile( + ` +const f = (v: number) => v +const t: string = f(5) +const v: boolean = t +`, + 'foo.ts', + ), + ).not.toThrowError() + }) + + it('should report diagnostics related to codes with pathRegex config is undefined', () => { + const compiler = makeCompiler({ tsJestConfig: { ...baseTsJestConfig, tsConfig: false } }) + + expect(() => + compiler.compile( + ` +const f = (v: number) = v +const t: string = f(5) +`, + 'foo.ts', + ), + ).toThrowErrorMatchingSnapshot() + }) + + it('should report diagnostics related to codes with pathRegex config matches file name', () => { + const compiler = makeCompiler({ + tsJestConfig: { ...baseTsJestConfig, tsConfig: false, diagnostics: { pathRegex: 'foo.ts' } }, + }) + + expect(() => + compiler.compile( + ` +const f = (v: number) = v +const t: string = f(5) +`, + 'foo.ts', + ), + ).toThrowErrorMatchingSnapshot() + }) + + it('should not report diagnostics related to codes with pathRegex config does not match file name', () => { + const compiler = makeCompiler({ + tsJestConfig: { ...baseTsJestConfig, tsConfig: false, diagnostics: { pathRegex: 'bar.ts' } }, + }) + + expect(() => + compiler.compile( + ` +const f = (v: number) = v +const t: string = f(5) +`, + 'foo.ts', + ), + ).not.toThrowError() + }) +}) diff --git a/src/compiler/transpile-module.ts b/src/compiler/transpile-module.ts new file mode 100644 index 0000000000..8ca30bc4e5 --- /dev/null +++ b/src/compiler/transpile-module.ts @@ -0,0 +1,30 @@ +import { Logger } from 'bs-logger' +import { normalize } from 'path' + +import { ConfigSet } from '../config/config-set' +import { CompileResult, SourceOutput } from '../types' + +/** + * @internal + */ +export const compileUsingTranspileModule = (configs: ConfigSet, logger: Logger): CompileResult => { + logger.debug('compileUsingTranspileModule(): create typescript compiler') + + return (code: string, fileName: string): SourceOutput => { + logger.debug({ fileName }, 'getOutput(): compiling as isolated module') + + const normalizedFileName = normalize(fileName) + const result = configs.compilerModule.transpileModule(code, { + fileName: normalizedFileName, + transformers: configs.tsCustomTransformers, + compilerOptions: configs.typescript.options, + reportDiagnostics: configs.shouldReportDiagnostic(normalizedFileName), + }) + + if (result.diagnostics && configs.shouldReportDiagnostic(normalizedFileName)) { + configs.raiseDiagnostics(result.diagnostics, normalizedFileName, logger) + } + + return [result.outputText, result.sourceMapText!] + } +} diff --git a/src/config/__snapshots__/config-set.spec.ts.snap b/src/config/__snapshots__/config-set.spec.ts.snap index 57ee18fee6..bb58b30cc6 100644 --- a/src/config/__snapshots__/config-set.spec.ts.snap +++ b/src/config/__snapshots__/config-set.spec.ts.snap @@ -42,6 +42,7 @@ Object { "tsJest": Object { "babelConfig": undefined, "compiler": "typescript", + "compilerHost": false, "diagnostics": Object { "ignoreCodes": Array [ 6059, @@ -51,6 +52,7 @@ Object { "pretty": true, "throws": true, }, + "emit": false, "isolatedModules": false, "packageJson": Object { "kind": "file", @@ -67,7 +69,6 @@ Object { "inlineSources": true, "module": 1, "noEmit": false, - "outDir": "$$ts-jest$$", "removeComments": false, "sourceMap": true, "target": 1, @@ -131,6 +132,7 @@ exports[`tsJest should return correct defaults 1`] = ` Object { "babelConfig": undefined, "compiler": "typescript", + "compilerHost": false, "diagnostics": Object { "ignoreCodes": Array [ 6059, @@ -140,6 +142,7 @@ Object { "pretty": true, "throws": true, }, + "emit": false, "isolatedModules": false, "packageJson": Object { "kind": "file", diff --git a/src/config/config-set.spec.ts b/src/config/config-set.spec.ts index a9a18c06fb..3683f768c3 100644 --- a/src/config/config-set.spec.ts +++ b/src/config/config-set.spec.ts @@ -45,7 +45,7 @@ function createConfigSet({ resolve?: ((path: string) => string) | null [key: string]: any } = {}) { - const cs = new ConfigSet(fakers.jestConfig(jestConfig, tsJestConfig), parentConfig) + const cs = new ConfigSet(fakers.getJestConfig(jestConfig, tsJestConfig), parentConfig) if (resolve) { cs.resolvePath = resolve } @@ -893,7 +893,6 @@ Array [ `) expect(compiler.ts).toBe(cs.compilerModule) expect(typeof compiler.compile).toBe('function') - expect(typeof compiler.getTypeInfo).toBe('function') }) }) // tsCompiler @@ -967,7 +966,7 @@ describe('cacheKey', () => { cs.jsonValue.value = val // digest is mocked in src/__mocks__/index.ts expect(cs.cacheKey).toMatchInlineSnapshot( - '"{\\"digest\\":\\"a0d51ca854194df8191d0e65c0ca4730f510f332\\",\\"jest\\":{\\"__backported\\":true,\\"globals\\":{}},\\"projectDepVersions\\":{\\"dev\\":\\"1.2.5\\",\\"opt\\":\\"1.2.3\\",\\"peer\\":\\"1.2.4\\",\\"std\\":\\"1.2.6\\"},\\"transformers\\":[\\"hoisting-jest-mock@1\\"],\\"tsJest\\":{\\"compiler\\":\\"typescript\\",\\"diagnostics\\":{\\"ignoreCodes\\":[6059,18002,18003],\\"pretty\\":true,\\"throws\\":true},\\"isolatedModules\\":false,\\"packageJson\\":{\\"kind\\":\\"file\\"},\\"transformers\\":[]},\\"tsconfig\\":{\\"declaration\\":false,\\"inlineSourceMap\\":false,\\"inlineSources\\":true,\\"module\\":1,\\"noEmit\\":false,\\"outDir\\":\\"$$ts-jest$$\\",\\"removeComments\\":false,\\"sourceMap\\":true,\\"target\\":1}}"', + `"{\\"digest\\":\\"a0d51ca854194df8191d0e65c0ca4730f510f332\\",\\"jest\\":{\\"__backported\\":true,\\"globals\\":{}},\\"projectDepVersions\\":{\\"dev\\":\\"1.2.5\\",\\"opt\\":\\"1.2.3\\",\\"peer\\":\\"1.2.4\\",\\"std\\":\\"1.2.6\\"},\\"transformers\\":[\\"hoisting-jest-mock@1\\"],\\"tsJest\\":{\\"compiler\\":\\"typescript\\",\\"compilerHost\\":false,\\"diagnostics\\":{\\"ignoreCodes\\":[6059,18002,18003],\\"pretty\\":true,\\"throws\\":true},\\"emit\\":false,\\"isolatedModules\\":false,\\"packageJson\\":{\\"kind\\":\\"file\\"},\\"transformers\\":[]},\\"tsconfig\\":{\\"declaration\\":false,\\"inlineSourceMap\\":false,\\"inlineSources\\":true,\\"module\\":1,\\"noEmit\\":false,\\"removeComments\\":false,\\"sourceMap\\":true,\\"target\\":1}}"`, ) }) }) // cacheKey diff --git a/src/config/config-set.ts b/src/config/config-set.ts index f58b61e7d3..c39d5bea61 100644 --- a/src/config/config-set.ts +++ b/src/config/config-set.ts @@ -24,7 +24,7 @@ import { } from 'typescript' import { digest as MY_DIGEST, version as MY_VERSION } from '..' -import { createCompiler } from '../compiler' +import { createCompiler } from '../compiler/instance' import { internals as internalAstTransformers } from '../transformers' import { AstTransformerDesc, @@ -265,15 +265,18 @@ export class ConfigSet { // parsed options const res: TsJestConfig = { tsConfig, + compilerHost: options.compilerHost ?? false, + emit: options.emit ?? false, packageJson, babelConfig, diagnostics, isolatedModules: !!options.isolatedModules, - compiler: options.compiler || 'typescript', + compiler: options.compiler ?? 'typescript', transformers, stringifyContentPathRegex, } this.logger.debug({ tsJestConfig: res }, 'normalized ts-jest config') + return res } @@ -530,17 +533,15 @@ export class ConfigSet { // we don't want to create declaration files declaration: false, noEmit: false, - outDir: '$$ts-jest$$', // else istanbul related will be dropped removeComments: false, // to clear out else it's buggy out: undefined, outFile: undefined, - composite: undefined, + composite: undefined, // see https://github.com/TypeStrong/ts-node/pull/657/files declarationDir: undefined, declarationMap: undefined, emitDeclarationOnly: undefined, - incremental: undefined, sourceRoot: undefined, tsBuildInfoFile: undefined, } @@ -628,6 +629,8 @@ export class ConfigSet { } /** + * Load TypeScript configuration. Returns the parsed TypeScript config and + * any `tsConfig` options specified in ts-jest tsConfig * @internal */ readTsConfig( diff --git a/src/ts-jest-transformer.ts b/src/ts-jest-transformer.ts index 30a51f2ce5..17b5658ae0 100644 --- a/src/ts-jest-transformer.ts +++ b/src/ts-jest-transformer.ts @@ -92,6 +92,7 @@ export class TsJestTransformer implements Transformer { jestConfig: new JsonableValue(jestConfigObj), configSet, }) + return configSet } @@ -127,6 +128,7 @@ export class TsJestTransformer implements Transformer { result = source } else if (isJsFile || isTsFile) { // transpile TS code (source maps are included) + /* istanbul ignore if */ result = configs.tsCompiler.compile(source, filePath) } else { // we should not get called for files with other extension than js[x], ts[x] and d.ts, @@ -163,6 +165,7 @@ export class TsJestTransformer implements Transformer { * @param fileContent The content of the file * @param filePath The full path to the file * @param jestConfigStr The JSON-encoded version of jest config + * @param transformOptions * @param transformOptions.instrument Whether the content will be instrumented by our transformer (always false) * @param transformOptions.rootDir Jest current rootDir */ @@ -176,6 +179,7 @@ export class TsJestTransformer implements Transformer { const configs = this.configsFor(jestConfigStr) // we do not instrument, ensure it is false all the time const { instrument = false, rootDir = configs.rootDir } = transformOptions + return sha1( configs.cacheKey, '\x00', diff --git a/src/types.ts b/src/types.ts index 241e2ca310..1b5b8e75f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,20 @@ export interface TsJestGlobalOptions { */ isolatedModules?: boolean + /** + * Use TypeScript's compiler host API. + * + * @default false + */ + compilerHost?: boolean + + /** + * Emit compiled files into `.ts-jest` directory + * + * @default false + */ + emit?: boolean + /** * Compiler to use (default to 'typescript'): */ @@ -57,8 +71,16 @@ export interface TsJestGlobalOptions { | boolean | { pretty?: boolean + /** + * Ignore TypeScript warnings by diagnostic code. + */ ignoreCodes?: number | string | (number | string)[] pathRegex?: RegExp | string + /** + * Logs TypeScript errors to stderr instead of throwing exceptions. + * + * @default false + */ warnOnly?: boolean } @@ -119,11 +141,12 @@ export interface TsJestConfig { tsConfig: TsJestConfig$tsConfig packageJson: TsJestConfig$packageJson isolatedModules: boolean + compilerHost: boolean + emit: boolean compiler: string diagnostics: TsJestConfig$diagnostics babelConfig: TsJestConfig$babelConfig transformers: string[] - // to deprecate / deprecated === === === stringifyContentPathRegex: TsJestConfig$stringifyContentPathRegex } @@ -159,33 +182,31 @@ export interface TSCommon { formatDiagnosticsWithColorAndContext: typeof _ts.formatDiagnosticsWithColorAndContext } -/** - * Track the project information. - * @internal - */ -export interface MemoryCache { - contents: { [path: string]: string | undefined } - versions: { [path: string]: number | undefined } - outputs: { [path: string]: string } -} - -/** - * Information retrieved from type info check. - */ -export interface TypeInfo { - name: string - comment: string -} - export interface TsCompiler { cwd: string extensions: string[] cachedir: string | undefined ts: TSCommon compile(code: string, fileName: string, lineOffset?: number): string - getTypeInfo(code: string, fileName: string, position: number): TypeInfo } +/** + * Internal source output. + */ +export type SourceOutput = [string, string] + +/** + * Track the project information. + * @internal + */ +export interface MemoryCache { + contents: Map + versions: Map + outputs: Map +} + +export type CompileResult = (code: string, fileName: string, lineOffset?: number) => SourceOutput + export interface AstTransformerDesc { name: string version: number diff --git a/src/util/messages.ts b/src/util/messages.ts index 33939b8af4..028141ee35 100644 --- a/src/util/messages.ts +++ b/src/util/messages.ts @@ -3,12 +3,12 @@ /** * @internal */ -export enum Errors { +export const enum Errors { LoadingModuleFailed = 'Loading module {{module}} failed with error: {{error}}', UnableToLoadOneModule = 'Unable to load the module {{module}}. {{reason}} To fix it:\n{{fix}}', UnableToLoadAnyModule = 'Unable to load any of these modules: {{module}}. {{reason}}. To fix it:\n{{fix}}', TypesUnavailableWithoutTypeCheck = 'Type information is unavailable with "isolatedModules"', - UnableToRequireDefinitionFile = 'Unable to require `.d.ts` file.\nThis is usually the result of a faulty configuration or import. Make sure there is a `.js`, `.json` or another executable extension available alongside `{{file}}`.', + UnableToRequireDefinitionFile = 'Unable to require `.d.ts` file for file: {{file}}.\nThis is usually the result of a faulty configuration or import. Make sure there is a `.js`, `.json` or another executable extension available alongside `{{file}}`.', FileNotFound = 'File not found: {{inputPath}} (resolved as: {{resolvedPath}})', UntestedDependencyVersion = "Version {{actualVersion}} of {{module}} installed has not been tested with ts-jest. If you're experiencing issues, consider using a supported version ({{expectedVersion}}). Please do not report issues in ts-jest if you are using unsupported versions.", MissingDependency = "Module {{module}} is not installed. If you're experiencing issues, consider installing a supported version ({{expectedVersion}}).", @@ -27,7 +27,7 @@ export enum Errors { /** * @internal */ -export enum Helps { +export const enum Helps { FixMissingModule = '{{label}}: `npm i -D {{module}}` (or `yarn add --dev {{module}}`)', IgnoreDiagnosticCode = 'customize using `[jest-config].globals.ts-jest.diagnostics` option', MigrateConfigUsingCLI = 'Your Jest configuration is outdated. Use the CLI to help migrating it: ts-jest config:migrate .', @@ -36,7 +36,7 @@ export enum Helps { /** * @internal */ -export enum Deprecateds { +export const enum Deprecateds { EnvVar = 'Using env. var "{{old}}" is deprecated, use "{{new}}" instead.', ConfigOption = '"[jest-config].{{oldPath}}" is deprecated, use "[jest-config].{{newPath}}" instead.', ConfigOptionWithNote = '"[jest-config].{{oldPath}}" is deprecated, use "[jest-config].{{newPath}}" instead.\n ↳ {{note}}', @@ -47,7 +47,7 @@ export enum Deprecateds { /** * @internal */ -export enum ImportReasons { +export const enum ImportReasons { TsJest = 'Using "ts-jest" requires this package to be installed.', BabelJest = 'Using "babel-jest" requires this package to be installed.', } diff --git a/tslint.json b/tslint.json index 7669513b00..e21fb54d77 100644 --- a/tslint.json +++ b/tslint.json @@ -47,6 +47,7 @@ "no-string-throw": true, "no-var-keyword": true, "object-literal-sort-keys": false, + "one-variable-per-declaration": false, "ordered-imports": [ true, {