From bdce0a29db1b5a2766d583cef40277a2b3857659 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Thu, 2 May 2024 15:16:48 +0200 Subject: [PATCH] feat: support standalone mode (#5565) --- docs/guide/cli-table.md | 7 +- .../ui/client/composables/client/index.ts | 26 +++- packages/vitest/src/api/setup.ts | 4 + packages/vitest/src/api/types.ts | 1 + packages/vitest/src/node/cli/cli-api.ts | 10 +- packages/vitest/src/node/cli/cli-config.ts | 9 +- packages/vitest/src/node/config.ts | 3 + packages/vitest/src/node/core.ts | 45 ++++++- packages/vitest/src/node/logger.ts | 5 +- packages/vitest/src/node/stdin.ts | 22 +++- packages/vitest/src/node/watch-filter.ts | 4 + packages/vitest/src/types/config.ts | 9 ++ test/reporters/tests/default.test.ts | 4 +- test/watch/test/stdin.test.ts | 111 ++++++++++-------- 14 files changed, 187 insertions(+), 73 deletions(-) diff --git a/docs/guide/cli-table.md b/docs/guide/cli-table.md index 3f5581b8e2ef..ddcd0045d8f5 100644 --- a/docs/guide/cli-table.md +++ b/docs/guide/cli-table.md @@ -25,9 +25,9 @@ | `--coverage.cleanOnRerun` | Clean coverage report on watch rerun (default: true) | | `--coverage.reportsDirectory ` | Directory to write coverage report to (default: ./coverage) | | `--coverage.reporter ` | Coverage reporters to use. Visit [`coverage.reporter`](https://vitest.dev/config/#coverage-reporter) for more information (default: `["text", "html", "clover", "json"]`) | -| `--coverage.reportOnFailure` | Generate coverage report even when tests fail (default: false) | -| `--coverage.allowExternal` | Collect coverage of files outside the project root (default: false) | -| `--coverage.skipFull` | Do not show files with 100% statement, branch, and function coverage (default: false) | +| `--coverage.reportOnFailure` | Generate coverage report even when tests fail (default: `false`) | +| `--coverage.allowExternal` | Collect coverage of files outside the project root (default: `false`) | +| `--coverage.skipFull` | Do not show files with 100% statement, branch, and function coverage (default: `false`) | | `--coverage.thresholds.100` | Shortcut to set all coverage thresholds to 100 (default: `false`) | | `--coverage.thresholds.perFile` | Check thresholds per file. See `--coverage.thresholds.lines`, `--coverage.thresholds.functions`, `--coverage.thresholds.branches` and `--coverage.thresholds.statements` for the actual thresholds (default: `false`) | | `--coverage.thresholds.autoUpdate` | Update threshold values: "lines", "functions", "branches" and "statements" to configuration file when current coverage is above the configured thresholds (default: `false`) | @@ -119,3 +119,4 @@ | `--segfaultRetry ` | Retry the test suite if it crashes due to a segfault (default: `true`) | | `--no-color` | Removes colors from the console output | | `--clearScreen` | Clear terminal screen when re-running tests during watch mode (default: `true`) | +| `--standalone` | Start Vitest without running tests. File filters will be ignored, tests will be running only on change (default: `false`) | diff --git a/packages/ui/client/composables/client/index.ts b/packages/ui/client/composables/client/index.ts index c484711bc532..96fc1a7cb419 100644 --- a/packages/ui/client/composables/client/index.ts +++ b/packages/ui/client/composables/client/index.ts @@ -3,6 +3,8 @@ import type { WebSocketStatus } from '@vueuse/core' import type { ErrorWithDiff, File, ResolvedConfig } from 'vitest' import type { Ref } from 'vue' import { reactive } from 'vue' +import { relative } from 'pathe' +import { generateHash } from '@vitest/runner/utils' import type { RunState } from '../../../types' import { ENTRY_URL, isReport } from '../../constants' import { parseError } from '../error' @@ -84,7 +86,29 @@ watch( client.rpc.getConfig(), client.rpc.getUnhandledErrors(), ]) - client.state.collectFiles(files) + if (_config.standalone) { + const filenames = await client.rpc.getTestFiles() + const files = filenames.map(([name, filepath]) => { + const path = relative(_config.root, filepath) + return { + filepath, + name: path, + id: /* #__PURE__ */ generateHash(`${path}${name || ''}`), + mode: 'skip', + type: 'suite', + result: { + state: 'skip', + }, + meta: {}, + tasks: [], + projectName: name, + } + }) + client.state.collectFiles(files) + } + else { + client.state.collectFiles(files) + } unhandledErrors.value = (errors || []).map(parseError) config.value = _config }) diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index f236f08816e0..37c3bb92dbb2 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -164,6 +164,10 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi getProvidedContext() { return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any) }, + async getTestFiles() { + const spec = await ctx.globTestFiles() + return spec.map(([project, file]) => [project.getName(), file]) as [string, string][] + }, }, { post: msg => ws.send(msg), diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 4b47bedc7ed2..45afb11fdf6e 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -15,6 +15,7 @@ export interface WebSocketHandlers { getCountOfFailedTests: () => number sendLog: (log: UserConsoleLog) => void getFiles: () => File[] + getTestFiles: () => Promise<[name: string, file: string][]> getPaths: () => string[] getConfig: () => ResolvedConfig resolveSnapshotPath: (testPath: string) => string diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index 739321a30b9b..b6f783c8b0bd 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -88,11 +88,17 @@ export async function startVitest( }) ctx.onAfterSetServer(() => { - ctx.start(cliFilters) + if (ctx.config.standalone) + ctx.init() + else + ctx.start(cliFilters) }) try { - await ctx.start(cliFilters) + if (ctx.config.standalone) + await ctx.init() + else + await ctx.start(cliFilters) } catch (e) { process.exitCode = 1 diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index e89164f05b7e..459db90a5472 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -202,13 +202,13 @@ export const cliOptionsConfig: VitestCLIOptions = { array: true, }, reportOnFailure: { - description: 'Generate coverage report even when tests fail (default: false)', + description: 'Generate coverage report even when tests fail (default: `false`)', }, allowExternal: { - description: 'Collect coverage of files outside the project root (default: false)', + description: 'Collect coverage of files outside the project root (default: `false`)', }, skipFull: { - description: 'Do not show files with 100% statement, branch, and function coverage (default: false)', + description: 'Do not show files with 100% statement, branch, and function coverage (default: `false`)', }, thresholds: { description: null, @@ -601,6 +601,9 @@ export const cliOptionsConfig: VitestCLIOptions = { clearScreen: { description: 'Clear terminal screen when re-running tests during watch mode (default: `true`)', }, + standalone: { + description: 'Start Vitest without running tests. File filters will be ignored, tests will be running only on change (default: `false`)', + }, // disable CLI options cliExclude: null, diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 167b90e5073c..37b13f416d6f 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -136,6 +136,9 @@ export function resolveConfig( resolved.shard = { index, count } } + if (resolved.standalone && !resolved.watch) + throw new Error(`Vitest standalone mode requires --watch`) + if (resolved.maxWorkers) resolved.maxWorkers = Number(resolved.maxWorkers) diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 2ca7acc68c4a..4e565fa690fb 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -13,7 +13,7 @@ import type { CancelReason, File } from '@vitest/runner' import { ViteNodeServer } from 'vite-node/server' import type { defineWorkspace } from 'vitest/config' import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, VitestRunMode } from '../types' -import { hasFailed, noop, slash, toArray, wildcardPatternToRegExp } from '../utils' +import { getTasks, hasFailed, noop, slash, toArray, wildcardPatternToRegExp } from '../utils' import { getCoverageProvider } from '../integrations/coverage' import { CONFIG_NAMES, configFiles, workspacesFiles as workspaceFiles } from '../constants' import { rootDir } from '../paths' @@ -418,6 +418,25 @@ export class Vitest { await this.report('onWatcherStart') } + async init() { + this._onClose = [] + + try { + await this.initCoverageProvider() + await this.coverageProvider?.clean(this.config.coverage.clean) + await this.initBrowserProviders() + } + finally { + await this.report('onInit', this) + } + + // populate test files cache so watch mode can trigger a file rerun + await this.globTestFiles() + + if (this.config.watch) + await this.report('onWatcherStart') + } + private async getTestDependencies(filepath: WorkspaceSpec, deps = new Set()) { const addImports = async ([project, filepath]: WorkspaceSpec) => { if (deps.has(filepath)) @@ -607,14 +626,24 @@ export class Vitest { if (pattern === '') this.filenamePattern = undefined - this.configOverride.testNamePattern = pattern ? new RegExp(pattern) : undefined + const testNamePattern = pattern ? new RegExp(pattern) : undefined + this.configOverride.testNamePattern = testNamePattern + // filter only test files that have tests matching the pattern + if (testNamePattern) { + files = files.filter((filepath) => { + const files = this.state.getFiles([filepath]) + return !files.length || files.some((file) => { + const tasks = getTasks(file) + return !tasks.length || tasks.some(task => testNamePattern.test(task.name)) + }) + }) + } await this.rerunFiles(files, trigger) } - async changeFilenamePattern(pattern: string) { + async changeFilenamePattern(pattern: string, files: string[] = this.state.getFilepaths()) { this.filenamePattern = pattern - const files = this.state.getFilepaths() const trigger = this.filenamePattern ? 'change filename pattern' : 'reset filename pattern' await this.rerunFiles(files, trigger) @@ -758,8 +787,10 @@ export class Vitest { const matchingProjects: WorkspaceProject[] = [] await Promise.all(this.projects.map(async (project) => { - if (await project.isTargetFile(id)) + if (await project.isTargetFile(id)) { matchingProjects.push(project) + project.testFilesList?.push(id) + } })) if (matchingProjects.length > 0) { @@ -940,6 +971,10 @@ export class Vitest { ))) } + public async getTestFilepaths() { + return this.globTestFiles().then(files => files.map(([, file]) => file)) + } + public async globTestFiles(filters: string[] = []) { const files: WorkspaceSpec[] = [] await Promise.all(this.projects.map(async (project) => { diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 130794c4d7d5..46b4bfb4fb8b 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -189,7 +189,10 @@ export class Logger { if (this.ctx.coverageProvider) this.log(c.dim(' Coverage enabled with ') + c.yellow(this.ctx.coverageProvider.name)) - this.log() + if (this.ctx.config.standalone) + this.log(c.yellow(`\nVitest is running in standalone mode. Edit a test file to rerun tests.`)) + else + this.log() } async printUnhandledErrors(errors: unknown[]) { diff --git a/packages/vitest/src/node/stdin.ts b/packages/vitest/src/node/stdin.ts index 543922081ce5..1e18ebb5ee11 100644 --- a/packages/vitest/src/node/stdin.ts +++ b/packages/vitest/src/node/stdin.ts @@ -2,7 +2,7 @@ import readline from 'node:readline' import type { Writable } from 'node:stream' import c from 'picocolors' import prompt from 'prompts' -import { relative } from 'pathe' +import { relative, resolve } from 'pathe' import { getTests, isWindows, stdout } from '../utils' import { toArray } from '../utils/base' import type { Vitest } from './core' @@ -73,8 +73,10 @@ export function registerConsoleShortcuts(ctx: Vitest, stdin: NodeJS.ReadStream = if (name === 'u') return ctx.updateSnapshot() // rerun all tests - if (name === 'a' || name === 'return') - return ctx.changeNamePattern('') + if (name === 'a' || name === 'return') { + const files = await ctx.getTestFilepaths() + return ctx.changeNamePattern('', files, 'rerun all tests') + } // rerun current pattern tests if (name === 'r') return ctx.rerunFiles() @@ -113,7 +115,13 @@ export function registerConsoleShortcuts(ctx: Vitest, stdin: NodeJS.ReadStream = }) on() - await ctx.changeNamePattern(filter?.trim() || '', undefined, 'change pattern') + const files = ctx.state.getFilepaths() + // if running in standalone mode, Vitest instance doesn't know about any test file + const cliFiles = ctx.config.standalone && !files.length + ? await ctx.getTestFilepaths() + : undefined + + await ctx.changeNamePattern(filter?.trim() || '', cliFiles, 'change pattern') } async function inputProjectName() { @@ -143,8 +151,12 @@ export function registerConsoleShortcuts(ctx: Vitest, stdin: NodeJS.ReadStream = on() latestFilename = filter?.trim() || '' + const lastResults = watchFilter.getLastResults() - await ctx.changeFilenamePattern(latestFilename) + await ctx.changeFilenamePattern( + latestFilename, + filter && lastResults.length ? lastResults.map(i => resolve(ctx.config.root, i)) : undefined, + ) } let rl: readline.Interface | undefined diff --git a/packages/vitest/src/node/watch-filter.ts b/packages/vitest/src/node/watch-filter.ts index 788593d9918c..10727f500d05 100644 --- a/packages/vitest/src/node/watch-filter.ts +++ b/packages/vitest/src/node/watch-filter.ts @@ -179,4 +179,8 @@ export class WatchFilter { // @ts-expect-error -- write() method has different signature on the union type this.stdout.write(data) } + + public getLastResults() { + return this.results + } } diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index 269936195a59..efbd238904bd 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -801,6 +801,15 @@ export interface UserConfig extends InlineConfig { */ config?: string | false | undefined + /** + * Do not run tests when Vitest starts. + * + * Vitest will only run tests if it's called programmatically or the test file changes. + * + * CLI file filters will be ignored. + */ + standalone?: boolean + /** * Use happy-dom */ diff --git a/test/reporters/tests/default.test.ts b/test/reporters/tests/default.test.ts index f37487f4ccc9..8b958237d54e 100644 --- a/test/reporters/tests/default.test.ts +++ b/test/reporters/tests/default.test.ts @@ -38,7 +38,9 @@ describe('default reporter', async () => { // one file vitest.write('p') await vitest.waitForStdout('Input filename pattern') - vitest.write('a\n') + vitest.write('a') + await vitest.waitForStdout('a.test.ts') + vitest.write('\n') await vitest.waitForStdout('Filename pattern: a') await vitest.waitForStdout('Waiting for file changes...') expect(vitest.stdout).contain('✓ a1 test') diff --git a/test/watch/test/stdin.test.ts b/test/watch/test/stdin.test.ts index 3c88a08558f3..38a6145e1d69 100644 --- a/test/watch/test/stdin.test.ts +++ b/test/watch/test/stdin.test.ts @@ -1,68 +1,64 @@ import { rmSync, writeFileSync } from 'node:fs' -import { expect, onTestFinished, test } from 'vitest' +import { describe, expect, onTestFinished, test } from 'vitest' import { runVitest } from '../../test-utils' -const options = { root: 'fixtures', watch: true } +const _options = { root: 'fixtures', watch: true } -test('quit watch mode', async () => { - const { vitest, waitForClose } = await runVitest(options) +describe.each([true, false])('standalone mode is %s', (standalone) => { + const options = { ..._options, standalone } - vitest.write('q') + test('quit watch mode', async () => { + const { vitest, waitForClose } = await runVitest(options) - await waitForClose() -}) + vitest.write('q') -test('rerun current pattern tests', async () => { - const { vitest } = await runVitest({ ...options, testNamePattern: 'sum' }) + await waitForClose() + }) - vitest.write('r') + test('filter by filename', async () => { + const { vitest } = await runVitest(options) - await vitest.waitForStdout('RERUN') - await vitest.waitForStdout('Test name pattern: /sum/') - await vitest.waitForStdout('1 passed') -}) + vitest.write('p') -test('filter by filename', async () => { - const { vitest } = await runVitest(options) + await vitest.waitForStdout('Input filename pattern') - vitest.write('p') + vitest.write('math') - await vitest.waitForStdout('Input filename pattern') + await vitest.waitForStdout('Pattern matches 1 result') + await vitest.waitForStdout('› math.test.ts') - vitest.write('math') + vitest.write('\n') - await vitest.waitForStdout('Pattern matches 1 result') - await vitest.waitForStdout('› math.test.ts') + await vitest.waitForStdout('Filename pattern: math') + await vitest.waitForStdout('1 passed') + }) - vitest.write('\n') + test('filter by test name', async () => { + const { vitest } = await runVitest(options) - await vitest.waitForStdout('Filename pattern: math') - await vitest.waitForStdout('1 passed') -}) + vitest.write('t') -test('filter by test name', async () => { - const { vitest } = await runVitest(options) + await vitest.waitForStdout('Input test name pattern') - vitest.write('t') + vitest.write('sum') + if (standalone) + await vitest.waitForStdout('Pattern matches no results') + else + await vitest.waitForStdout('Pattern matches 1 result') + await vitest.waitForStdout('› sum') - await vitest.waitForStdout('Input test name pattern') + vitest.write('\n') - vitest.write('sum') - await vitest.waitForStdout('Pattern matches 1 result') - await vitest.waitForStdout('› sum') + await vitest.waitForStdout('Test name pattern: /sum/') + await vitest.waitForStdout('1 passed') + }) - vitest.write('\n') + test.skipIf(process.env.GITHUB_ACTIONS)('cancel test run', async () => { + const { vitest } = await runVitest(options) - await vitest.waitForStdout('Test name pattern: /sum/') - await vitest.waitForStdout('1 passed') -}) - -test.skipIf(process.env.GITHUB_ACTIONS)('cancel test run', async () => { - const { vitest } = await runVitest(options) - - const testPath = 'fixtures/cancel.test.ts' - const testCase = `// Dynamic test case + const testPath = 'fixtures/cancel.test.ts' + const testCase = `// Dynamic test case import { afterAll, afterEach, test } from 'vitest' // These should be called even when test is cancelled @@ -80,17 +76,28 @@ test('2 - test that is cancelled', async () => { }) ` - onTestFinished(() => rmSync(testPath)) - writeFileSync(testPath, testCase, 'utf8') + onTestFinished(() => rmSync(testPath)) + writeFileSync(testPath, testCase, 'utf8') + + // Test case is running, cancel it + await vitest.waitForStdout('[cancel-test]: test') + vitest.write('c') - // Test case is running, cancel it - await vitest.waitForStdout('[cancel-test]: test') - vitest.write('c') + // Test hooks should still be called + await vitest.waitForStdout('CANCELLED') + await vitest.waitForStdout('[cancel-test]: afterAll') + await vitest.waitForStdout('[cancel-test]: afterEach') - // Test hooks should still be called - await vitest.waitForStdout('CANCELLED') - await vitest.waitForStdout('[cancel-test]: afterAll') - await vitest.waitForStdout('[cancel-test]: afterEach') + expect(vitest.stdout).not.include('[cancel-test]: should not run') + }) +}) + +test('rerun current pattern tests', async () => { + const { vitest } = await runVitest({ ..._options, testNamePattern: 'sum' }) - expect(vitest.stdout).not.include('[cancel-test]: should not run') + vitest.write('r') + + await vitest.waitForStdout('RERUN') + await vitest.waitForStdout('Test name pattern: /sum/') + await vitest.waitForStdout('1 passed') })