diff --git a/e2e/list/fixtures/c.test.ts b/e2e/list/fixtures/c.test.ts new file mode 100644 index 000000000..85673703e --- /dev/null +++ b/e2e/list/fixtures/c.test.ts @@ -0,0 +1,17 @@ +import { describe, it } from '@rstest/core'; + +describe.each([0])('test c describe each %#', () => {}); + +describe.for([0])('test c describe for %#', () => {}); + +describe.runIf(true)('test c describe runIf', () => {}); + +describe.skipIf(false)('test c describe skipIf', () => {}); + +it.each([0])('test c it each %#', () => {}); + +it.for([0])('test c it for %#', () => {}); + +it.runIf(true)('test c it runIf', () => {}); + +it.skipIf(false)('test c it skipIf', () => {}); diff --git a/e2e/list/index.test.ts b/e2e/list/index.test.ts index 02c21af83..75e3e710f 100644 --- a/e2e/list/index.test.ts +++ b/e2e/list/index.test.ts @@ -28,6 +28,10 @@ describe('test list command', () => { "a.test.ts > test a-2", "b.test.ts > test b > test b-1", "b.test.ts > test b-2", + "c.test.ts > test c it each 0", + "c.test.ts > test c it for 0", + "c.test.ts > test c it runIf", + "c.test.ts > test c it skipIf", ] `); }); @@ -75,6 +79,7 @@ describe('test list command', () => { [ "a.test.ts > test a > test a-1", "a.test.ts > test a-2", + "c.test.ts > test c it each 0", ] `); }); @@ -99,6 +104,79 @@ describe('test list command', () => { [ "a.test.ts", "b.test.ts", + "c.test.ts", + ] + `); + }); + + it('should list tests and suites correctly', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['list', '--includeSuites'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await expectExecSuccess(); + + const logs = cli.stdout?.split('\n').filter(Boolean); + + expect(logs).toMatchInlineSnapshot(` + [ + "a.test.ts > test a", + "a.test.ts > test a > test a-1", + "a.test.ts > test a-2", + "b.test.ts > test b", + "b.test.ts > test b > test b-1", + "b.test.ts > test b-2", + "c.test.ts > test c describe each 0", + "c.test.ts > test c describe for 0", + "c.test.ts > test c describe runIf", + "c.test.ts > test c describe skipIf", + "c.test.ts > test c it each 0", + "c.test.ts > test c it for 0", + "c.test.ts > test c it runIf", + "c.test.ts > test c it skipIf", + ] + `); + }); + + it('should list tests and suites with location correctly', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['list', '--includeSuites', '--printLocation'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await expectExecSuccess(); + + const logs = cli.stdout?.split('\n').filter(Boolean); + + // rspack transpiles describe() to (0,rstest.describe)(), so the location is end of the callee + // FIXME rspack trasnpiles describe.for to describe["for"] so the location is different from describe.each + expect(logs).toMatchInlineSnapshot(` + [ + "a.test.ts:3:9 > test a", + "a.test.ts:4:5 > test a > test a-1", + "a.test.ts:9:3 > test a-2", + "b.test.ts:3:9 > test b", + "b.test.ts:4:5 > test b > test b-1", + "b.test.ts:9:3 > test b-2", + "c.test.ts:3:1 > test c describe each 0", + "c.test.ts:5:13 > test c describe for 0", + "c.test.ts:7:1 > test c describe runIf", + "c.test.ts:9:1 > test c describe skipIf", + "c.test.ts:11:1 > test c it each 0", + "c.test.ts:13:7 > test c it for 0", + "c.test.ts:15:1 > test c it runIf", + "c.test.ts:17:1 > test c it skipIf", ] `); }); diff --git a/e2e/list/json.test.ts b/e2e/list/json.test.ts index 48d763942..aa6a4bc2e 100644 --- a/e2e/list/json.test.ts +++ b/e2e/list/json.test.ts @@ -28,19 +28,43 @@ describe('test list command with --json', () => { "[", " {", " "file": "/e2e/list/fixtures/a.test.ts",", - " "name": "test a > test a-1"", + " "name": "test a > test a-1",", + " "type": "case"", " },", " {", " "file": "/e2e/list/fixtures/a.test.ts",", - " "name": "test a-2"", + " "name": "test a-2",", + " "type": "case"", " },", " {", " "file": "/e2e/list/fixtures/b.test.ts",", - " "name": "test b > test b-1"", + " "name": "test b > test b-1",", + " "type": "case"", " },", " {", " "file": "/e2e/list/fixtures/b.test.ts",", - " "name": "test b-2"", + " "name": "test b-2",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it each 0",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it for 0",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it runIf",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it skipIf",", + " "type": "case"", " }", "]", ] @@ -66,10 +90,16 @@ describe('test list command with --json', () => { [ "[", " {", - " "file": "/e2e/list/fixtures/a.test.ts"", + " "file": "/e2e/list/fixtures/a.test.ts",", + " "type": "file"", + " },", + " {", + " "file": "/e2e/list/fixtures/b.test.ts",", + " "type": "file"", " },", " {", - " "file": "/e2e/list/fixtures/b.test.ts"", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "type": "file"", " }", "]", ] @@ -97,4 +127,246 @@ describe('test list command with --json', () => { fs.rmSync(outputPath, { force: true }); }); + + it('should list tests and suites json correctly', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['list', '--json', '--includeSuites'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await expectExecSuccess(); + + const logs = cli.stdout?.split('\n').filter(Boolean); + + expect(logs).toMatchInlineSnapshot(` + [ + "[", + " {", + " "file": "/e2e/list/fixtures/a.test.ts",", + " "name": "test a",", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/a.test.ts",", + " "name": "test a > test a-1",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/a.test.ts",", + " "name": "test a-2",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/b.test.ts",", + " "name": "test b",", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/b.test.ts",", + " "name": "test b > test b-1",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/b.test.ts",", + " "name": "test b-2",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe each 0",", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe for 0",", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe runIf",", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe skipIf",", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it each 0",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it for 0",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it runIf",", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it skipIf",", + " "type": "case"", + " }", + "]", + ] + `); + }); + + it('should list tests and suites with location json correctly', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['list', '--json', '--includeSuites', '--printLocation'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await expectExecSuccess(); + + const logs = cli.stdout?.split('\n').filter(Boolean); + + expect(logs).toMatchInlineSnapshot(` + [ + "[", + " {", + " "file": "/e2e/list/fixtures/a.test.ts",", + " "name": "test a",", + " "location": {", + " "line": 3,", + " "column": 9", + " },", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/a.test.ts",", + " "name": "test a > test a-1",", + " "location": {", + " "line": 4,", + " "column": 5", + " },", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/a.test.ts",", + " "name": "test a-2",", + " "location": {", + " "line": 9,", + " "column": 3", + " },", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/b.test.ts",", + " "name": "test b",", + " "location": {", + " "line": 3,", + " "column": 9", + " },", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/b.test.ts",", + " "name": "test b > test b-1",", + " "location": {", + " "line": 4,", + " "column": 5", + " },", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/b.test.ts",", + " "name": "test b-2",", + " "location": {", + " "line": 9,", + " "column": 3", + " },", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe each 0",", + " "location": {", + " "line": 3,", + " "column": 1", + " },", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe for 0",", + " "location": {", + " "line": 5,", + " "column": 13", + " },", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe runIf",", + " "location": {", + " "line": 7,", + " "column": 1", + " },", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c describe skipIf",", + " "location": {", + " "line": 9,", + " "column": 1", + " },", + " "type": "suite"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it each 0",", + " "location": {", + " "line": 11,", + " "column": 1", + " },", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it for 0",", + " "location": {", + " "line": 13,", + " "column": 7", + " },", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it runIf",", + " "location": {", + " "line": 15,", + " "column": 1", + " },", + " "type": "case"", + " },", + " {", + " "file": "/e2e/list/fixtures/c.test.ts",", + " "name": "test c it skipIf",", + " "location": {", + " "line": 17,", + " "column": 1", + " },", + " "type": "case"", + " }", + "]", + ] + `); + }); }); diff --git a/e2e/projects/coverage.test.ts b/e2e/projects/coverage.test.ts index 92078c867..09dfd876b 100644 --- a/e2e/projects/coverage.test.ts +++ b/e2e/projects/coverage.test.ts @@ -45,7 +45,7 @@ describe('test projects coverage', () => { expect( fs.existsSync(join(__dirname, 'fixtures/coverage/index.html')), ).toBeTruthy(); - }, 15000); + }, 20000); it('should run projects correctly with coverage.include', async () => { const { cli, expectExecSuccess } = await runRstestCli({ diff --git a/e2e/reporter/index.test.ts b/e2e/reporter/index.test.ts index e1822bf4e..d75e0cdf3 100644 --- a/e2e/reporter/index.test.ts +++ b/e2e/reporter/index.test.ts @@ -88,6 +88,7 @@ describe.concurrent('reporters', () => { ).toBe(3); expect(cli.stdout).toContain('[custom reporter] onTestFileStart'); + expect(cli.stdout).toContain('[custom reporter] onTestFileReady'); expect( cli.stdout.match(/\[custom reporter\] onTestCaseResult/g)?.length, diff --git a/e2e/reporter/rstest.customReporterConfig.ts b/e2e/reporter/rstest.customReporterConfig.ts index b9138f339..d662dd2d4 100644 --- a/e2e/reporter/rstest.customReporterConfig.ts +++ b/e2e/reporter/rstest.customReporterConfig.ts @@ -15,6 +15,10 @@ class MyReporter implements Reporter { reporterResult.push('[custom reporter] onTestFileStart'); } + onTestFileReady(_file: TestFileInfo) { + reporterResult.push('[custom reporter] onTestFileReady'); + } + onTestSuiteStart(_test: TestSuiteInfo) { reporterResult.push('[custom reporter] onTestSuiteStart'); } diff --git a/e2e/rstest.config.ts b/e2e/rstest.config.ts index 696271d21..fb3ca1b61 100644 --- a/e2e/rstest.config.ts +++ b/e2e/rstest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ setupFiles: ['../scripts/rstest.setup.ts'], - testTimeout: 10_000, + testTimeout: process.env.CI ? 20_000 : 10_000, slowTestThreshold: 2_000, output: { externals: { diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md index 87fe3f7d5..bdf63935d 100644 --- a/packages/core/LICENSE.md +++ b/packages/core/LICENSE.md @@ -943,7 +943,7 @@ Licensed under MIT license in the repository at https://github.com/chaijs/loupe. ### magic-string -Licensed under MIT license in the repository at https://github.com/rich-harris/magic-string.git. +Licensed under MIT license in the repository at git+https://github.com/Rich-Harris/magic-string.git. > Copyright 2018 Rich Harris > diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 670c6fe72..53dfee995 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -87,6 +87,10 @@ const applyCommonOptions = (cli: CAC) => { .option( '--unstubEnvs', 'Restores all `process.env` values that were changed with `rstest.stubEnv` before every test', + ) + .option( + '--includeTaskLocation', + 'Collect test and suite locations. This might increase the running time.', ); }; @@ -176,6 +180,8 @@ export function setupCommands(): void { .command('list [...filters]', 'lists all test files that Rstest will run') .option('--filesOnly', 'only list the test files') .option('--json [boolean/path]', 'print tests as JSON or write to a file') + .option('--includeSuites', 'include suites in output') + .option('--printLocation', 'print test case location') .action( async ( filters: string[], @@ -184,6 +190,9 @@ export function setupCommands(): void { try { const { initCli } = await import('./init'); const { config, configFilePath, projects } = await initCli(options); + if (options.printLocation) { + config.includeTaskLocation = true; + } const { createRstest } = await import('../core'); const rstest = createRstest( { config, configFilePath, projects }, @@ -193,6 +202,8 @@ export function setupCommands(): void { await rstest.listTests({ filesOnly: options.filesOnly, json: options.json, + includeSuites: options.includeSuites, + printLocation: options.printLocation, }); } catch (err) { logger.error('Failed to run Rstest list.'); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ce2d4efba..d26fcf0df 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -149,6 +149,7 @@ const createDefaultConfig = (): NormalizedConfig => ({ hideSkippedTests: false, logHeapUsage: false, bail: 0, + includeTaskLocation: false, coverage: { exclude: [ '**/node_modules/**', diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index d441d15e2..0fe596467 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -38,9 +38,9 @@ export function createRstest( await runTests(context); }; - const listTests = async (options: ListCommandOptions): Promise => { + const listTests = async (options: ListCommandOptions) => { const { listTests } = await import('./listTests'); - await listTests(context, options); + return listTests(context, options); }; return { diff --git a/packages/core/src/core/listTests.ts b/packages/core/src/core/listTests.ts index d0e2f5dfe..2e1b45316 100644 --- a/packages/core/src/core/listTests.ts +++ b/packages/core/src/core/listTests.ts @@ -2,8 +2,9 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, join, relative } from 'node:path'; import { createPool } from '../pool'; import type { - FormattedError, ListCommandOptions, + ListCommandResult, + Location, RstestContext, Test, } from '../types'; @@ -15,6 +16,7 @@ import { getTestEntries, logger, prettyTestPath, + ROOT_SUITE_NAME, } from '../utils'; import { createRsbuildServer, prepareRsbuild } from './rsbuild'; @@ -107,12 +109,7 @@ const collectTestFiles = async ({ context: RstestContext; globTestSourceEntries: (name: string) => Promise>; }) => { - const list: { - tests: Test[]; - testPath: string; - project: string; - errors?: FormattedError[]; - }[] = []; + const list: ListCommandResult[] = []; for (const project of context.projects) { const files = await globTestSourceEntries(project.environmentName); list.push( @@ -132,8 +129,8 @@ const collectTestFiles = async ({ export async function listTests( context: RstestContext, - { filesOnly, json }: ListCommandOptions, -): Promise { + { filesOnly, json, printLocation, includeSuites }: ListCommandOptions, +): Promise { const { rootPath } = context; const testEntries: Record> = {}; @@ -176,6 +173,8 @@ export async function listTests( file: string; name?: string; project?: string; + location?: Location; + type: 'file' | 'suite' | 'case'; }[] = []; const traverseTests = (test: Test) => { @@ -183,20 +182,19 @@ export async function listTests( return; } - if (test.type === 'case') { - if (showProject) { - tests.push({ - file: test.testPath, - name: getTaskNameWithPrefix(test), - project: test.project, - }); - } else { - tests.push({ - file: test.testPath, - name: getTaskNameWithPrefix(test), - }); - } - } else { + if ( + test.type === 'case' || + (includeSuites && test.type === 'suite' && test.name !== ROOT_SUITE_NAME) + ) + tests.push({ + file: test.testPath, + name: getTaskNameWithPrefix(test), + location: test.location, + type: test.type, + project: showProject ? test.project : undefined, + }); + + if (test.type === 'suite') { for (const child of test.tests) { traverseTests(child); } @@ -230,7 +228,7 @@ export async function listTests( } await close(); - return; + return list; } for (const file of list) { @@ -239,10 +237,12 @@ export async function listTests( tests.push({ file: file.testPath, project: file.project, + type: 'file', }); } else { tests.push({ file: file.testPath, + type: 'file', }); } continue; @@ -263,7 +263,10 @@ export async function listTests( } } else { for (const test of tests) { - const shortPath = relative(rootPath, test.file); + let shortPath = relative(rootPath, test.file); + if (test.location && printLocation) { + shortPath = `${shortPath}:${test.location.line}:${test.location.column}`; + } logger.log( test.name ? `${color.dim(`${shortPath} > `)}${test.name}` @@ -273,4 +276,6 @@ export async function listTests( } await close(); + + return list; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e6de8e40a..40f2bbf47 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,6 +83,7 @@ export type { TestCaseInfo, TestFileInfo, TestFileResult, + TestInfo, TestResult, TestSuiteInfo, } from './types'; diff --git a/packages/core/src/pool/index.ts b/packages/core/src/pool/index.ts index 643df22c1..7a742aa48 100644 --- a/packages/core/src/pool/index.ts +++ b/packages/core/src/pool/index.ts @@ -64,6 +64,7 @@ const getRuntimeConfig = (context: ProjectContext): RuntimeConfig => { logHeapUsage, bail, chaiConfig, + includeTaskLocation, } = context.normalizedConfig; return { @@ -89,6 +90,7 @@ const getRuntimeConfig = (context: ProjectContext): RuntimeConfig => { logHeapUsage, bail, chaiConfig, + includeTaskLocation, }; }; @@ -233,6 +235,11 @@ export const createPool = async ({ reporters.map((reporter) => reporter.onTestFileStart?.(test)), ); }, + onTestFileReady: async (test: TestFileInfo) => { + await Promise.all( + reporters.map((reporter) => reporter.onTestFileReady?.(test)), + ); + }, onTestSuiteStart: async (test: TestSuiteInfo) => { await Promise.all( reporters.map((reporter) => reporter.onTestSuiteStart?.(test)), diff --git a/packages/core/src/runtime/runner/index.ts b/packages/core/src/runtime/runner/index.ts index 04ee14a67..48eb6fc2b 100644 --- a/packages/core/src/runtime/runner/index.ts +++ b/packages/core/src/runtime/runner/index.ts @@ -4,6 +4,7 @@ import type { RunnerHooks, Test, TestFileResult, + TestInfo, WorkerState, } from '../../types'; import { getSnapshotClient } from '../api/snapshot'; @@ -53,6 +54,21 @@ export function createRunner({ workerState }: { workerState: WorkerState }): { const tests = await runtime.instance.getTests(); traverseUpdateTest(tests, testNamePattern); + hooks.onTestFileReady?.({ + testPath, + tests: tests.map(function toTestInfo(test: Test): TestInfo { + return { + testId: test.testId, + name: test.name, + parentNames: test.parentNames, + testPath: test.testPath, + project: test.project, + type: test.type, + location: test.location, + tests: test.type === 'suite' ? test.tests.map(toTestInfo) : [], + }; + }), + }); runtime.instance.updateStatus('running'); const results = await testRunner.runTests({ diff --git a/packages/core/src/runtime/runner/runner.ts b/packages/core/src/runtime/runner/runner.ts index 09da71a9b..508490fc3 100644 --- a/packages/core/src/runtime/runner/runner.ts +++ b/packages/core/src/runtime/runner/runner.ts @@ -301,6 +301,8 @@ export class TestRunner { testPath, project: test.project, testId: test.testId, + type: 'suite', + location: test.location, }); if (test.tests.length === 0) { @@ -390,6 +392,8 @@ export class TestRunner { timeout: test.timeout, parentNames: test.parentNames, project: test.project, + type: 'case', + location: test.location, }); do { diff --git a/packages/core/src/runtime/runner/runtime.ts b/packages/core/src/runtime/runner/runtime.ts index a0134fb45..b38af3e3b 100644 --- a/packages/core/src/runtime/runner/runtime.ts +++ b/packages/core/src/runtime/runner/runtime.ts @@ -1,3 +1,6 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as stackTraceParse } from 'stacktrace-parser'; import type { AfterAllListener, AfterEachListener, @@ -7,6 +10,7 @@ import type { DescribeEachFn, DescribeForFn, Fixtures, + Location, MaybePromise, NormalizedFixtures, RunnerAPI, @@ -161,6 +165,7 @@ export class RunnerRuntime { each = false, concurrent, sequential, + location, }: { name: string; fn?: () => MaybePromise; @@ -168,6 +173,7 @@ export class RunnerRuntime { each?: boolean; concurrent?: boolean; sequential?: boolean; + location?: Location; }): void { this.checkStatus(name, 'suite'); const currentSuite: Omit = { @@ -180,6 +186,7 @@ export class RunnerRuntime { testPath: this.testPath, concurrent, sequential, + location, }; if (!fn) { @@ -301,6 +308,7 @@ export class RunnerRuntime { each = false, concurrent, sequential, + location, }: { name: string; fixtures?: NormalizedFixtures; @@ -312,6 +320,7 @@ export class RunnerRuntime { fails?: boolean; concurrent?: boolean; sequential?: boolean; + location?: Location; }): void { this.checkStatus(name, 'case'); this.addTestCase({ @@ -330,6 +339,7 @@ export class RunnerRuntime { fails, onFinished: [], onFailed: [], + location, }); } @@ -341,6 +351,7 @@ export class RunnerRuntime { runMode?: TestRunMode; concurrent?: boolean; sequential?: boolean; + location?: Location; }): ReturnType { return (name: string, fn) => { for (let i = 0; i < cases.length; i++) { @@ -366,6 +377,7 @@ export class RunnerRuntime { runMode?: TestRunMode; concurrent?: boolean; sequential?: boolean; + location?: Location; }): ReturnType { return (name: string, fn) => { for (let i = 0; i < cases.length; i++) { @@ -391,6 +403,7 @@ export class RunnerRuntime { fails?: boolean; concurrent?: boolean; sequential?: boolean; + location?: Location; }): ReturnType { return (name, fn, timeout = this.runtimeConfig.testTimeout) => { for (let i = 0; i < cases.length; i++) { @@ -419,6 +432,7 @@ export class RunnerRuntime { runMode?: TestRunMode; concurrent?: boolean; sequential?: boolean; + location?: Location; }): ReturnType { return (name, fn, timeout = this.runtimeConfig.testTimeout) => { for (let i = 0; i < cases.length; i++) { @@ -469,6 +483,26 @@ export const createRuntimeAPI = ({ runtimeConfig, }); + const getLocation = (): Location | undefined => { + if (!runtimeConfig.includeTaskLocation) return undefined; + const stack = new Error().stack; + if (stack) { + const frames = stackTraceParse(stack); + for (const frame of frames) { + let filename = frame.file ?? ''; + if (filename.startsWith('file://')) filename = fileURLToPath(filename); + // testPath is always unix path style, so convert filename with same way + filename = filename.replaceAll(path.sep, '/'); + if (filename === testPath) { + const line = frame.lineNumber; + const column = frame.column; + if (line != null && column != null) return { line, column }; + } + } + } + return undefined; + }; + const createTestAPI = ( options: { concurrent?: boolean; @@ -476,6 +510,7 @@ export const createRuntimeAPI = ({ fails?: boolean; fixtures?: NormalizedFixtures; runMode?: 'skip' | 'only' | 'todo'; + location?: Location; } = {}, ): TestAPI => { const testFn = ((name, fn, timeout) => @@ -484,6 +519,7 @@ export const createRuntimeAPI = ({ fn, timeout, ...options, + location: options.location ?? getLocation(), })) as TestAPI; for (const { name, overrides } of [ @@ -502,20 +538,32 @@ export const createRuntimeAPI = ({ }); } - testFn.runIf = (condition: boolean) => (condition ? testFn : testFn.skip); + testFn.runIf = (condition: boolean) => + createTestAPI({ + ...options, + location: getLocation(), + runMode: condition ? options.runMode : 'skip', + }); - testFn.skipIf = (condition: boolean) => (condition ? testFn.skip : testFn); + testFn.skipIf = (condition: boolean) => + createTestAPI({ + ...options, + location: getLocation(), + runMode: condition ? 'skip' : options.runMode, + }); testFn.each = ((cases: any) => runtimeInstance.each({ cases, ...options, + location: getLocation(), })) as TestEachFn; testFn.for = ((cases: any) => runtimeInstance.for({ cases, ...options, + location: getLocation(), })) as TestForFn; return testFn; @@ -544,6 +592,7 @@ export const createRuntimeAPI = ({ sequential?: boolean; concurrent?: boolean; runMode?: 'skip' | 'only' | 'todo'; + location?: Location; } = {}, ): DescribeAPI => { const describeFn = ((name, fn) => @@ -551,6 +600,7 @@ export const createRuntimeAPI = ({ name, fn, ...options, + location: options.location ?? getLocation(), })) as DescribeAPI; for (const { name, overrides } of [ @@ -569,20 +619,30 @@ export const createRuntimeAPI = ({ } describeFn.skipIf = (condition: boolean) => - condition ? describeFn.skip : describeFn; + createDescribeAPI({ + ...options, + location: getLocation(), + runMode: condition ? 'skip' : options.runMode, + }); describeFn.runIf = (condition: boolean) => - condition ? describeFn : describeFn.skip; + createDescribeAPI({ + ...options, + location: getLocation(), + runMode: condition ? options.runMode : 'skip', + }); describeFn.each = ((cases: any) => runtimeInstance.describeEach({ cases, ...options, + location: getLocation(), })) as DescribeEachFn; describeFn.for = ((cases: any) => runtimeInstance.describeFor({ cases, ...options, + location: getLocation(), })) as DescribeForFn; return describeFn; diff --git a/packages/core/src/runtime/worker/index.ts b/packages/core/src/runtime/worker/index.ts index 14c78e733..ef4146b3e 100644 --- a/packages/core/src/runtime/worker/index.ts +++ b/packages/core/src/runtime/worker/index.ts @@ -385,7 +385,7 @@ const runInPool = async ( cleanups.push(cleanup); - rpc.onTestFileStart?.({ testPath }); + rpc.onTestFileStart?.({ testPath, tests: [] }); await loadFiles({ rstestContext, @@ -400,6 +400,9 @@ const runInPool = async ( const results = await runner.runTests( testPath, { + onTestFileReady: async (test) => { + await rpc.onTestFileReady(test); + }, onTestSuiteStart: async (test) => { await rpc.onTestSuiteStart(test); }, diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 098dd233b..3c5d115ab 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -274,6 +274,11 @@ export interface RstestConfig { */ chaiConfig?: ChaiConfig; + /** + * Include `location` property in `TestInfo` received by reporters + */ + includeTaskLocation?: boolean; + // Rsbuild configs plugins?: RsbuildConfig['plugins']; diff --git a/packages/core/src/types/core.ts b/packages/core/src/types/core.ts index 4cca7af85..5b3025c8f 100644 --- a/packages/core/src/types/core.ts +++ b/packages/core/src/types/core.ts @@ -6,7 +6,13 @@ import type { RstestConfig, } from './config'; import type { Reporter } from './reporter'; -import type { TestCaseInfo, TestFileResult, TestResult } from './testSuite'; +import type { + FormattedError, + Test, + TestCaseInfo, + TestFileResult, + TestResult, +} from './testSuite'; export type RstestCommand = 'watch' | 'run' | 'list'; @@ -74,10 +80,19 @@ export type RstestContext = { export type ListCommandOptions = { filesOnly?: boolean; json?: boolean | string; + includeSuites?: boolean; + printLocation?: boolean; +}; + +export type ListCommandResult = { + tests: Test[]; + testPath: string; + project: string; + errors?: FormattedError[]; }; export type RstestInstance = { context: RstestContext; runTests: () => Promise; - listTests: (options: ListCommandOptions) => Promise; + listTests: (options: ListCommandOptions) => Promise; }; diff --git a/packages/core/src/types/reporter.ts b/packages/core/src/types/reporter.ts index ee4fd9c10..92bf628bc 100644 --- a/packages/core/src/types/reporter.ts +++ b/packages/core/src/types/reporter.ts @@ -54,6 +54,10 @@ export interface Reporter { * Called before test file run. */ onTestFileStart?: (test: TestFileInfo) => void; + /** + * Called after tests in file collected. + */ + onTestFileReady?: (test: TestFileInfo) => void; /** * Called when the test file has finished running. */ diff --git a/packages/core/src/types/runner.ts b/packages/core/src/types/runner.ts index af8eec003..640757c0e 100644 --- a/packages/core/src/types/runner.ts +++ b/packages/core/src/types/runner.ts @@ -1,8 +1,17 @@ -import type { TestCaseInfo, TestResult, TestSuiteInfo } from './testSuite'; +import type { + TestCaseInfo, + TestFileInfo, + TestResult, + TestSuiteInfo, +} from './testSuite'; export type RunnerHooks = { onTestSuiteStart?: (test: TestSuiteInfo) => Promise; onTestSuiteResult?: (result: TestResult) => Promise; + /** + * Called after tests in file collected. + */ + onTestFileReady?: (test: TestFileInfo) => Promise; /** * Called before running the test case. */ diff --git a/packages/core/src/types/testSuite.ts b/packages/core/src/types/testSuite.ts index 6cb153695..575eb9819 100644 --- a/packages/core/src/types/testSuite.ts +++ b/packages/core/src/types/testSuite.ts @@ -27,6 +27,11 @@ export interface TaskResult { errors?: FormattedError[]; } +export type Location = { + line: number; + column: number; +}; + export type TestCaseInfo = { testId: string; testPath: TestPath; @@ -35,6 +40,9 @@ export type TestCaseInfo = { parentNames?: string[]; project: string; startTime?: number; + /** Only included when `includeTaskLocation` config is enabled */ + location?: Location; + type: 'case'; }; export type TestCase = TestCaseInfo & { @@ -51,7 +59,6 @@ export type TestCase = TestCaseInfo & { only?: boolean; onFinished: OnTestFinishedHandler[]; onFailed: OnTestFailedHandler[]; - type: 'case'; /** * Store promises (from async expects) to wait for them before finishing the test */ @@ -85,6 +92,9 @@ export type TestSuiteInfo = { parentNames?: string[]; testPath: TestPath; project: string; + type: 'suite'; + /** Only included when `includeTaskLocation` config is enabled */ + location?: Location; }; export type TestSuite = TestSuiteInfo & { @@ -94,8 +104,7 @@ export type TestSuite = TestSuiteInfo & { concurrent?: boolean; sequential?: boolean; /** nested cases and suite could in a suite */ - tests: (TestSuite | TestCase)[]; - type: 'suite'; + tests: Test[]; afterAllListeners?: AfterAllListener[]; beforeAllListeners?: BeforeAllListener[]; afterEachListeners?: AfterEachListener[]; @@ -110,8 +119,11 @@ export type TestSuiteListeners = keyof Pick< | 'beforeEachListeners' >; +export type TestInfo = TestCaseInfo | (TestSuiteInfo & { tests: TestInfo[] }); + export type TestFileInfo = { testPath: TestPath; + tests: TestInfo[]; }; export type Test = TestSuite | TestCase; diff --git a/packages/core/src/types/worker.ts b/packages/core/src/types/worker.ts index 9308c45bc..59d8fe1d4 100644 --- a/packages/core/src/types/worker.ts +++ b/packages/core/src/types/worker.ts @@ -24,6 +24,7 @@ export type ServerRPC = {}; /** Runtime to Server */ export type RuntimeRPC = { onTestFileStart: (test: TestFileInfo) => Promise; + onTestFileReady: (test: TestFileInfo) => Promise; getAssetsByEntry: () => Promise<{ assetFiles: Record; sourceMaps: Record; @@ -61,6 +62,7 @@ export type RuntimeConfig = Pick< | 'logHeapUsage' | 'bail' | 'chaiConfig' + | 'includeTaskLocation' >; export type WorkerContext = { diff --git a/packages/core/tests/__snapshots__/config.test.ts.snap b/packages/core/tests/__snapshots__/config.test.ts.snap index d7061b29d..d47d8521a 100644 --- a/packages/core/tests/__snapshots__/config.test.ts.snap +++ b/packages/core/tests/__snapshots__/config.test.ts.snap @@ -49,6 +49,7 @@ exports[`mergeRstestConfig > should merge config correctly with default config 1 "tests/**/*.test.ts", ], "includeSource": [], + "includeTaskLocation": false, "isolate": true, "logHeapUsage": false, "maxConcurrency": 5, diff --git a/packages/core/tests/core/__snapshots__/rstest.test.ts.snap b/packages/core/tests/core/__snapshots__/rstest.test.ts.snap index f064aa294..4c1639cbd 100644 --- a/packages/core/tests/core/__snapshots__/rstest.test.ts.snap +++ b/packages/core/tests/core/__snapshots__/rstest.test.ts.snap @@ -48,6 +48,7 @@ exports[`rstest context > should generate rstest context correctly 1`] = ` "**/*.{test,spec}.?(c|m)[jt]s?(x)", ], "includeSource": [], + "includeTaskLocation": false, "isolate": true, "logHeapUsage": false, "maxConcurrency": 5, @@ -123,6 +124,7 @@ exports[`rstest context > should generate rstest context correctly with multiple "/packages/core/test-project/tests/**/*.test.ts", ], "includeSource": [], + "includeTaskLocation": false, "isolate": true, "logHeapUsage": false, "maxConcurrency": 5, @@ -201,6 +203,7 @@ exports[`rstest context > should generate rstest context correctly with multiple "**/*.{test,spec}.?(c|m)[jt]s?(x)", ], "includeSource": [], + "includeTaskLocation": false, "isolate": true, "logHeapUsage": false, "maxConcurrency": 5, diff --git a/packages/vscode/src/testRunReporter.ts b/packages/vscode/src/testRunReporter.ts index 5c6849026..c7717dbfe 100644 --- a/packages/vscode/src/testRunReporter.ts +++ b/packages/vscode/src/testRunReporter.ts @@ -92,7 +92,7 @@ export class TestRunReporter implements Reporter { this.onTestCaseResult(result); } - onTestCaseStart(test: TestCaseInfo) { + onTestCaseStart(test: TestCaseInfo | TestSuiteInfo) { // ignore reported item not belongs current testItem if (!this.contains(test)) return; diff --git a/website/docs/en/guide/basic/cli.mdx b/website/docs/en/guide/basic/cli.mdx index 725274b55..df74af5a4 100644 --- a/website/docs/en/guide/basic/cli.mdx +++ b/website/docs/en/guide/basic/cli.mdx @@ -102,6 +102,32 @@ $ npx rstest list --json $ npx rstest list --json=./output.json ``` +You can use `--includeSuites` to print test suites along side test cases: + +```bash +$ npx rstest list + +# the output is shown below: +a.test.ts > test a +a.test.ts > test a > test a-1 +a.test.ts > test a-2 +b.test.ts > test b +b.test.ts > test b > test b-1 +b.test.ts > test b-2 +``` + +You can use `--printLocation` to print location of tests: + +```bash +$ npx rstest list + +# the output is shown below: +a.test.ts:4:5 > test a > test a-1 +a.test.ts:9:3 > test a-2 +b.test.ts:4:5 > test b > test b-1 +b.test.ts:9:3 > test b-2 +``` + ## CLI options Rstest CLI provides several common options that can be used with all commands: diff --git a/website/docs/zh/guide/basic/cli.mdx b/website/docs/zh/guide/basic/cli.mdx index 46dd9df64..ad74974e5 100644 --- a/website/docs/zh/guide/basic/cli.mdx +++ b/website/docs/zh/guide/basic/cli.mdx @@ -102,6 +102,32 @@ $ npx rstest list --json $ npx rstest list --json=./output.json ``` +您可以使用 `--includeSuites` 选项,在打印测试用例的同时输出测试套件信息: + +```bash +$ npx rstest list + +# 输出如下: +a.test.ts > test a +a.test.ts > test a > test a-1 +a.test.ts > test a-2 +b.test.ts > test b +b.test.ts > test b > test b-1 +b.test.ts > test b-2 +``` + +您可以使用 `--printLocation` 选项来打印测试的位置信息: + +```bash +$ npx rstest list + +# 输出如下: +a.test.ts:4:5 > test a > test a-1 +a.test.ts:9:3 > test a-2 +b.test.ts:4:5 > test b > test b-1 +b.test.ts:9:3 > test b-2 +``` + ## CLI 选项 Rstest CLI 支持以下常用参数,所有命令均可使用: