diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 5ee6a85c9bab..0d3c61a94ec4 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -178,7 +178,7 @@ jobs: uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: timeout_minutes: 10 - max_attempts: 3 + max_attempts: 5 retry_on: error command: yarn jest-coverage --color --config jest.config.ci.mjs --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=${{ matrix.shard }} - name: map coverage diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml index 12f5ee8acc4c..6ed18dd2442f 100644 --- a/.github/workflows/test-nightly.yml +++ b/.github/workflows/test-nightly.yml @@ -38,7 +38,7 @@ jobs: uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: timeout_minutes: 10 - max_attempts: 3 + max_attempts: 5 retry_on: error command: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=${{ matrix.shard }} @@ -70,6 +70,6 @@ jobs: uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: timeout_minutes: 10 - max_attempts: 3 + max_attempts: 5 retry_on: error command: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=${{ matrix.shard }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c3d86a4613f2..f259220c2bb2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: timeout_minutes: 10 - max_attempts: 3 + max_attempts: 5 retry_on: error command: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=${{ inputs.shard }} @@ -69,6 +69,6 @@ jobs: uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: timeout_minutes: 10 - max_attempts: 3 + max_attempts: 5 retry_on: error command: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=${{ inputs.shard }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d316027af684..b6f7970085fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## main +### Features + +- `[jest-circus, jest-cli, jest-config, jest-core, jest-jasmine2, jest-types]` Add `--collect-tests` flag to discover and list tests without executing them ([#16006](https://github.com/jestjs/jest/pull/16006)) + ## 30.3.0 ### Features diff --git a/e2e/__tests__/collectTests.test.ts b/e2e/__tests__/collectTests.test.ts new file mode 100644 index 000000000000..1e0a3ca3e950 --- /dev/null +++ b/e2e/__tests__/collectTests.test.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import runJest from '../runJest'; + +describe('jest --collect-tests', () => { + test('lists test names without executing test bodies', () => { + const {exitCode, stdout} = runJest('each', [ + '--collect-tests', + '--testPathPatterns=success', + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("The word red contains the letter 'e'"); + expect(stdout).toContain('passes one row expected true == true'); + expect(stdout).toContain('passes all rows expected true == true'); + expect(stdout).toContain('success.test.js'); + }); + + test('produces valid JSON with --json', () => { + const {exitCode, stdout} = runJest('each', [ + '--collect-tests', + '--json', + '--testPathPatterns=success', + ]); + + expect(exitCode).toBe(0); + const json = JSON.parse(stdout); + expect(json.success).toBe(true); + expect(json.numTotalTestSuites).toBe(1); + expect(json.numPendingTests).toBeGreaterThan(0); + + const testFile = json.testResults[0]; + expect(testFile.name).toContain('success.test.js'); + for (const assertion of testFile.assertionResults) { + expect(assertion.status).toBe('pending'); + } + }); + + test('does not execute tests (failing tests still exit 0)', () => { + const {exitCode, stdout} = runJest('each', [ + '--collect-tests', + '--testPathPatterns=failure', + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain('failure.test.js'); + expect(stdout).toContain('fails'); + }); + + test('filters correctly with --testNamePattern', () => { + const {exitCode, stdout} = runJest('each', [ + '--collect-tests', + '--testPathPatterns=success', + '--testNamePattern=one row', + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain('passes one row expected'); + expect(stdout).not.toContain("The word red contains the letter 'e'"); + }); + + test('exits 0 even when no tests match', () => { + const {exitCode, stdout} = runJest('each', [ + '--collect-tests', + '--testPathPatterns=nonexistent', + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain('No tests found'); + }); +}); diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/__tests__/collectTestsWithoutRunning.test.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/__tests__/collectTestsWithoutRunning.test.ts new file mode 100644 index 000000000000..662bf7a6c57c --- /dev/null +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/__tests__/collectTestsWithoutRunning.test.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {beforeEach, describe, expect, it, jest} from '@jest/globals'; +import {makeProjectConfig} from '@jest/test-utils'; +import type {Circus} from '@jest/types'; +import {getState as getRunnerState, resetState} from '../../state'; +import {makeDescribe, makeTest} from '../../utils'; +import {collectTestsWithoutRunning} from '../jestAdapterInit'; + +jest.mock('../../state', () => { + const actual = + jest.requireActual('../../state'); + return {...actual, dispatch: jest.fn()}; +}); + +beforeEach(() => { + resetState(); +}); + +const addTest = (name: string, parent: Circus.DescribeBlock) => { + const test = makeTest( + () => {}, + undefined, + false, + name, + parent, + undefined, + new Error(), + false, + ); + parent.children.push(test); +}; + +const collect = (testPath = '/test.js') => + collectTestsWithoutRunning({config: makeProjectConfig(), testPath}); + +describe('collectTestsWithoutRunning', () => { + it('collects flat tests with pending status', async () => { + const root = getRunnerState().rootDescribeBlock; + addTest('test one', root); + addTest('test two', root); + + const result = await collect(); + + expect(result.testResults.map(r => r.title)).toEqual([ + 'test one', + 'test two', + ]); + expect(result.testResults[0].status).toBe('pending'); + expect(result.numPendingTests).toBe(2); + }); + + it('collects nested tests with correct ancestor titles', async () => { + const root = getRunnerState().rootDescribeBlock; + const outer = makeDescribe('outer', root); + root.children.push(outer); + const inner = makeDescribe('inner', outer); + outer.children.push(inner); + addTest('deep test', inner); + + const result = await collect(); + + expect(result.testResults[0].ancestorTitles).toEqual(['outer', 'inner']); + expect(result.testResults[0].fullName).toBe('outer inner deep test'); + }); + + it('preserves source order across describe blocks', async () => { + const root = getRunnerState().rootDescribeBlock; + const a = makeDescribe('A', root); + root.children.push(a); + addTest('first', a); + const b = makeDescribe('B', root); + root.children.push(b); + addTest('second', b); + + const result = await collect(); + + expect(result.testResults.map(r => r.title)).toEqual(['first', 'second']); + }); + + it('returns empty results when no tests exist', async () => { + const result = await collect(); + expect(result.testResults).toHaveLength(0); + }); + + it('filters tests by testNamePattern from runner state', async () => { + const state = getRunnerState(); + state.testNamePattern = /one/; + addTest('test one', state.rootDescribeBlock); + addTest('test two', state.rootDescribeBlock); + + const result = await collect(); + + expect(result.testResults.map(r => r.title)).toEqual(['test one']); + }); +}); diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts index e80bbfba4809..03c27c1b2e55 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts @@ -22,10 +22,13 @@ const jestAdapter = async ( testPath: string, sendMessageToJest?: TestFileEvent, ): Promise => { - const {initialize, runAndTransformResultsToJestFormat} = - runtime.requireInternalModule( - FRAMEWORK_INITIALIZER, - ); + const { + collectTestsWithoutRunning, + initialize, + runAndTransformResultsToJestFormat, + } = runtime.requireInternalModule( + FRAMEWORK_INITIALIZER, + ); const {globals, snapshotState} = await initialize({ config, @@ -96,6 +99,14 @@ const jestAdapter = async ( runtime.requireModule(testPath); } + if (globalConfig.collectTests) { + const results = await collectTestsWithoutRunning({ + config, + testPath, + }); + return deepCyclicCopy(results, {keepPrototype: false}); + } + const setupAfterEnvPerfStats = { setupAfterEnvEnd, setupAfterEnvStart, diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts index 357033e2348e..48d7830006e5 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts @@ -140,6 +140,62 @@ export const initialize = async ({ return {globals: globalsObject, snapshotState}; }; +export const collectTestsWithoutRunning = async ({ + config, + testPath, +}: { + config: Config.ProjectConfig; + testPath: string; +}): Promise => { + const {rootDescribeBlock, testNamePattern} = getRunnerState(); + + const assertionResults: Array = []; + + const walk = ( + block: Circus.DescribeBlock, + ancestors: Array, + ): void => { + for (const child of block.children) { + if (child.type === 'describeBlock') { + walk(child, [...ancestors, child.name]); + continue; + } + + if (testNamePattern && !testNamePattern.test(getTestID(child))) { + continue; + } + + const title = child.name; + assertionResults.push({ + ancestorTitles: [...ancestors], + duration: null, + failing: false, + failureDetails: [], + failureMessages: [], + fullName: [...ancestors, title].join(' '), + invocations: 0, + location: null, + numPassingAsserts: 0, + retryReasons: [], + startAt: null, + status: 'pending' as Status, + title, + }); + } + }; + walk(rootDescribeBlock, []); + + await dispatch({name: 'teardown'}); + + return { + ...createEmptyTestResult(), + displayName: config.displayName, + numPendingTests: assertionResults.length, + testFilePath: testPath, + testResults: assertionResults, + }; +}; + export const runAndTransformResultsToJestFormat = async ({ config, globalConfig, diff --git a/packages/jest-cli/src/args.ts b/packages/jest-cli/src/args.ts index a1cdd59516bf..fb83ccf59a6d 100644 --- a/packages/jest-cli/src/args.ts +++ b/packages/jest-cli/src/args.ts @@ -172,6 +172,12 @@ export const options: {[key: string]: Options} = { requiresArg: true, type: 'string', }, + collectTests: { + description: + 'Discover and report all test cases without executing them. ' + + 'Prints a tree of test suites and test names.', + type: 'boolean', + }, color: { description: 'Forces test results output color highlighting (even if ' + diff --git a/packages/jest-config/src/ValidConfig.ts b/packages/jest-config/src/ValidConfig.ts index f40c1c9247d1..2bbf1181c943 100644 --- a/packages/jest-config/src/ValidConfig.ts +++ b/packages/jest-config/src/ValidConfig.ts @@ -24,6 +24,7 @@ export const initialOptions: Config.InitialOptions = { clearMocks: false, collectCoverage: true, collectCoverageFrom: ['src', '!public'], + collectTests: false, coverageDirectory: 'coverage', coveragePathIgnorePatterns: [NODE_MODULES_REGEXP], coverageProvider: 'v8', diff --git a/packages/jest-config/src/index.ts b/packages/jest-config/src/index.ts index 2ee7078be474..fc3a60f5a7a7 100644 --- a/packages/jest-config/src/index.ts +++ b/packages/jest-config/src/index.ts @@ -133,6 +133,7 @@ const groupOptions = ( ci: options.ci, collectCoverage: options.collectCoverage, collectCoverageFrom: options.collectCoverageFrom, + collectTests: options.collectTests, coverageDirectory: options.coverageDirectory, coverageProvider: options.coverageProvider, coverageReporters: options.coverageReporters, diff --git a/packages/jest-config/src/normalize.ts b/packages/jest-config/src/normalize.ts index 13b30d7f66ec..76b49fb2640b 100644 --- a/packages/jest-config/src/normalize.ts +++ b/packages/jest-config/src/normalize.ts @@ -894,6 +894,7 @@ export default async function normalize( case 'changedFilesWithAncestor': case 'clearMocks': case 'collectCoverage': + case 'collectTests': case 'coverageProvider': case 'coverageReporters': case 'coverageThreshold': diff --git a/packages/jest-core/src/__tests__/printCollectedTestTree.test.ts b/packages/jest-core/src/__tests__/printCollectedTestTree.test.ts new file mode 100644 index 000000000000..d3d1059bb574 --- /dev/null +++ b/packages/jest-core/src/__tests__/printCollectedTestTree.test.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {AssertionResult} from '@jest/test-result'; +import {printCollectedTestTree} from '../runJest'; + +const makeResult = ( + title: string, + ancestorTitles: Array = [], +): AssertionResult => ({ancestorTitles, title}) as AssertionResult; + +const collectOutput = (fn: (stream: NodeJS.WritableStream) => void): string => { + const chunks: Array = []; + const stream = {write: (s: string) => chunks.push(s) && true}; + fn(stream as NodeJS.WritableStream); + return chunks.join(''); +}; + +describe('printCollectedTestTree', () => { + test('prints top-level tests', () => { + const output = collectOutput(stream => + printCollectedTestTree([makeResult('standalone')], stream), + ); + expect(output).toContain(' standalone\n'); + }); + + test('prints tests grouped by describe blocks', () => { + const output = collectOutput(stream => + printCollectedTestTree( + [makeResult('test a', ['suite']), makeResult('test b', ['suite'])], + stream, + ), + ); + expect(output).toContain('suite\n'); + expect(output).toContain(' test a\n'); + expect(output).toContain(' test b\n'); + }); + + test('prints nested describe blocks with indentation', () => { + const output = collectOutput(stream => + printCollectedTestTree([makeResult('deep', ['outer', 'inner'])], stream), + ); + expect(output).toContain('outer\n'); + expect(output).toContain(' inner\n'); + expect(output).toContain(' deep\n'); + }); + + test('handles empty results', () => { + const output = collectOutput(stream => printCollectedTestTree([], stream)); + expect(output).toBe(''); + }); +}); diff --git a/packages/jest-core/src/__tests__/runJest.test.js b/packages/jest-core/src/__tests__/runJest.test.js index f2266263ad05..5606457dbb0e 100644 --- a/packages/jest-core/src/__tests__/runJest.test.js +++ b/packages/jest-core/src/__tests__/runJest.test.js @@ -47,3 +47,30 @@ describe('runJest', () => { expect(stderrSpy).toHaveBeenCalled(); }); }); + +describe('runJest with collectTests', () => { + test('handles no tests found', async () => { + const onComplete = jest.fn(); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runJest({ + contexts: [], + globalConfig: { + collectTests: true, + rootDir: '', + testPathPatterns: new TestPathPatterns([]), + testSequencer: require.resolve('@jest/test-sequencer'), + }, + onComplete, + outputStream: {write: jest.fn()}, + startRun: jest.fn(), + testWatcher: {isInterrupted: () => false}, + }); + + expect(consoleSpy).toHaveBeenCalledWith('No tests found.'); + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({numTotalTests: 0}), + ); + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/jest-core/src/lib/__tests__/__snapshots__/logDebugMessages.test.ts.snap b/packages/jest-core/src/lib/__tests__/__snapshots__/logDebugMessages.test.ts.snap index ec07be1650a5..9c940425258b 100644 --- a/packages/jest-core/src/lib/__tests__/__snapshots__/logDebugMessages.test.ts.snap +++ b/packages/jest-core/src/lib/__tests__/__snapshots__/logDebugMessages.test.ts.snap @@ -85,6 +85,7 @@ exports[`prints the config object 1`] = ` "ci": false, "collectCoverage": false, "collectCoverageFrom": [], + "collectTests": false, "coverageDirectory": "coverage", "coverageProvider": "babel", "coverageReporters": [], diff --git a/packages/jest-core/src/runJest.ts b/packages/jest-core/src/runJest.ts index 1bebdebb2ef3..b0b17fe2c70c 100644 --- a/packages/jest-core/src/runJest.ts +++ b/packages/jest-core/src/runJest.ts @@ -12,8 +12,11 @@ import chalk from 'chalk'; import exit from 'exit-x'; import * as fs from 'graceful-fs'; import {CustomConsole} from '@jest/console'; +import {VerboseReporter} from '@jest/reporters'; import { type AggregatedResult, + type AssertionResult, + type Suite, type Test, type TestContext, type TestResultsProcessor, @@ -37,6 +40,25 @@ import serializeToJSON from './lib/serializeToJSON'; import runGlobalHook from './runGlobalHook'; import type {Filter, TestRunData} from './types'; +export const printCollectedTestTree = ( + testResults: Array, + outputStream: NodeJS.WritableStream, +): void => { + const printSuite = (suite: Suite, indent: number): void => { + if (suite.title) { + outputStream.write(`${' '.repeat(indent)}${suite.title}\n`); + } + for (const t of suite.tests) { + outputStream.write(`${' '.repeat(indent + 1)}${t.title}\n`); + } + for (const child of suite.suites) { + printSuite(child, indent + 1); + } + }; + const root = VerboseReporter.groupTestsBySuites(testResults); + printSuite(root, 0); +}; + const getTestPaths = async ( globalConfig: Config.GlobalConfig, projectConfig: Config.ProjectConfig, @@ -248,6 +270,46 @@ export default async function runJest({ const hasTests = allTests.length > 0; + if (globalConfig.collectTests) { + if (!hasTests) { + // eslint-disable-next-line no-console + console.log('No tests found.'); + onComplete?.(makeEmptyAggregatedTestResult()); + return; + } + + // Suppress reporters; circus collects tests without executing. + const collectTestsConfig: Config.GlobalConfig = Object.freeze({ + ...globalConfig, + collectCoverage: false, + reporters: [], + silent: true, + }); + const scheduler = await createTestScheduler(collectTestsConfig, { + startRun, + ...testSchedulerContext, + }); + const results = await scheduler.scheduleTests(allTests, testWatcher); + + if (!globalConfig.json) { + for (const testResult of results.testResults) { + if (testResult.testResults.length > 0) { + outputStream.write(`${testResult.testFilePath}\n`); + printCollectedTestTree(testResult.testResults, outputStream); + } + } + } + + await processResults(results, { + json: globalConfig.json, + onComplete, + outputFile: globalConfig.outputFile, + outputStream, + testResultsProcessor: globalConfig.testResultsProcessor, + }); + return; + } + if (!hasTests) { const {exitWith0, message: noTestsFoundMessage} = getNoTestsFoundMessage( testRunData, diff --git a/packages/jest-jasmine2/src/__tests__/collectSpecs.test.ts b/packages/jest-jasmine2/src/__tests__/collectSpecs.test.ts new file mode 100644 index 000000000000..ab398d440a18 --- /dev/null +++ b/packages/jest-jasmine2/src/__tests__/collectSpecs.test.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {makeProjectConfig} from '@jest/test-utils'; +import { + type SpecLike, + type SuiteLike, + buildCollectedTestResult, + collectSpecs, +} from '..'; + +const makeSpec = (description: string, fullName?: string): SpecLike => ({ + description, + getFullName: () => fullName ?? description, +}); + +const makeSuite = ( + description: string, + children: Array, +): SuiteLike => ({children, description}); + +describe('collectSpecs', () => { + test('collects flat specs with pending status', () => { + const root = makeSuite('', [makeSpec('test one'), makeSpec('test two')]); + const results = collectSpecs(root, [], null); + + expect(results).toHaveLength(2); + expect(results[0].title).toBe('test one'); + expect(results[0].status).toBe('pending'); + expect(results[1].title).toBe('test two'); + }); + + test('collects nested specs with ancestor titles', () => { + const root = makeSuite('', [ + makeSuite('outer', [ + makeSuite('inner', [makeSpec('deep', 'outer inner deep')]), + ]), + ]); + const results = collectSpecs(root, [], null); + + expect(results).toHaveLength(1); + expect(results[0].ancestorTitles).toEqual(['outer', 'inner']); + expect(results[0].fullName).toBe('outer inner deep'); + expect(results[0].title).toBe('deep'); + }); + + test('filters by testNamePattern', () => { + const root = makeSuite('', [ + makeSpec('matching test', 'matching test'), + makeSpec('other test', 'other test'), + ]); + const results = collectSpecs(root, [], /matching/i); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('matching test'); + }); + + test('returns empty array for empty suite', () => { + const root = makeSuite('', []); + const results = collectSpecs(root, [], null); + expect(results).toHaveLength(0); + }); + + test('preserves order across sibling suites', () => { + const root = makeSuite('', [ + makeSuite('A', [makeSpec('first', 'A first')]), + makeSuite('B', [makeSpec('second', 'B second')]), + ]); + const results = collectSpecs(root, [], null); + + expect(results.map(r => r.title)).toEqual(['first', 'second']); + }); +}); + +describe('buildCollectedTestResult', () => { + test('returns TestResult with pending tests', () => { + const suite = makeSuite('', [makeSpec('test a'), makeSpec('test b')]); + const result = buildCollectedTestResult({ + config: makeProjectConfig(), + suite, + testNamePattern: undefined, + testPath: '/path/to/test.js', + }); + + expect(result.testResults).toHaveLength(2); + expect(result.numPendingTests).toBe(2); + expect(result.numPassingTests).toBe(0); + expect(result.numFailingTests).toBe(0); + expect(result.testFilePath).toBe('/path/to/test.js'); + }); + + test('filters by testNamePattern string', () => { + const suite = makeSuite('', [ + makeSpec('match me', 'match me'), + makeSpec('skip me', 'skip me'), + ]); + const result = buildCollectedTestResult({ + config: makeProjectConfig(), + suite, + testNamePattern: 'match', + testPath: '/test.js', + }); + + expect(result.testResults).toHaveLength(1); + expect(result.testResults[0].title).toBe('match me'); + expect(result.numPendingTests).toBe(1); + }); + + test('returns empty results for empty suite', () => { + const suite = makeSuite('', []); + const result = buildCollectedTestResult({ + config: makeProjectConfig(), + suite, + testNamePattern: undefined, + testPath: '/test.js', + }); + + expect(result.testResults).toHaveLength(0); + expect(result.numPendingTests).toBe(0); + }); +}); diff --git a/packages/jest-jasmine2/src/index.ts b/packages/jest-jasmine2/src/index.ts index 00562172fff9..d2068bc2720f 100644 --- a/packages/jest-jasmine2/src/index.ts +++ b/packages/jest-jasmine2/src/index.ts @@ -8,7 +8,11 @@ import * as path from 'path'; import type {JestEnvironment} from '@jest/environment'; import {getCallsite} from '@jest/source-map'; -import type {TestResult} from '@jest/test-result'; +import { + type AssertionResult, + type TestResult, + createEmptyTestResult, +} from '@jest/test-result'; import type {Config, Global} from '@jest/types'; import type Runtime from 'jest-runtime'; import type {SnapshotState} from 'jest-snapshot'; @@ -25,6 +29,79 @@ const JASMINE = require.resolve('./jasmine/jasmineLight'); const jestEachBuildDir = path.dirname(require.resolve('jest-each')); +export type SuiteLike = { + children: Array; + description: string; +}; + +export type SpecLike = { + description: string; + getFullName: () => string; +}; + +export const collectSpecs = ( + suite: SuiteLike, + ancestors: Array, + testNamePatternRE: RegExp | null, +): Array => { + const results: Array = []; + for (const child of suite.children) { + if ('children' in child) { + results.push( + ...collectSpecs( + child, + [...ancestors, child.description], + testNamePatternRE, + ), + ); + } else { + const fullName = child.getFullName(); + if (!testNamePatternRE || testNamePatternRE.test(fullName)) { + results.push({ + ancestorTitles: [...ancestors], + duration: null, + failing: false, + failureDetails: [], + failureMessages: [], + fullName, + invocations: 0, + location: null, + numPassingAsserts: 0, + retryReasons: [], + startAt: null, + status: 'pending', + title: child.description, + }); + } + } + } + return results; +}; + +export const buildCollectedTestResult = ({ + config, + suite, + testNamePattern, + testPath, +}: { + config: Config.ProjectConfig; + suite: SuiteLike; + testNamePattern: string | undefined; + testPath: string; +}): TestResult => { + const testNamePatternRE = testNamePattern + ? new RegExp(testNamePattern, 'i') + : null; + const assertionResults = collectSpecs(suite, [], testNamePatternRE); + return { + ...createEmptyTestResult(), + displayName: config.displayName, + numPendingTests: assertionResults.length, + testFilePath: testPath, + testResults: assertionResults, + }; +}; + export default async function jasmine2( globalConfig: Config.GlobalConfig, config: Config.ProjectConfig, @@ -199,6 +276,15 @@ export default async function jasmine2( runtime.requireModule(testPath); } + if (globalConfig.collectTests) { + return buildCollectedTestResult({ + config, + suite: env.topSuite() as unknown as SuiteLike, + testNamePattern: globalConfig.testNamePattern, + testPath, + }); + } + await env.execute(); const results = await reporter.getResults(); diff --git a/packages/jest-schemas/src/raw-types.ts b/packages/jest-schemas/src/raw-types.ts index e1b034485da1..3c9e6c5ce670 100644 --- a/packages/jest-schemas/src/raw-types.ts +++ b/packages/jest-schemas/src/raw-types.ts @@ -237,6 +237,7 @@ export const InitialOptions = Type.Partial( changedSince: Type.String(), collectCoverage: Type.Boolean(), collectCoverageFrom: Type.Array(Type.String()), + collectTests: Type.Boolean(), coverageDirectory: Type.String(), coveragePathIgnorePatterns: Type.Array(Type.String()), coverageProvider: CoverageProvider, diff --git a/packages/jest-types/src/Config.ts b/packages/jest-types/src/Config.ts index 1a0a9d0b64ff..3a0500c3d7ab 100644 --- a/packages/jest-types/src/Config.ts +++ b/packages/jest-types/src/Config.ts @@ -261,6 +261,7 @@ export type GlobalConfig = { ci: boolean; collectCoverage: boolean; collectCoverageFrom: Array; + collectTests: boolean; coverageDirectory: string; coveragePathIgnorePatterns?: Array; coverageProvider: CoverageProvider; @@ -412,6 +413,7 @@ export type Argv = Arguments< clearMocks: boolean; collectCoverage: boolean; collectCoverageFrom: string; + collectTests: boolean; color: boolean; colors: boolean; config: string; diff --git a/packages/test-utils/src/config.ts b/packages/test-utils/src/config.ts index 3f5032e5c580..997599ec671c 100644 --- a/packages/test-utils/src/config.ts +++ b/packages/test-utils/src/config.ts @@ -15,6 +15,7 @@ const DEFAULT_GLOBAL_CONFIG: Config.GlobalConfig = { ci: false, collectCoverage: false, collectCoverageFrom: [], + collectTests: false, coverageDirectory: 'coverage', coverageProvider: 'babel', coverageReporters: [],