diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f1165f46273..35120243efba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - `[@jest/globals]` New package so Jest's globals can be explicitly imported ([#9801](https://github.com/facebook/jest/pull/9801)) +- `[jest-core]` Show coverage of sources related to tests in changed files ([#9769](https://github.com/facebook/jest/pull/9769)) - `[jest-runtime]` Populate `require.cache` ([#9841](https://github.com/facebook/jest/pull/9841)) ### Fixes diff --git a/e2e/__tests__/onlyChanged.test.ts b/e2e/__tests__/onlyChanged.test.ts index 52abdd6d0613..947a11d50ddc 100644 --- a/e2e/__tests__/onlyChanged.test.ts +++ b/e2e/__tests__/onlyChanged.test.ts @@ -137,6 +137,39 @@ test('report test coverage for only changed files', () => { expect(stdout).not.toMatch('b.js'); }); +test('report test coverage of source on test file change under only changed files', () => { + writeFiles(DIR, { + '__tests__/a.test.js': ` + require('../a'); + test('a1', () => expect(1).toBe(1)); + `, + 'a.js': 'module.exports = {}', + 'package.json': JSON.stringify({ + jest: { + collectCoverage: true, + coverageReporters: ['text'], + testEnvironment: 'node', + }, + }), + }); + + run(`${GIT} init`, DIR); + run(`${GIT} add .`, DIR); + run(`${GIT} commit --no-gpg-sign -m "first"`, DIR); + + writeFiles(DIR, { + '__tests__/a.test.js': ` + require('../a'); + test('a1', () => expect(1).toBe(1)); + test('a2', () => expect(2).toBe(2)); + `, + }); + + const {stdout} = runJest(DIR, ['--only-changed']); + + expect(stdout).toMatch('a.js'); +}); + test('do not pickup non-tested files when reporting coverage on only changed files', () => { writeFiles(DIR, { 'a.js': 'module.exports = {}', diff --git a/packages/jest-core/src/SearchSource.ts b/packages/jest-core/src/SearchSource.ts index ba70f4ce021d..48c630f2adb4 100644 --- a/packages/jest-core/src/SearchSource.ts +++ b/packages/jest-core/src/SearchSource.ts @@ -51,13 +51,22 @@ const toTests = (context: Context, tests: Array) => path, })); +const hasSCM = (changedFilesInfo: ChangedFiles) => { + const {repos} = changedFilesInfo; + // no SCM (git/hg/...) is found in any of the roots. + const noSCM = Object.values(repos).every(scm => scm.size === 0); + return !noSCM; +}; + export default class SearchSource { private _context: Context; + private _dependencyResolver: DependencyResolver | null; private _testPathCases: TestPathCases = []; constructor(context: Context) { const {config} = context; this._context = context; + this._dependencyResolver = null; const rootPattern = new RegExp( config.roots.map(dir => escapePathForRegex(dir + path.sep)).join('|'), @@ -92,6 +101,17 @@ export default class SearchSource { } } + private _getOrBuildDependencyResolver(): DependencyResolver { + if (!this._dependencyResolver) { + this._dependencyResolver = new DependencyResolver( + this._context.resolver, + this._context.hasteFS, + buildSnapshotResolver(this._context.config), + ); + } + return this._dependencyResolver; + } + private _filterTestPathsWithStats( allPaths: Array, testPathPattern?: string, @@ -155,11 +175,7 @@ export default class SearchSource { allPaths: Set, collectCoverage: boolean, ): SearchResult { - const dependencyResolver = new DependencyResolver( - this._context.resolver, - this._context.hasteFS, - buildSnapshotResolver(this._context.config), - ); + const dependencyResolver = this._getOrBuildDependencyResolver(); if (!collectCoverage) { return { @@ -240,14 +256,11 @@ export default class SearchSource { changedFilesInfo: ChangedFiles, collectCoverage: boolean, ): SearchResult { - const {repos, changedFiles} = changedFilesInfo; - // no SCM (git/hg/...) is found in any of the roots. - const noSCM = (Object.keys(repos) as Array< - keyof ChangedFiles['repos'] - >).every(scm => repos[scm].size === 0); - return noSCM - ? {noSCM: true, tests: []} - : this.findRelatedTests(changedFiles, collectCoverage); + if (!hasSCM(changedFilesInfo)) { + return {noSCM: true, tests: []}; + } + const {changedFiles} = changedFilesInfo; + return this.findRelatedTests(changedFiles, collectCoverage); } private _getTestPaths( @@ -328,4 +341,24 @@ export default class SearchSource { return searchResult; } + + findRelatedSourcesFromTestsInChangedFiles( + changedFilesInfo: ChangedFiles, + ): Array { + if (!hasSCM(changedFilesInfo)) { + return []; + } + const {changedFiles} = changedFilesInfo; + const dependencyResolver = this._getOrBuildDependencyResolver(); + const relatedSourcesSet = new Set(); + changedFiles.forEach(filePath => { + if (this.isTestFilePath(filePath)) { + const sourcePaths = dependencyResolver.resolve(filePath, { + skipNodeResolution: this._context.config.skipNodeResolution, + }); + sourcePaths.forEach(sourcePath => relatedSourcesSet.add(sourcePath)); + } + }); + return Array.from(relatedSourcesSet); + } } diff --git a/packages/jest-core/src/TestScheduler.ts b/packages/jest-core/src/TestScheduler.ts index 41b40e18955f..bf455a2e05c1 100644 --- a/packages/jest-core/src/TestScheduler.ts +++ b/packages/jest-core/src/TestScheduler.ts @@ -44,6 +44,7 @@ export type TestSchedulerContext = { firstRun: boolean; previousSuccess: boolean; changedFiles?: Set; + sourcesRelatedToTestsInChangedFiles?: Set; }; export default class TestScheduler { private _dispatcher: ReporterDispatcher; @@ -180,7 +181,9 @@ export default class TestScheduler { if (!testRunners[config.runner]) { const Runner: typeof TestRunner = require(config.runner); testRunners[config.runner] = new Runner(this._globalConfig, { - changedFiles: this._context && this._context.changedFiles, + changedFiles: this._context?.changedFiles, + sourcesRelatedToTestsInChangedFiles: this._context + ?.sourcesRelatedToTestsInChangedFiles, }); } }); @@ -272,7 +275,9 @@ export default class TestScheduler { if (!isDefault && collectCoverage) { this.addReporter( new CoverageReporter(this._globalConfig, { - changedFiles: this._context && this._context.changedFiles, + changedFiles: this._context?.changedFiles, + sourcesRelatedToTestsInChangedFiles: this._context + ?.sourcesRelatedToTestsInChangedFiles, }), ); } @@ -302,7 +307,9 @@ export default class TestScheduler { if (collectCoverage) { this.addReporter( new CoverageReporter(this._globalConfig, { - changedFiles: this._context && this._context.changedFiles, + changedFiles: this._context?.changedFiles, + sourcesRelatedToTestsInChangedFiles: this._context + ?.sourcesRelatedToTestsInChangedFiles, }), ); } diff --git a/packages/jest-core/src/__tests__/SearchSource.test.ts b/packages/jest-core/src/__tests__/SearchSource.test.ts index 292e5e4b986a..0a6815062c85 100644 --- a/packages/jest-core/src/__tests__/SearchSource.test.ts +++ b/packages/jest-core/src/__tests__/SearchSource.test.ts @@ -531,4 +531,64 @@ describe('SearchSource', () => { } }); }); + + describe('findRelatedSourcesFromTestsInChangedFiles', () => { + const rootDir = path.resolve( + __dirname, + '../../../jest-runtime/src/__tests__/test_root', + ); + + beforeEach(async () => { + const {options: config} = normalize( + { + haste: { + hasteImplModulePath: path.resolve( + __dirname, + '../../../jest-haste-map/src/__tests__/haste_impl.js', + ), + providesModuleNodeModules: [], + }, + name: 'SearchSource-findRelatedSourcesFromTestsInChangedFiles-tests', + rootDir, + }, + {} as Config.Argv, + ); + const context = await Runtime.createContext(config, { + maxWorkers, + watchman: false, + }); + searchSource = new SearchSource(context); + }); + + it('return empty set if no SCM', () => { + const requireRegularModule = path.join( + rootDir, + 'RequireRegularModule.js', + ); + const sources = searchSource.findRelatedSourcesFromTestsInChangedFiles({ + changedFiles: new Set([requireRegularModule]), + repos: { + git: new Set(), + hg: new Set(), + }, + }); + expect(sources).toEqual([]); + }); + + it('return sources required by tests', () => { + const regularModule = path.join(rootDir, 'RegularModule.js'); + const requireRegularModule = path.join( + rootDir, + 'RequireRegularModule.js', + ); + const sources = searchSource.findRelatedSourcesFromTestsInChangedFiles({ + changedFiles: new Set([requireRegularModule]), + repos: { + git: new Set('/path/to/git'), + hg: new Set(), + }, + }); + expect(sources).toEqual([regularModule]); + }); + }); }); diff --git a/packages/jest-core/src/runJest.ts b/packages/jest-core/src/runJest.ts index 38bcb8dc95be..bea930899c0d 100644 --- a/packages/jest-core/src/runJest.ts +++ b/packages/jest-core/src/runJest.ts @@ -34,13 +34,12 @@ import type {Filter, TestRunData} from './types'; const getTestPaths = async ( globalConfig: Config.GlobalConfig, - context: Context, + source: SearchSource, outputStream: NodeJS.WriteStream, changedFiles: ChangedFiles | undefined, jestHooks: JestHookEmitter, filter?: Filter, ) => { - const source = new SearchSource(context); const data = await source.getTestPaths(globalConfig, changedFiles, filter); if (!data.tests.length && globalConfig.onlyChanged && data.noSCM) { @@ -167,11 +166,14 @@ export default async function runJest({ } } + const searchSources = contexts.map(context => new SearchSource(context)); + const testRunData: TestRunData = await Promise.all( - contexts.map(async context => { + contexts.map(async (context, index) => { + const searchSource = searchSources[index]; const matches = await getTestPaths( globalConfig, - context, + searchSource, outputStream, changedFilesPromise && (await changedFilesPromise), jestHooks, @@ -242,9 +244,22 @@ export default async function runJest({ } if (changedFilesPromise) { - testSchedulerContext.changedFiles = ( - await changedFilesPromise - ).changedFiles; + const changedFilesInfo = await changedFilesPromise; + if (changedFilesInfo.changedFiles) { + testSchedulerContext.changedFiles = changedFilesInfo.changedFiles; + const sourcesRelatedToTestsInChangedFilesArray = contexts + .map((_, index) => { + const searchSource = searchSources[index]; + const relatedSourceFromTestsInChangedFiles = searchSource.findRelatedSourcesFromTestsInChangedFiles( + changedFilesInfo, + ); + return relatedSourceFromTestsInChangedFiles; + }) + .reduce((total, paths) => total.concat(paths), []); + testSchedulerContext.sourcesRelatedToTestsInChangedFiles = new Set( + sourcesRelatedToTestsInChangedFilesArray, + ); + } } const results = await new TestScheduler( diff --git a/packages/jest-reporters/src/__tests__/coverage_worker.test.js b/packages/jest-reporters/src/__tests__/coverage_worker.test.js index b3351ba29006..86cfeecb1ab3 100644 --- a/packages/jest-reporters/src/__tests__/coverage_worker.test.js +++ b/packages/jest-reporters/src/__tests__/coverage_worker.test.js @@ -41,6 +41,7 @@ test('resolves to the result of generateEmptyCoverage upon success', async () => globalConfig, config, undefined, + undefined, ); expect(result).toEqual(42); diff --git a/packages/jest-reporters/src/coverage_reporter.ts b/packages/jest-reporters/src/coverage_reporter.ts index b769743e2ff6..1e1cbfdbaab6 100644 --- a/packages/jest-reporters/src/coverage_reporter.ts +++ b/packages/jest-reporters/src/coverage_reporter.ts @@ -204,6 +204,9 @@ export default class CoverageReporter extends BaseReporter { changedFiles: this._options.changedFiles && Array.from(this._options.changedFiles), + sourcesRelatedToTestsInChangedFiles: + this._options.sourcesRelatedToTestsInChangedFiles && + Array.from(this._options.sourcesRelatedToTestsInChangedFiles), }, path: filename, }); diff --git a/packages/jest-reporters/src/coverage_worker.ts b/packages/jest-reporters/src/coverage_worker.ts index 23109ffa16ee..d1894b97574c 100644 --- a/packages/jest-reporters/src/coverage_worker.ts +++ b/packages/jest-reporters/src/coverage_worker.ts @@ -40,6 +40,8 @@ export function worker({ path, globalConfig, config, - options && options.changedFiles && new Set(options.changedFiles), + options?.changedFiles && new Set(options.changedFiles), + options?.sourcesRelatedToTestsInChangedFiles && + new Set(options.sourcesRelatedToTestsInChangedFiles), ); } diff --git a/packages/jest-reporters/src/generateEmptyCoverage.ts b/packages/jest-reporters/src/generateEmptyCoverage.ts index 62ab4c023f2c..fae80dc5e049 100644 --- a/packages/jest-reporters/src/generateEmptyCoverage.ts +++ b/packages/jest-reporters/src/generateEmptyCoverage.ts @@ -31,6 +31,7 @@ export default function ( globalConfig: Config.GlobalConfig, config: Config.ProjectConfig, changedFiles?: Set, + sourcesRelatedToTestsInChangedFiles?: Set, ): CoverageWorkerResult | null { const coverageOptions = { changedFiles, @@ -38,6 +39,7 @@ export default function ( collectCoverageFrom: globalConfig.collectCoverageFrom, collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom, coverageProvider: globalConfig.coverageProvider, + sourcesRelatedToTestsInChangedFiles, }; let coverageWorkerResult: CoverageWorkerResult | null = null; if (shouldInstrument(filename, coverageOptions, config)) { diff --git a/packages/jest-reporters/src/types.ts b/packages/jest-reporters/src/types.ts index ddfde5782362..d2725ead5917 100644 --- a/packages/jest-reporters/src/types.ts +++ b/packages/jest-reporters/src/types.ts @@ -37,10 +37,12 @@ export type CoverageWorker = {worker: typeof worker}; export type CoverageReporterOptions = { changedFiles?: Set; + sourcesRelatedToTestsInChangedFiles?: Set; }; export type CoverageReporterSerializedOptions = { changedFiles?: Array; + sourcesRelatedToTestsInChangedFiles?: Array; }; export type OnTestStart = (test: Test) => Promise; diff --git a/packages/jest-runner/src/runTest.ts b/packages/jest-runner/src/runTest.ts index 36754d3f086e..d9a64a874155 100644 --- a/packages/jest-runner/src/runTest.ts +++ b/packages/jest-runner/src/runTest.ts @@ -147,11 +147,13 @@ async function runTestInternal( setGlobal(environment.global, 'console', testConsole); const runtime = new Runtime(config, environment, resolver, cacheFS, { - changedFiles: context && context.changedFiles, + changedFiles: context?.changedFiles, collectCoverage: globalConfig.collectCoverage, collectCoverageFrom: globalConfig.collectCoverageFrom, collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom, coverageProvider: globalConfig.coverageProvider, + sourcesRelatedToTestsInChangedFiles: + context?.sourcesRelatedToTestsInChangedFiles, }); const start = Date.now(); diff --git a/packages/jest-runner/src/types.ts b/packages/jest-runner/src/types.ts index c45b44d32677..4862c886ce86 100644 --- a/packages/jest-runner/src/types.ts +++ b/packages/jest-runner/src/types.ts @@ -51,6 +51,7 @@ export type TestRunnerOptions = { export type TestRunnerContext = { changedFiles?: Set; + sourcesRelatedToTestsInChangedFiles?: Set; }; export type TestRunnerSerializedContext = { diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 33df2ba5dbe6..550f75b3eeca 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -171,6 +171,7 @@ class Runtime { collectCoverageFrom: [], collectCoverageOnlyFrom: undefined, coverageProvider: 'babel', + sourcesRelatedToTestsInChangedFiles: undefined, }; this._currentlyExecutingModulePath = ''; this._environment = environment; diff --git a/packages/jest-transform/src/shouldInstrument.ts b/packages/jest-transform/src/shouldInstrument.ts index e9d75afdbc63..d9fcdcd268c3 100644 --- a/packages/jest-transform/src/shouldInstrument.ts +++ b/packages/jest-transform/src/shouldInstrument.ts @@ -94,7 +94,12 @@ export default function shouldInstrument( } if (options.changedFiles && !options.changedFiles.has(filename)) { - return false; + if (!options.sourcesRelatedToTestsInChangedFiles) { + return false; + } + if (!options.sourcesRelatedToTestsInChangedFiles.has(filename)) { + return false; + } } return true; diff --git a/packages/jest-transform/src/types.ts b/packages/jest-transform/src/types.ts index 30075298bc26..64e1145eb3c0 100644 --- a/packages/jest-transform/src/types.ts +++ b/packages/jest-transform/src/types.ts @@ -16,6 +16,7 @@ export type ShouldInstrumentOptions = Pick< | 'coverageProvider' > & { changedFiles?: Set; + sourcesRelatedToTestsInChangedFiles?: Set; }; export type Options = ShouldInstrumentOptions &