From 5f2605457e94b548bd7b9b28fc968554f7eefa91 Mon Sep 17 00:00:00 2001 From: Ahn Date: Wed, 8 Jul 2020 11:12:49 +0200 Subject: [PATCH] fix(compiler): use `resolveModuleNames` TypeScript API to get resolved modules for test files (#1784) Fixes #1747 --- .../diagnostics/{throw => }/main.spec.ts | 0 e2e/__cases__/diagnostics/{throw => }/main.ts | 0 e2e/__cases__/diagnostics/warn/main.spec.ts | 7 - e2e/__cases__/diagnostics/warn/main.ts | 2 - e2e/__cases__/test-helpers/deprecated.spec.ts | 4 +- e2e/__cases__/test-helpers/pass-to-mock.ts | 25 +++ e2e/__cases__/test-helpers/pass.spec.ts | 4 +- .../__snapshots__/diagnostics.test.ts.snap | 6 +- e2e/__tests__/diagnostics.test.ts | 6 +- src/__helpers__/fakers.ts | 44 +++- src/__mocks__/changed-modules/main.spec.ts | 3 - src/__mocks__/changed-modules/main.ts | 3 - src/__mocks__/thing.spec.ts | 4 +- .../{unchanged-modules/main.ts => thing.ts} | 1 + src/__mocks__/thing1.spec.ts | 3 + src/__mocks__/unchanged-modules/main.spec.ts | 3 - .../language-service.spec.ts.snap | 86 ++++++- src/compiler/compiler-utils.ts | 52 ----- src/compiler/language-service.spec.ts | 211 ++++++++---------- src/compiler/language-service.ts | 139 ++++++++---- .../__snapshots__/config-set.spec.ts.snap | 30 ++- src/config/config-set.spec.ts | 28 +-- src/config/config-set.ts | 1 + src/types.ts | 7 +- 24 files changed, 372 insertions(+), 297 deletions(-) rename e2e/__cases__/diagnostics/{throw => }/main.spec.ts (100%) rename e2e/__cases__/diagnostics/{throw => }/main.ts (100%) delete mode 100644 e2e/__cases__/diagnostics/warn/main.spec.ts delete mode 100644 e2e/__cases__/diagnostics/warn/main.ts create mode 100644 e2e/__cases__/test-helpers/pass-to-mock.ts delete mode 100644 src/__mocks__/changed-modules/main.spec.ts delete mode 100644 src/__mocks__/changed-modules/main.ts rename src/__mocks__/{unchanged-modules/main.ts => thing.ts} (76%) create mode 100644 src/__mocks__/thing1.spec.ts delete mode 100644 src/__mocks__/unchanged-modules/main.spec.ts delete mode 100644 src/compiler/compiler-utils.ts diff --git a/e2e/__cases__/diagnostics/throw/main.spec.ts b/e2e/__cases__/diagnostics/main.spec.ts similarity index 100% rename from e2e/__cases__/diagnostics/throw/main.spec.ts rename to e2e/__cases__/diagnostics/main.spec.ts diff --git a/e2e/__cases__/diagnostics/throw/main.ts b/e2e/__cases__/diagnostics/main.ts similarity index 100% rename from e2e/__cases__/diagnostics/throw/main.ts rename to e2e/__cases__/diagnostics/main.ts diff --git a/e2e/__cases__/diagnostics/warn/main.spec.ts b/e2e/__cases__/diagnostics/warn/main.spec.ts deleted file mode 100644 index 9ff2d8a187..0000000000 --- a/e2e/__cases__/diagnostics/warn/main.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { foo, Thing } from './main'; - -export const thing: Thing = { a: 1 }; - -test('foo is 42', () => { - expect(foo).toBe(42); -}); diff --git a/e2e/__cases__/diagnostics/warn/main.ts b/e2e/__cases__/diagnostics/warn/main.ts deleted file mode 100644 index 8aaf8e72ca..0000000000 --- a/e2e/__cases__/diagnostics/warn/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const foo = 42 -export type Thing = { a: number, b: number } diff --git a/e2e/__cases__/test-helpers/deprecated.spec.ts b/e2e/__cases__/test-helpers/deprecated.spec.ts index 14e482acf6..fd89b8235b 100644 --- a/e2e/__cases__/test-helpers/deprecated.spec.ts +++ b/e2e/__cases__/test-helpers/deprecated.spec.ts @@ -1,6 +1,6 @@ import { mocked } from 'ts-jest' -import { foo } from './to-mock' -jest.mock('./to-mock') +import { foo } from './pass-to-mock' +jest.mock('./pass-to-mock') test('foo', () => { foo() diff --git a/e2e/__cases__/test-helpers/pass-to-mock.ts b/e2e/__cases__/test-helpers/pass-to-mock.ts new file mode 100644 index 0000000000..83860de331 --- /dev/null +++ b/e2e/__cases__/test-helpers/pass-to-mock.ts @@ -0,0 +1,25 @@ +export const foo = () => 'foo' + +export function bar() { + return 'bar' +} +export namespace bar { + export function dummy() { + return 'dummy' + } + export namespace dummy { + export const deep = { + deeper: (one: string = '1') => `deeper ${one}` + } + } +} + +export class MyClass { + constructor(s: string) { + this.myProperty = 3 + this.myStr = s + } + somethingClassy() { return this.myStr } + public myProperty: number; + public myStr: string; +} diff --git a/e2e/__cases__/test-helpers/pass.spec.ts b/e2e/__cases__/test-helpers/pass.spec.ts index 4744e748d8..976311e200 100644 --- a/e2e/__cases__/test-helpers/pass.spec.ts +++ b/e2e/__cases__/test-helpers/pass.spec.ts @@ -1,6 +1,6 @@ import { mocked } from 'ts-jest/utils' -import { foo, bar, MyClass } from './to-mock' -jest.mock('./to-mock') +import { foo, bar, MyClass } from './pass-to-mock' +jest.mock('./pass-to-mock') test('foo', () => { // real returns 'foo', mocked returns 'bar' diff --git a/e2e/__tests__/__snapshots__/diagnostics.test.ts.snap b/e2e/__tests__/__snapshots__/diagnostics.test.ts.snap index cb930f3571..f27fa9a493 100644 --- a/e2e/__tests__/__snapshots__/diagnostics.test.ts.snap +++ b/e2e/__tests__/__snapshots__/diagnostics.test.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`With diagnostics throw should fail using template "default" 1`] = ` - × jest + × jest --no-cache ↳ exit code: 1 ===[ STDOUT ]=================================================================== @@ -28,7 +28,7 @@ exports[`With diagnostics throw should fail using template "default" 1`] = ` `; exports[`With diagnostics throw should fail using template "with-babel-7" 1`] = ` - × jest + × jest --no-cache ↳ exit code: 1 ===[ STDOUT ]=================================================================== @@ -55,7 +55,7 @@ exports[`With diagnostics throw should fail using template "with-babel-7" 1`] = `; exports[`With diagnostics throw should fail using template "with-babel-7-string-config" 1`] = ` - × jest + × jest --no-cache ↳ exit code: 1 ===[ STDOUT ]=================================================================== diff --git a/e2e/__tests__/diagnostics.test.ts b/e2e/__tests__/diagnostics.test.ts index 451d472313..0ab2be89d7 100644 --- a/e2e/__tests__/diagnostics.test.ts +++ b/e2e/__tests__/diagnostics.test.ts @@ -2,7 +2,9 @@ import { allValidPackageSets } from '../__helpers__/templates' import { configureTestCase } from '../__helpers__/test-case' describe('With diagnostics throw', () => { - const testCase = configureTestCase('diagnostics/throw') + const testCase = configureTestCase('diagnostics', { + noCache: true, // warnings shown only on first compilation + }) testCase.runWithTemplates(allValidPackageSets, 1, (runTest, { testLabel }) => { it(testLabel, () => { @@ -14,7 +16,7 @@ describe('With diagnostics throw', () => { }) describe('With diagnostics warn only', () => { - const testCase = configureTestCase('diagnostics/warn', { + const testCase = configureTestCase('diagnostics', { tsJestConfig: { diagnostics: { warnOnly: true }, }, diff --git a/src/__helpers__/fakers.ts b/src/__helpers__/fakers.ts index cce96601fa..f417dddbf1 100644 --- a/src/__helpers__/fakers.ts +++ b/src/__helpers__/fakers.ts @@ -26,7 +26,7 @@ export function tsJestConfig(options?: Partial): TsJestConfig { } } -export function getJestConfig( +function getJestConfig( options?: Partial, tsJestOptions?: TsJestGlobalOptions, ): T { @@ -53,6 +53,39 @@ export function importReason(text = '[[BECAUSE]]'): ImportReasons { return text as any } +export const defaultResolve = (path: string): string => `resolved:${path}` + +export function createConfigSet({ + jestConfig, + tsJestConfig, + parentConfig, + resolve = defaultResolve, + ...others +}: { + jestConfig?: Partial + tsJestConfig?: TsJestGlobalOptions + parentConfig?: TsJestGlobalOptions + resolve?: ((path: string) => string) | null + [key: string]: any +} = {}): ConfigSet { + const defaultTestRegex = ['(/__tests__/.*|(\\\\.|/)(test|spec))\\\\.[jt]sx?$'] + const defaultTestMatch = ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'] + jestConfig = { + ...jestConfig, + testMatch: jestConfig?.testMatch ? [...jestConfig.testMatch, ...defaultTestMatch] : defaultTestMatch, + testRegex: jestConfig?.testRegex ? [...defaultTestRegex, ...jestConfig.testRegex] : defaultTestRegex, + } + const cs = new ConfigSet(getJestConfig(jestConfig, tsJestConfig), parentConfig) + if (resolve) { + cs.resolvePath = resolve + } + Object.keys(others).forEach((key) => { + Object.defineProperty(cs, key, { value: others[key] }) + }) + + return cs +} + // not really unit-testing here, but it's hard to mock all those values :-D export function makeCompiler({ jestConfig, @@ -68,14 +101,7 @@ export function makeCompiler({ ...(tsJestConfig.diagnostics as any), pretty: false, } - const testRegex = ['^.+\\.[tj]sx?$'] - const testMatch = ['^.+\\.tsx?$'] - jestConfig = { - ...jestConfig, - testMatch: jestConfig?.testMatch ? [...jestConfig.testMatch, ...testMatch] : testMatch, - testRegex: jestConfig?.testRegex ? [...testRegex, ...jestConfig.testRegex] : testRegex, - } - const cs = new ConfigSet(getJestConfig(jestConfig, tsJestConfig), parentConfig) + const cs = createConfigSet({ jestConfig, tsJestConfig, parentConfig, resolve: null }) return createCompilerInstance(cs) } diff --git a/src/__mocks__/changed-modules/main.spec.ts b/src/__mocks__/changed-modules/main.spec.ts deleted file mode 100644 index 40e5da46d6..0000000000 --- a/src/__mocks__/changed-modules/main.spec.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Thing } from './main' - -export const thing: Thing = { a: 1 } diff --git a/src/__mocks__/changed-modules/main.ts b/src/__mocks__/changed-modules/main.ts deleted file mode 100644 index fea8e002b1..0000000000 --- a/src/__mocks__/changed-modules/main.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Thing { - a: number -} diff --git a/src/__mocks__/thing.spec.ts b/src/__mocks__/thing.spec.ts index bc40cfb36e..0ae3f7e9d1 100644 --- a/src/__mocks__/thing.spec.ts +++ b/src/__mocks__/thing.spec.ts @@ -1,5 +1,3 @@ -interface Thing { - a: number -} +import { Thing } from './thing' export const thing: Thing = { a: 1 } diff --git a/src/__mocks__/unchanged-modules/main.ts b/src/__mocks__/thing.ts similarity index 76% rename from src/__mocks__/unchanged-modules/main.ts rename to src/__mocks__/thing.ts index fea8e002b1..f11e5f04f0 100644 --- a/src/__mocks__/unchanged-modules/main.ts +++ b/src/__mocks__/thing.ts @@ -1,3 +1,4 @@ export interface Thing { a: number + b: number } diff --git a/src/__mocks__/thing1.spec.ts b/src/__mocks__/thing1.spec.ts new file mode 100644 index 0000000000..74c8df1e4f --- /dev/null +++ b/src/__mocks__/thing1.spec.ts @@ -0,0 +1,3 @@ +import { Thing } from './thing' + +export const thing: Thing = { a: 1, b: 2 } diff --git a/src/__mocks__/unchanged-modules/main.spec.ts b/src/__mocks__/unchanged-modules/main.spec.ts deleted file mode 100644 index 40e5da46d6..0000000000 --- a/src/__mocks__/unchanged-modules/main.spec.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Thing } from './main' - -export const thing: Thing = { a: 1 } diff --git a/src/compiler/__snapshots__/language-service.spec.ts.snap b/src/compiler/__snapshots__/language-service.spec.ts.snap index d57b59a2fc..8230db1e0c 100644 --- a/src/compiler/__snapshots__/language-service.spec.ts.snap +++ b/src/compiler/__snapshots__/language-service.spec.ts.snap @@ -36,7 +36,84 @@ exports[`Language service allowJs option should compile js file for allowJs true ================================================================================ `; -exports[`Language service diagnostics should report diagnostics related to typings with pathRegex config matches file name 1`] = `"test-diagnostics.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'."`; +exports[`Language service diagnostics should only report diagnostics for imported modules but not test files without cache 1`] = ` +Array [ + "[level:20] compileAndUpdateOutput(): get compile output +", + "[level:20] compileFn(): compiling using language service +", + "[level:20] updateMemoryCache(): update memory cache for language service +", + "[level:20] visitSourceFileNode(): hoisting +", + "[level:20] compileFn(): computing diagnostics using language service +", +] +`; + +exports[`Language service diagnostics should report diagnostics for imported modules as well as test files which use imported modules with cache 1`] = ` +Array [ + "[level:20] compileAndUpdateOutput(): get compile output +", + "[level:20] compileFn(): compiling using language service +", + "[level:20] updateMemoryCache(): update memory cache for language service +", + "[level:20] visitSourceFileNode(): hoisting +", + "[level:20] compileFn(): computing diagnostics using language service +", + "[level:20] updateMemoryCache(): update memory cache for language service +", + "[level:20] compileFn(): computing diagnostics using language service for test file which uses the module +", +] +`; + +exports[`Language service diagnostics should throw error when cannot compile 1`] = ` +"Unable to require \`.d.ts\` file for file: test-cannot-compile.d.ts. +This is usually the result of a faulty configuration or import. Make sure there is a \`.js\`, \`.json\` or another executable extension available alongside \`test-cannot-compile.d.ts\`." +`; + +exports[`Language service diagnostics shouldn't report diagnostic when processing file isn't used by any test files 1`] = ` +Array [ + "[level:20] compileAndUpdateOutput(): get compile output +", + "[level:20] compileFn(): compiling using language service +", + "[level:20] updateMemoryCache(): update memory cache for language service +", + "[level:20] visitSourceFileNode(): hoisting +", + "[level:20] compileFn(): computing diagnostics using language service +", +] +`; + +exports[`Language service diagnostics shouldn't report diagnostics for test file name that has been type checked before 1`] = ` +Array [ + "[level:20] compileAndUpdateOutput(): get compile output +", + "[level:20] compileFn(): compiling using language service +", + "[level:20] updateMemoryCache(): update memory cache for language service +", + "[level:20] visitSourceFileNode(): hoisting +", + "[level:20] compileFn(): computing diagnostics using language service +", + "[level:20] compileAndUpdateOutput(): get compile output +", + "[level:20] compileFn(): compiling using language service +", + "[level:20] updateMemoryCache(): update memory cache for language service +", + "[level:20] visitSourceFileNode(): hoisting +", + "[level:20] compileFn(): computing diagnostics using language service +", +] +`; exports[`Language service jsx option should compile tsx file for jsx preserve 1`] = ` ===[ FILE: test-jsx.tsx ]======================================================= @@ -85,10 +162,3 @@ exports[`Language service jsx option should compile tsx file for other jsx optio version: 3 ================================================================================ `; - -exports[`Language service should do type check for the test file when imported module has changed 1`] = `"src/__mocks__/changed-modules/main.spec.ts(3,14): error TS2741: Property 'b' is missing in type '{ a: number; }' but required in type 'Thing'."`; - -exports[`Language service should throw error when cannot compile 1`] = ` -"Unable to require \`.d.ts\` file for file: test-cannot-compile.d.ts. -This is usually the result of a faulty configuration or import. Make sure there is a \`.js\`, \`.json\` or another executable extension available alongside \`test-cannot-compile.d.ts\`." -`; diff --git a/src/compiler/compiler-utils.ts b/src/compiler/compiler-utils.ts deleted file mode 100644 index 10551c0cfe..0000000000 --- a/src/compiler/compiler-utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Logger } from 'bs-logger' -import { writeFileSync } from 'fs' -import { join, normalize } from 'path' -import * as _ts from 'typescript' - -import { MemoryCache } from '../types' -import { sha1 } from '../util/sha1' -import { stringify } from '../util/json' - -/** - * @internal - */ -export function getResolvedModulesCache(cacheDir: string): string { - return join(cacheDir, sha1('ts-jest-resolved-modules', '\x00')) -} - -/** - * @internal - * Get resolved modules of a test file and put into memory cache - */ -/* istanbul ignore next (we leave this for e2e) */ -export function cacheResolvedModules( - fileName: string, - fileContent: string, - memoryCache: MemoryCache, - program: _ts.Program, - cacheDir: string, - logger: Logger, -): void { - /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ - // @ts-expect-error - const importReferences = program.getSourceFile(fileName)!.imports // eslint-disable-line @typescript-eslint/no-non-null-assertion - /** - * Ugly trick while waiting for https://github.com/microsoft/TypeScript/issues/33994 - */ - if (importReferences.length) { - logger.debug({ fileName }, 'cacheResolvedModules(): get resolved modules') - - memoryCache.resolvedModules[fileName] = Object.create(null) - memoryCache.resolvedModules[fileName].modulePaths = importReferences - .filter((importReference: any) => importReference.parent.parent.resolvedModules?.get(importReference.text)) - .map((importReference: any) => - normalize( - (importReference.parent.parent.resolvedModules.get(importReference.text) as _ts.ResolvedModule) - .resolvedFileName, - ), - ) - .reduce((a: any, b: any) => a.concat(b), []) - memoryCache.resolvedModules[fileName].testFileContent = fileContent - writeFileSync(getResolvedModulesCache(cacheDir), stringify(memoryCache.resolvedModules)) - } -} diff --git a/src/compiler/language-service.spec.ts b/src/compiler/language-service.spec.ts index 8a82bcefef..513ed4581e 100644 --- a/src/compiler/language-service.spec.ts +++ b/src/compiler/language-service.spec.ts @@ -1,14 +1,11 @@ import { LogLevels } from 'bs-logger' import { readFileSync } from 'fs' -import { writeFileSync } from 'fs-extra' +import { removeSync } from 'fs-extra' +import { join } from 'path' import { makeCompiler } from '../__helpers__/fakers' import { logTargetMock } from '../__helpers__/mocks' -import { tempDir } from '../__helpers__/path' import ProcessedSource from '../__helpers__/processed-source' -import { normalizeSlashes } from '../util/normalize-slashes' - -import * as compilerUtils from './compiler-utils' const logTarget = logTargetMock() @@ -17,47 +14,6 @@ describe('Language service', () => { logTarget.clear() }) - describe('cache resolved modules', () => { - let spy: jest.SpyInstance - const tmp = tempDir('compiler') - const fileName = 'src/__mocks__/unchanged-modules/main.spec.ts' - const source = `import { Thing } from './main' - -export const thing: Thing = { a: 1 }` - - beforeEach(() => { - spy = jest.spyOn(compilerUtils, 'cacheResolvedModules').mockImplementation(() => {}) - }) - - afterEach(() => { - spy.mockRestore() - }) - - it('should cache resolved modules for test file with testMatchPatterns from jest config when match', () => { - const compiler = makeCompiler({ - jestConfig: { cache: true, cacheDirectory: tmp, testRegex: [/.*\.(spec|test)\.[jt]sx?$/] as any[] }, - tsJestConfig: { tsConfig: false }, - }) - - compiler.compile(source, fileName) - - expect(spy).toHaveBeenCalled() - expect(spy.mock.calls[0][0]).toEqual(normalizeSlashes(fileName)) - expect(spy.mock.calls[0][1]).toEqual(source) - }) - - it("shouldn't cache resolved modules for test file with testMatchPatterns from jest config when not match", () => { - const compiler = makeCompiler({ - jestConfig: { cache: true, cacheDirectory: tmp, testRegex: [/.*\.(foo|bar)\.[jt]sx?$/] as any[] }, - tsJestConfig: { tsConfig: false }, - }) - - compiler.compile(source, fileName) - - expect(compilerUtils.cacheResolvedModules).not.toHaveBeenCalled() - }) - }) - describe('allowJs option', () => { const fileName = 'test-allow-js.js' const source = 'export default 42' @@ -123,7 +79,7 @@ export const thing: Thing = { a: 1 }` const fileName = 'test-source-map.ts' it('should have correct source maps without mapRoot', () => { - const compiler = makeCompiler({ tsJestConfig: { tsConfig: false } }) + const compiler = makeCompiler({ tsJestConfig: { tsConfig: require.resolve('../../tsconfig.spec.json') } }) const compiled = compiler.compile(source, fileName) expect(new ProcessedSource(compiled, fileName).outputSourceMaps).toMatchObject({ @@ -151,99 +107,122 @@ export const thing: Thing = { a: 1 }` }) }) - it('should not do type check for the test file which is already finished type checking before', () => { - const tmp = tempDir('compiler') - const testFileName = 'src/__mocks__/unchanged-modules/main.spec.ts' - const testFileSrc = readFileSync(testFileName, 'utf-8') - const importedModuleSrc = readFileSync('src/__mocks__/unchanged-modules/main.ts', 'utf-8') + describe('diagnostics', () => { + const importedFileName = require.resolve('../__mocks__/thing.ts') + const importedFileContent = readFileSync(importedFileName, 'utf-8') + const baseTsJestConfig = { tsConfig: require.resolve('../../tsconfig.spec.json') } + + it(`should report diagnostics for imported modules as well as test files which use imported modules with cache`, async () => { + const testFileName = require.resolve('../__mocks__/thing1.spec.ts') + const testFileContent = readFileSync(testFileName, 'utf-8') + const cacheDir = join(process.cwd(), 'tmp') + /** + * Run the 1st compilation with Promise resolve setTimeout to stimulate 2 different test runs to test cached + * resolved modules + */ + async function firstCompile() { + return new Promise((resolve) => { + const compiler1 = makeCompiler({ + jestConfig: { + cache: true, + cacheDirectory: cacheDir, + }, + tsJestConfig: baseTsJestConfig, + }) + + logTarget.clear() + compiler1.compile(testFileContent, testFileName) + + // probably 300ms is enough to stimulate 2 separated runs after each other + setTimeout(() => resolve(), 300) + }) + } + + await firstCompile() + + const compiler2 = makeCompiler({ + jestConfig: { + cache: true, + cacheDirectory: cacheDir, + }, + tsJestConfig: baseTsJestConfig, + }) + logTarget.clear() - const compiler = makeCompiler({ - jestConfig: { cache: true, cacheDirectory: tmp, testMatch: ['src/__mocks__/unchanged-modules/*.spec.ts'] }, - tsJestConfig: { tsConfig: false }, - }) + compiler2.compile(importedFileContent, importedFileName) - compiler.compile(testFileSrc, testFileName) - logTarget.clear() - compiler.compile(importedModuleSrc, require.resolve('../__mocks__/unchanged-modules/main.ts')) - - expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchInlineSnapshot(` - Array [ - "[level:20] compileAndUpdateOutput(): get compile output - ", - "[level:20] compileFn(): compiling using language service - ", - "[level:20] updateMemoryCache(): update memory cache for language service - ", - "[level:20] visitSourceFileNode(): hoisting - ", - "[level:20] compileFn(): computing diagnostics using language service - ", - ] - `) - }) + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchSnapshot() - it('should do type check for the test file when imported module has changed', () => { - const tmp = tempDir('compiler') - const testFileName = 'src/__mocks__/changed-modules/main.spec.ts' - const testFileSrc = readFileSync(testFileName, 'utf-8') - const importedModulePath = 'src/__mocks__/changed-modules/main.ts' - const importedModuleSrc = readFileSync(importedModulePath, 'utf-8') - const newImportedModuleSrc = 'export interface Thing { a: number, b: number }' - - const compiler1 = makeCompiler({ - jestConfig: { cache: true, cacheDirectory: tmp, testMatch: ['src/__mocks__/changed-modules/*.spec.ts'] }, - tsJestConfig: { tsConfig: false }, + removeSync(cacheDir) }) - compiler1.compile(testFileSrc, testFileName) - writeFileSync(importedModulePath, 'export interface Thing { a: number, b: number }') - const compiler2 = makeCompiler({ - jestConfig: { cache: true, cacheDirectory: tmp, testMatch: ['src/__mocks__/changed-modules/*.spec.ts'] }, - tsJestConfig: { tsConfig: false }, + it(`should only report diagnostics for imported modules but not test files without cache`, () => { + const testFileName = require.resolve('../__mocks__/thing1.spec.ts') + const testFileContent = readFileSync(testFileName, 'utf-8') + const compiler1 = makeCompiler({ + tsJestConfig: baseTsJestConfig, + }) + logTarget.clear() + compiler1.compile(testFileContent, testFileName) + + const compiler2 = makeCompiler({ + tsJestConfig: baseTsJestConfig, + }) + logTarget.clear() + + compiler2.compile(importedFileContent, importedFileName) + + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchSnapshot() }) - expect(() => - compiler2.compile(newImportedModuleSrc, require.resolve('../__mocks__/changed-modules/main.ts')), - ).toThrowErrorMatchingSnapshot() + it(`shouldn't report diagnostics for test file name that has been type checked before`, () => { + const testFileName = require.resolve('../__mocks__/thing1.spec.ts') + const testFileContent = readFileSync(testFileName, 'utf-8') + const compiler1 = makeCompiler({ + tsJestConfig: baseTsJestConfig, + }) + logTarget.clear() - writeFileSync(importedModulePath, importedModuleSrc) - }) + compiler1.compile(testFileContent, testFileName) + compiler1.compile(importedFileContent, importedFileName) - describe('diagnostics', () => { - const fileName = 'test-diagnostics.ts' - const source = ` -const g = (v: number) => v -const x: string = g(5) -` + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchSnapshot() + }) - it('should report diagnostics related to typings with pathRegex config matches file name', () => { + it(`shouldn't report diagnostics when file name doesn't match diagnostic file pattern`, () => { const compiler = makeCompiler({ - tsJestConfig: { tsConfig: false, diagnostics: { pathRegex: fileName } }, + tsJestConfig: { + ...baseTsJestConfig, + diagnostics: { pathRegex: 'foo.spec.ts' }, + }, }) - expect(() => compiler.compile(source, fileName)).toThrowErrorMatchingSnapshot() + expect(() => compiler.compile(importedFileContent, importedFileName)).not.toThrowError() }) - it('should not report diagnostics related to typings with pathRegex config does not match file name', () => { + it(`shouldn't report diagnostic when processing file isn't used by any test files`, () => { const compiler = makeCompiler({ - tsJestConfig: { tsConfig: false, diagnostics: { pathRegex: 'bar.ts' } }, + tsJestConfig: baseTsJestConfig, }) + logTarget.clear() - expect(() => compiler.compile(source, fileName)).not.toThrowError() + compiler.compile(importedFileContent, 'foo.ts') + + expect(logTarget.filteredLines(LogLevels.debug, Infinity)).toMatchSnapshot() }) - }) - it('should throw error when cannot compile', () => { - const fileName = 'test-cannot-compile.d.ts' - const source = ` + it('should throw error when cannot compile', () => { + const fileName = 'test-cannot-compile.d.ts' + const source = ` interface Foo { a: string } ` - const compiler = makeCompiler({ - tsJestConfig: { tsConfig: false }, - }) + const compiler = makeCompiler({ + tsJestConfig: baseTsJestConfig, + }) - expect(() => compiler.compile(source, fileName)).toThrowErrorMatchingSnapshot() + expect(() => compiler.compile(source, fileName)).toThrowErrorMatchingSnapshot() + }) }) }) diff --git a/src/compiler/language-service.ts b/src/compiler/language-service.ts index ca55528c44..5c671a7c23 100644 --- a/src/compiler/language-service.ts +++ b/src/compiler/language-service.ts @@ -1,21 +1,29 @@ import { LogContexts, Logger, LogLevels } from 'bs-logger' -import { readFileSync } from 'fs' -import { basename, normalize, relative } from 'path' +import { readFileSync, writeFile } from 'fs' +import { basename, normalize, relative, join } from 'path' +import memoize = require('lodash.memoize') import mkdirp = require('mkdirp') import * as _ts from 'typescript' -import { ConfigSet } from '../config/config-set' +import type { ConfigSet } from '../config/config-set' import { LINE_FEED } from '../constants' import { CompilerInstance, MemoryCache, SourceOutput, TSFile } from '../types' import { Errors, interpolate } from '../util/messages' -import { cacheResolvedModules, getResolvedModulesCache } from './compiler-utils' -import memoize = require('lodash.memoize') +import { parse, stringify } from '../util/json' +import { sha1 } from '../util/sha1' -function doTypeChecking(configs: ConfigSet, fileName: string, service: _ts.LanguageService, logger: Logger) { +function doTypeChecking( + configs: ConfigSet, + diagnosedFiles: string[], + fileName: string, + service: _ts.LanguageService, + logger: Logger, +): void { if (configs.shouldReportDiagnostic(fileName)) { // Get the relevant diagnostics - this is 3x faster than `getPreEmitDiagnostics`. const diagnostics = service.getSemanticDiagnostics(fileName).concat(service.getSyntacticDiagnostics(fileName)) + diagnosedFiles.push(fileName) // will raise or just warn diagnostics depending on config configs.raiseDiagnostics(diagnostics, fileName, logger) } @@ -31,22 +39,25 @@ export const initializeLanguageServiceInstance = (configs: ConfigSet, logger: Lo const cwd = configs.cwd const cacheDir = configs.tsCacheDir const { options, fileNames } = configs.parsedTsConfig - const memoryCache: MemoryCache = { - files: new Map(), - resolvedModules: Object.create(null), - } + const diagnosedFiles: string[] = [] const serviceHostTraceCtx = { namespace: 'ts:serviceHost', call: null, [LogContexts.logLevel]: LogLevels.trace, } + const memoryCache: MemoryCache = { + files: new Map(), + resolvedModules: new Map(), + } + let tsResolvedModulesCachePath: string | undefined if (cacheDir) { // Make sure the cache directory exists before continuing. mkdirp.sync(cacheDir) + tsResolvedModulesCachePath = join(cacheDir, sha1('ts-jest-resolved-modules', '\x00')) try { - const fsMemoryCache = readFileSync(getResolvedModulesCache(cacheDir), 'utf-8') - /* istanbul ignore next (covered by e2e) */ - memoryCache.resolvedModules = JSON.parse(fsMemoryCache) + /* istanbul ignore next (already covered with unit test) */ + const cachedTSResolvedModules = readFileSync(tsResolvedModulesCachePath, 'utf-8') + memoryCache.resolvedModules = new Map(parse(cachedTSResolvedModules)) } catch (e) {} } // Initialize memory cache for typescript compiler @@ -55,10 +66,39 @@ export const initializeLanguageServiceInstance = (configs: ConfigSet, logger: Lo version: 0, }) }) - function isFileInCache(fileName: string) { + function isFileInCache(fileName: string): boolean { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return memoryCache.files.has(fileName) && memoryCache.files.get(fileName)!.version !== 0 } + const cacheReadFile = logger.wrap(serviceHostTraceCtx, 'readFile', memoize(ts.sys.readFile)) + /* istanbul ignore next */ + const moduleResolutionHost: _ts.ModuleResolutionHost = { + fileExists: memoize(ts.sys.fileExists), + readFile: cacheReadFile, + directoryExists: memoize(ts.sys.directoryExists), + getCurrentDirectory: () => cwd, + realpath: ts.sys.realpath && memoize(ts.sys.realpath), + getDirectories: memoize(ts.sys.getDirectories), + } + function resolveModuleNames(moduleNames: string[], containingFile: string): (_ts.ResolvedModuleFull | undefined)[] { + const normalizedContainingFile = normalize(containingFile) + const currentResolvedModules = memoryCache.resolvedModules.get(normalizedContainingFile) ?? [] + + return moduleNames.map((moduleName) => { + const resolveModuleName = ts.resolveModuleName(moduleName, containingFile, options, moduleResolutionHost) + const resolvedModule = resolveModuleName.resolvedModule + if (configs.isTestFile(normalizedContainingFile) && resolvedModule) { + const normalizedResolvedFileName = normalize(resolvedModule.resolvedFileName) + if (!currentResolvedModules.includes(normalizedResolvedFileName)) { + currentResolvedModules.push(normalizedResolvedFileName) + memoryCache.resolvedModules.set(normalizedContainingFile, currentResolvedModules) + } + } + + return resolvedModule + }) + } + let projectVersion = 1 // Set the file contents into cache. /* istanbul ignore next (cover by e2e) */ @@ -112,7 +152,6 @@ export const initializeLanguageServiceInstance = (configs: ConfigSet, logger: Lo }, getScriptSnapshot(fileName: string) { const normalizedFileName = normalize(fileName) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const hit = isFileInCache(normalizedFileName) logger.trace({ normalizedFileName, cacheHit: hit }, 'getScriptSnapshot():', 'cache', hit ? 'hit' : 'miss') @@ -120,7 +159,7 @@ export const initializeLanguageServiceInstance = (configs: ConfigSet, logger: Lo // Read contents from TypeScript memory cache. if (!hit) { memoryCache.files.set(normalizedFileName, { - text: ts.sys.readFile(normalizedFileName), + text: cacheReadFile(normalizedFileName), version: 1, }) } @@ -131,7 +170,7 @@ export const initializeLanguageServiceInstance = (configs: ConfigSet, logger: Lo return ts.ScriptSnapshot.fromString(contents) }, fileExists: memoize(ts.sys.fileExists), - readFile: logger.wrap(serviceHostTraceCtx, 'readFile', memoize(ts.sys.readFile)), + readFile: cacheReadFile, readDirectory: memoize(ts.sys.readDirectory), getDirectories: memoize(ts.sys.getDirectories), directoryExists: memoize(ts.sys.directoryExists), @@ -141,6 +180,7 @@ export const initializeLanguageServiceInstance = (configs: ConfigSet, logger: Lo getCompilationSettings: () => options, getDefaultLibFileName: () => ts.getDefaultLibFilePath(options), getCustomTransformers: () => configs.tsCustomTransformers, + resolveModuleNames, } logger.debug('initializeLanguageServiceInstance(): creating language service') @@ -151,42 +191,45 @@ export const initializeLanguageServiceInstance = (configs: ConfigSet, logger: Lo compileFn: (code: string, fileName: string): SourceOutput => { logger.debug({ fileName }, 'compileFn(): compiling using language service') - // Must set memory cache before attempting to read file. + // Must set memory cache before attempting to compile updateMemoryCache(code, fileName) const output: _ts.EmitOutput = service.getEmitOutput(fileName) - // Do type checking by getting TypeScript diagnostics - logger.debug({ fileName }, 'compileFn(): computing diagnostics using language service') - - doTypeChecking(configs, fileName, service, logger) + /* istanbul ignore next */ + if (tsResolvedModulesCachePath) { + // Cache resolved modules to disk so next run can reuse it + void (async () => { + // eslint-disable-next-line @typescript-eslint/await-thenable + await writeFile(tsResolvedModulesCachePath, stringify([...memoryCache.resolvedModules]), () => {}) + })() + } /** - * We don't need the following logic with no cache run because no cache always gives correct typing + * There might be a chance that test files are type checked even before jest executes them, we don't need to do + * type check again */ - if (cacheDir) { - if (configs.isTestFile(fileName)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - cacheResolvedModules(fileName, code, memoryCache, service.getProgram()!, cacheDir, logger) - } else { - Object.entries(memoryCache.resolvedModules) - .filter( - (entry) => - /** - * When imported modules change, we only need to check whether the test file is compiled previously or not - * base on memory cache. By checking memory cache, we can avoid repeatedly doing type checking against - * test file for 1st time run after clearing cache because - */ - entry[1].modulePaths.find((modulePath) => modulePath === fileName) && !memoryCache.files.has(entry[0]), + if (!diagnosedFiles.includes(fileName)) { + logger.debug({ fileName }, 'compileFn(): computing diagnostics using language service') + + doTypeChecking(configs, diagnosedFiles, fileName, service, logger) + } + /* istanbul ignore next (already covered with unit tests) */ + if (!configs.isTestFile(fileName)) { + for (const [testFileName, resolvedModules] of memoryCache.resolvedModules.entries()) { + // Only do type checking for test files which haven't been type checked before + if (resolvedModules.includes(fileName) && !diagnosedFiles.includes(testFileName)) { + const testFileContent = memoryCache.files.get(testFileName)?.text + if (!testFileContent) { + // Must set memory cache before attempting to get diagnostics + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + updateMemoryCache(cacheReadFile(testFileName)!, testFileName) + } + + logger.debug( + { testFileName }, + 'compileFn(): computing diagnostics using language service for test file which uses the module', ) - .forEach((entry) => { - const testFileName = entry[0] - const testFileContent = entry[1].testFileContent - logger.debug( - { fileName }, - 'compileFn(): computing diagnostics for test file that imports this module using language service', - ) - - updateMemoryCache(testFileContent, testFileName) - doTypeChecking(configs, testFileName, service, logger) - }) + + doTypeChecking(configs, diagnosedFiles, testFileName, service, logger) + } } } /* istanbul ignore next (this should never happen but is kept for security) */ diff --git a/src/config/__snapshots__/config-set.spec.ts.snap b/src/config/__snapshots__/config-set.spec.ts.snap index ac0693e1d5..e4a7fd62a6 100644 --- a/src/config/__snapshots__/config-set.spec.ts.snap +++ b/src/config/__snapshots__/config-set.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`cacheKey should be a string 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\\",\\"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,\\"removeComments\\":false,\\"sourceMap\\":true,\\"target\\":1}}"`; +exports[`cacheKey should be a string 1`] = `"{\\"digest\\":\\"a0d51ca854194df8191d0e65c0ca4730f510f332\\",\\"jest\\":{\\"__backported\\":true,\\"globals\\":{},\\"testMatch\\":[\\"**/__tests__/**/*.[jt]s?(x)\\",\\"**/?(*.)+(spec|test).[jt]s?(x)\\"],\\"testRegex\\":[\\"(/__tests__/.*|(\\\\\\\\\\\\\\\\.|/)(test|spec))\\\\\\\\\\\\\\\\.[jt]sx?$\\"]},\\"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,\\"removeComments\\":false,\\"sourceMap\\":true,\\"target\\":1}}"`; exports[`isTestFile should return a boolean value whether the file matches test pattern 1`] = `true`; @@ -18,6 +18,13 @@ Object { "__parent": true, }, }, + "testMatch": Array [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[jt]s?(x)", + ], + "testRegex": Array [ + "(/__tests__/.*|(\\\\\\\\.|/)(test|spec))\\\\\\\\.[jt]sx?$", + ], } `; @@ -25,6 +32,13 @@ exports[`jest should merge parent config if any with globals is undefined 1`] = Object { "__backported": true, "globals": undefined, + "testMatch": Array [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[jt]s?(x)", + ], + "testRegex": Array [ + "(/__tests__/.*|(\\\\\\\\.|/)(test|spec))\\\\\\\\.[jt]sx?$", + ], } `; @@ -32,6 +46,13 @@ exports[`jest should return correct config and go thru backports 1`] = ` Object { "__backported": true, "globals": Object {}, + "testMatch": Array [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[jt]s?(x)", + ], + "testRegex": Array [ + "(/__tests__/.*|(\\\\\\\\.|/)(test|spec))\\\\\\\\.[jt]sx?$", + ], } `; @@ -42,6 +63,13 @@ Object { "jest": Object { "__backported": true, "globals": Object {}, + "testMatch": Array [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[jt]s?(x)", + ], + "testRegex": Array [ + "(/__tests__/.*|(\\\\\\\\.|/)(test|spec))\\\\\\\\.[jt]sx?$", + ], }, "projectDepVersions": Object { "some-module": "1.2.3", diff --git a/src/config/config-set.spec.ts b/src/config/config-set.spec.ts index 33026e7177..47dfb1742a 100644 --- a/src/config/config-set.spec.ts +++ b/src/config/config-set.spec.ts @@ -1,6 +1,5 @@ /* eslint-disable jest/no-mocks-import */ import { Transformer } from '@jest/transform' -import { Config } from '@jest/types' import { LogLevels, testing } from 'bs-logger' import { readFileSync } from 'fs' import json5 = require('json5') @@ -8,8 +7,8 @@ import { resolve } from 'path' import * as ts from 'typescript' import * as _myModule from '..' -import * as fakers from '../__helpers__/fakers' import { logTargetMock } from '../__helpers__/mocks' +import { createConfigSet, defaultResolve } from '../__helpers__/fakers' import { TsJestGlobalOptions } from '../types' import * as _backports from '../util/backports' import { getPackageVersion } from '../util/get-package-version' @@ -30,33 +29,8 @@ backports.backportJestConfig.mockImplementation((_, config) => ({ __backported: true, })) -const defaultResolve = (path: string) => `resolved:${path}` const pkgVersion = (pkgName: string) => require(`${pkgName}/package.json`).version || '????' -function createConfigSet({ - jestConfig, - tsJestConfig, - parentConfig, - resolve = defaultResolve, - ...others -}: { - jestConfig?: Config.ProjectConfig - tsJestConfig?: TsJestGlobalOptions - parentConfig?: TsJestGlobalOptions - resolve?: ((path: string) => string) | null - [key: string]: any -} = {}) { - const cs = new ConfigSet(fakers.getJestConfig(jestConfig, tsJestConfig), parentConfig) - if (resolve) { - cs.resolvePath = resolve - } - Object.keys(others).forEach((key) => { - Object.defineProperty(cs, key, { value: others[key] }) - }) - - return cs -} - beforeEach(() => { jest.clearAllMocks() }) diff --git a/src/config/config-set.ts b/src/config/config-set.ts index 7ba4a754bc..23564a75ab 100644 --- a/src/config/config-set.ts +++ b/src/config/config-set.ts @@ -210,6 +210,7 @@ export class ConfigSet { */ pattern instanceof RegExp || typeof pattern === 'string', ) + /* istanbul ignore next */ if (!matchablePatterns.length) { matchablePatterns.push(...DEFAULT_JEST_TEST_MATCH) } diff --git a/src/types.ts b/src/types.ts index ce08e82ce8..1635e7c33e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -230,12 +230,7 @@ export interface TSFile { * @internal */ export interface MemoryCache { - resolvedModules: { - [testFilePath: string]: { - testFileContent: string - modulePaths: string[] - } - } + resolvedModules: Map files: TSFiles } /**