diff --git a/docs/config/changed.md b/docs/config/changed.md index b23aaa963..13c437823 100644 --- a/docs/config/changed.md +++ b/docs/config/changed.md @@ -15,4 +15,6 @@ To run tests against changes made in the last commit, you can use `--changed HEA When used with code coverage the report will contain only the files that were related to the changes. +Changes to files that every test file in a project depends on rerun all tests of that project: [`setupFiles`](/config/setupfiles), [`globalSetup`](/config/globalsetup), a custom [`runner`](/config/runner) or [`environment`](/config/environment), [`snapshotSerializers`](/config/snapshotserializers), the `.env` files loaded from [`envDir`](https://vite.dev/config/shared-options#envdir), and the project's config file with everything it imports. Vitest also follows the environment set in a `@vitest-environment` comment, the `__mocks__` files used by `vi.mock` calls without a factory, and the snapshot file of each test. Files passed to `toMatchFileSnapshot` are not tracked. + If paired with the [`forceRerunTriggers`](/config/forcereruntriggers) config option it will run the whole test suite if at least one of the files listed in the `forceRerunTriggers` list changes. By default, changes to the Vitest config file and `package.json` will always rerun the whole suite. diff --git a/packages/mocker/src/node/hoistMocks.ts b/packages/mocker/src/node/hoistMocks.ts index 0e42242c6..d2773aa19 100644 --- a/packages/mocker/src/node/hoistMocks.ts +++ b/packages/mocker/src/node/hoistMocks.ts @@ -25,6 +25,8 @@ export interface StaticMockCall { hasFactory: boolean /** the factory uses `importOriginal`/`importActual` */ factoryLoadsOriginal: boolean + /** called without a factory or options, so the module is automocked or redirected to `__mocks__` */ + automock?: boolean } export interface HoistMocksOptions { @@ -93,7 +95,10 @@ function getNodeTail(code: string, node: Node) { return end } -const regexpHoistable = /\b(?:vi|vitest)\s*\.\s*(?:mock|unmock|hoisted|doMock|doUnmock)\s*\(/ +const regexpHoistable = + /\b(?:vi|vitest)\s*\.\s*(?:mock|unmock|hoisted|doMock|doUnmock|importActual)\s*\(/ +// calls that load the original module even if it is mocked +const originalLoadingMethodNames = ['doUnmock', 'importActual'] const hashbangRE = /^#!.*\n/ // Public redistributions of Vitest that re-export its mocking API (`vi`) @@ -339,6 +344,7 @@ export function hoistMocks( method: methodName, specifier, hasFactory: factory != null, + automock: node.arguments.length === 1, factoryLoadsOriginal: factory != null && (factory.params.length > 0 || @@ -389,6 +395,18 @@ export function hoistMocks( } } + if (options.onStaticMock && originalLoadingMethodNames.includes(methodName)) { + const specifier = getStaticSpecifier(node.arguments[0]) + if (specifier != null) { + options.onStaticMock({ + method: methodName, + specifier, + hasFactory: false, + factoryLoadsOriginal: false, + }) + } + } + if (hoistedMethodNames.includes(methodName)) { hasMockApiCall = true assertNotDefaultExport( diff --git a/packages/mocker/src/node/hoistMocksPlugin.ts b/packages/mocker/src/node/hoistMocksPlugin.ts index cc11c1255..37dadd6f6 100644 --- a/packages/mocker/src/node/hoistMocksPlugin.ts +++ b/packages/mocker/src/node/hoistMocksPlugin.ts @@ -28,6 +28,8 @@ export function hoistMocksPlugin(options: HoistMocksPluginOptions = {}): Plugin ...hoistableMockMethodNames, ...hoistedMethodNames, ...dynamicImportMockMethodNames, + // reported as a static mock call, so the original is known to be loaded + 'importActual', ]) const regexpHoistable = new RegExp( @@ -63,7 +65,7 @@ export function hoistMocksPlugin(options: HoistMocksPluginOptions = {}): Plugin }) // vite keeps `meta` across re-transforms, so always reset it if (!s) { - return { meta: { vitestStaticMocks: null } } + return { meta: { vitestStaticMocks: staticMocks.length ? staticMocks : null } } } return { code: s.toString(), diff --git a/packages/vitest/src/node/affected-modules.ts b/packages/vitest/src/node/affected-modules.ts new file mode 100644 index 000000000..7e11a10b3 --- /dev/null +++ b/packages/vitest/src/node/affected-modules.ts @@ -0,0 +1,479 @@ +import type { DevEnvironment } from 'vite' +import type { Vitest } from './core' +import type { TestProject } from './project' +import type { TestSpecification } from './test-specification' +import { existsSync } from 'node:fs' +import os from 'node:os' +import { cleanUrl } from '@vitest/utils/helpers' +import { isAbsolute, resolve } from 'pathe' +import { getSpecificationDocblock } from '../utils/test-helpers' +import { getStaticMocks, resolveStaticMocks } from './environments/staticMocks' + +const builtinEnvironments = new Set(['node', 'jsdom', 'happy-dom', 'edge-runtime']) +const clientEnvironments = new Set(['jsdom', 'happy-dom']) +const noMocks = new Set() + +interface ModuleNode { + dependencies: string[] + // files added by plugins with `addWatchFile`, they are not modules and are never transformed + watchedFiles: string[] + mocked: Set + restored: Set + failed?: boolean +} + +/** + * Finds test specifications that statically depend on any of the changed files. + */ +export class AffectedModulesResolver { + private existsCache = new Map() + private transformConcurrency = os.availableParallelism?.() ?? os.cpus().length + private activeTransforms = 0 + private transformQueue: Array<() => void> = [] + + constructor( + private vitest: Vitest, + private related: string[], + ) {} + + async resolve(specs: TestSpecification[]): Promise { + if (this.dependsOnConfig(this.vitest.vite.config.configFileDependencies)) { + return specs + } + + // the module graph, and so the dependency edges, are per project + const specsByProject = new Map() + for (const spec of specs) { + let projectSpecs = specsByProject.get(spec.project) + if (!projectSpecs) { + specsByProject.set(spec.project, (projectSpecs = [])) + } + projectSpecs.push(spec) + } + + const affected = await Promise.all( + Array.from(specsByProject, ([project, projectSpecs]) => + this.findAffectedInProject(project, projectSpecs), + ), + ) + const affectedSpecs = new Set(affected.flat()) + return specs.filter((spec) => affectedSpecs.has(spec)) + } + + private async findAffectedInProject( + project: TestProject, + specs: TestSpecification[], + ): Promise { + if ( + this.dependsOnConfig(project.vite.config.configFileDependencies) || + this.dependsOnConfig(getEnvFiles(project)) + ) { + return specs + } + + // global setup and environments are loaded by Vitest itself, not by the test's module runner + const vitestWalk = new GraphWalk( + new ProjectGraph(project, project.vite.environments.__vitest__, this), + this.related, + ) + const vitestFiles = this.filterLocalSourceFiles([ + ...project.config.globalSetup, + await this.resolveEnvironmentFile(project, project.config.environment), + ]) + const environmentFiles = await Promise.all( + specs.map((spec) => this.getSpecificationEnvironmentFile(spec)), + ) + await vitestWalk.add( + [...vitestFiles, ...environmentFiles.filter((file) => file != null)], + noMocks, + ) + + // if global setup or a setup file is changed, run all specs + const affectedByVitest = vitestWalk.getAffected(noMocks) + if (vitestFiles.some((file) => affectedByVitest.has(file))) { + return specs + } + + // walk the graph of the environment that runs the test, so the run reuses the transforms + const affected: TestSpecification[] = [] + const related = new Set(this.related) + const snapshotContext = { config: project.serializedConfig } + const environments = await Promise.all(specs.map((spec) => this.getViteEnvironment(spec))) + const specsByEnvironment = new Map() + specs.forEach((spec, index) => { + // always run the spec if environment file is updated + const environmentFile = environmentFiles[index] + if ( + (environmentFile && affectedByVitest.has(environmentFile)) || + related.has(this.getSnapshotFile(spec, snapshotContext)) + ) { + affected.push(spec) + return + } + let environmentSpecs = specsByEnvironment.get(environments[index]) + if (!environmentSpecs) { + specsByEnvironment.set(environments[index], (environmentSpecs = [])) + } + environmentSpecs.push(spec) + }) + + const affectedInEnvironments = await Promise.all( + Array.from(specsByEnvironment, ([environment, environmentSpecs]) => + this.findAffectedInEnvironment(project, environment, environmentSpecs), + ), + ) + return [...affected, ...affectedInEnvironments.flat()] + } + + private getSnapshotFile(spec: TestSpecification, context: object): string { + const path = this.vitest.snapshot.resolvePath(spec.moduleId, context) + return resolve(spec.project.config.root, path) + } + + // mirrors the `viteEnvironment` of the builtin environments, custom ones are only known in the worker + private async getViteEnvironment(spec: TestSpecification): Promise { + const environments = spec.project.vite.environments + if (spec.pool === 'browser') { + return environments.ssr + } + const { environment } = await getSpecificationDocblock(spec) + return (clientEnvironments.has(environment.name) && environments.client) || environments.ssr + } + + private async findAffectedInEnvironment( + project: TestProject, + environment: DevEnvironment, + specs: TestSpecification[], + ): Promise { + const graph = new ProjectGraph(project, environment, this) + const config = project.config + + // setup files are walked without mocks: a mock only applies to the files loaded after it + const setupFiles = this.filterLocalSourceFiles(config.setupFiles) + const projectFiles = this.filterLocalSourceFiles([ + ...setupFiles, + ...config.snapshotSerializers, + config.runner, + config.snapshotEnvironment, + typeof config.diff === 'string' ? config.diff : undefined, + ]) + const walk = new GraphWalk(graph, this.related) + await walk.add(projectFiles, noMocks) + // the project files are fully walked at this point, so later walks can't change the result + const affectedByProjectFiles = walk.getAffected(noMocks) + if (projectFiles.some((file) => affectedByProjectFiles.has(file))) { + return specs + } + + // mocks from setup files apply to every test file of the project + const projectMocks = new Set() + const setupMocks = await Promise.all(setupFiles.map((file) => graph.getMocks(file))) + setupMocks.forEach(({ mocked }) => mocked.forEach((id) => projectMocks.add(id))) + + const specMocks = new Map>() + await Promise.all( + specs.map(async (spec) => { + const { mocked, restored } = await graph.getMocks(spec.moduleId) + if (!mocked.size && !restored.size) { + return + } + const specMocked = new Set([...projectMocks, ...mocked]) + restored.forEach((id) => specMocked.delete(id)) + specMocks.set(spec, specMocked) + }), + ) + + await Promise.all( + specs.map((spec) => walk.add([spec.moduleId], specMocks.get(spec) ?? projectMocks)), + ) + const affected = walk.getAffected(projectMocks) + // a test can load a module that a setup file mocks, so its walk is not limited by those mocks + const affectedWithoutMocks = specMocks.size ? walk.getAffected(noMocks) : affected + + return specs.filter((spec) => { + const mocked = specMocks.get(spec) + if (!mocked) { + return affected.has(spec.moduleId) + } + return ( + affectedWithoutMocks.has(spec.moduleId) && + walk.reachesChange(spec.moduleId, mocked, affectedWithoutMocks) + ) + }) + } + + private dependsOnConfig(configDependencies: string[] | undefined): boolean { + return !!configDependencies?.some((file) => this.related.includes(file)) + } + + private filterLocalSourceFiles(files: (string | null | undefined)[]): string[] { + return files.filter((file): file is string => !!file && this.isLocalSourceFile(file)) + } + + private async getSpecificationEnvironmentFile(spec: TestSpecification): Promise { + if (spec.pool === 'browser') { + return null + } + const { environment } = await getSpecificationDocblock(spec) + if (environment.name === spec.project.config.environment) { + return null + } + const file = await this.resolveEnvironmentFile(spec.project, environment.name) + return file && this.isLocalSourceFile(file) ? file : null + } + + // mirrors how the environment is loaded in the worker + private async resolveEnvironmentFile(project: TestProject, name: string): Promise { + if (builtinEnvironments.has(name)) { + return null + } + const root = project.config.root + if (name[0] === '.' || isAbsolute(name)) { + return resolve(root, name) + } + const resolved = await project.vite.environments.__vitest__.pluginContainer + .resolveId(`vitest-environment-${name}`, undefined) + .catch(() => null) + return resolved ? cleanUrl(resolved.id) : resolve(root, name) + } + + // an existing file outside of node_modules, dependency changes are not tracked + isLocalSourceFile(filepath: string): boolean { + if (filepath.includes('node_modules')) { + return false + } + const cached = this.existsCache.get(filepath) + if (cached !== undefined) { + return cached + } + const exists = existsSync(filepath) + this.existsCache.set(filepath, exists) + return exists + } + + // limit concurrency to lower peak memory usage on large graphs + async withTransformLimit(fn: () => Promise): Promise { + if (this.activeTransforms >= this.transformConcurrency) { + await new Promise((resolve) => this.transformQueue.push(resolve)) + } + this.activeTransforms++ + try { + return await fn() + } finally { + this.activeTransforms-- + this.transformQueue.shift()?.() + } + } +} + +// files loaded into `import.meta.env`, mirrors Vite's `loadEnv` +function getEnvFiles(project: TestProject): string[] { + const { envDir, mode } = project.vite.config + if (typeof envDir !== 'string') { + return [] + } + return ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`].map((file) => + resolve(envDir, file), + ) +} + +/** + * Transforms every module of a project's Vite environment at most once, no matter how many walks reach it. + */ +class ProjectGraph { + private modules = new Map>() + + constructor( + private project: TestProject, + private environment: DevEnvironment, + private resolver: AffectedModulesResolver, + ) {} + + getModule(id: string): Promise { + let node = this.modules.get(id) + if (!node) { + node = this.loadModule(id) + this.modules.set(id, node) + } + return node + } + + /** + * Modules that are never loaded when `id` hoists its mocks, and modules that `id` loads even if they are mocked. + */ + async getMocks(id: string): Promise<{ mocked: Set; restored: Set }> { + const node = await this.getModule(id) + return { mocked: node?.mocked ?? new Set(), restored: node?.restored ?? new Set() } + } + + // a module that fails to load is treated as affected, so its tests run and report the error + private async loadModule(id: string): Promise { + try { + return await this.transformModule(id) + } catch { + return { + dependencies: [], + watchedFiles: [], + mocked: new Set(), + restored: new Set(), + failed: true, + } + } + } + + private async transformModule(id: string): Promise { + const mod = this.environment.moduleGraph.getModuleById(id) + const transformed = + mod?.transformResult || + (await this.resolver.withTransformLimit(() => + this.project._transformService.transform(id, this.environment), + )) + if (!transformed) { + return null + } + + const { replaced, redirects, restored } = await resolveStaticMocks( + this.environment, + this.project.config, + id, + getStaticMocks(this.environment, id, transformed), + ) + // `deps` only has the imports, the other modules were added by plugins with `addWatchFile` + const imports = new Set([...(transformed.deps || []), ...(transformed.dynamicDeps || [])]) + const dependencies: string[] = [] + const watchedFiles: string[] = [] + // ids keep the query, so `./file.txt?raw` is walked as its own module + this.environment.moduleGraph.getModuleById(id)?.importedModules.forEach((imported) => { + if (imported.id && imported.file && this.resolver.isLocalSourceFile(imported.file)) { + ;(imports.has(imported.url) ? dependencies : watchedFiles).push(imported.id) + } + }) + // `vi.importActual` loads the original without importing it + ;[...redirects, ...restored].forEach((file) => { + if (this.resolver.isLocalSourceFile(file) && !dependencies.includes(file)) { + dependencies.push(file) + } + }) + + return { + dependencies, + watchedFiles, + mocked: new Set(Array.from(replaced, (mockedId) => cleanUrl(mockedId))), + restored: new Set(Array.from(restored, (restoredId) => cleanUrl(restoredId))), + } + } +} + +/** + * Walks the module graph once for every root, skipping a module only when every walk that reaches it mocks it. + */ +class GraphWalk { + private dependencies = new Map() + private importers = new Map>() + // modules mocked by every root that reaches the module, so they are not walked from it + private mockedByAll = new Map>() + private changed: Set + + constructor( + private graph: ProjectGraph, + related: string[], + ) { + this.changed = new Set(related) + } + + async add(roots: string[], mocked: Set): Promise { + await Promise.all(roots.map((root) => this.addModule(root, mocked))) + } + + // modules that import a changed file through modules that are not in `mocked` + getAffected(mocked: Set): Set { + const affected = new Set() + for (const id of this.changed) { + if (!mocked.has(id)) { + affected.add(id) + } + } + const queue = [...affected] + while (queue.length) { + const importedBy = this.importers.get(queue.pop()!) + if (!importedBy) { + continue + } + for (const importer of importedBy) { + if (!affected.has(importer) && !mocked.has(importer)) { + affected.add(importer) + queue.push(importer) + } + } + } + return affected + } + + // whether `id` imports a changed file without passing through `mocked`, + // only modules in `affected` can lead to a change + reachesChange(id: string, mocked: Set, affected: Set): boolean { + const visited = new Set([id]) + const stack = [id] + while (stack.length) { + const current = stack.pop()! + if (this.changed.has(current)) { + return true + } + for (const dep of this.dependencies.get(current) ?? []) { + if (affected.has(dep) && !mocked.has(dep) && !visited.has(dep)) { + visited.add(dep) + stack.push(dep) + } + } + } + return false + } + + private async addModule(id: string, mocked: Set): Promise { + const previous = this.mockedByAll.get(id) + if (previous) { + if (isSubset(previous, mocked)) { + return + } + mocked = new Set([...previous].filter((mockedId) => mocked.has(mockedId))) + } + this.mockedByAll.set(id, mocked) + + const node = await this.graph.getModule(id) + if (!node) { + return + } + // a file imported with a query is a separate module + if (node.failed || this.changed.has(cleanUrl(id))) { + this.changed.add(id) + } + this.dependencies.set( + id, + node.watchedFiles.length ? [...node.dependencies, ...node.watchedFiles] : node.dependencies, + ) + node.watchedFiles.forEach((file) => this.addImporter(file, id)) + await Promise.all( + node.dependencies.map((dep) => { + this.addImporter(dep, id) + return mocked.has(dep) ? undefined : this.addModule(dep, mocked) + }), + ) + } + + private addImporter(id: string, importer: string): void { + let importedBy = this.importers.get(id) + if (!importedBy) { + this.importers.set(id, (importedBy = new Set())) + } + importedBy.add(importer) + } +} + +function isSubset(subset: Set, set: Set): boolean { + for (const item of subset) { + if (!set.has(item)) { + return false + } + } + return true +} diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 29b0ab65e..2d8c10a76 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -633,8 +633,6 @@ export function resolveTestConfig( '**/node_modules/**', ].filter((pattern) => typeof pattern === 'string') - resolved.forceRerunTriggers = [...resolved.forceRerunTriggers, ...resolved.setupFiles] - if (resolved.cliExclude) { resolved.exclude.push(...resolved.cliExclude) } @@ -684,7 +682,6 @@ export function resolveTestConfig( resolved.snapshotSerializers = resolved.snapshotSerializers.map((file) => resolvePath(file, resolved.root), ) - resolved.forceRerunTriggers.push(...resolved.snapshotSerializers) if (options.resolveSnapshotPath) { delete (resolved as any).resolveSnapshotPath @@ -719,7 +716,6 @@ export function resolveTestConfig( if (typeof resolved.diff === 'string') { resolved.diff = resolvePath(resolved.diff, resolved.root) - resolved.forceRerunTriggers.push(resolved.diff) } if (options.related) { diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 4088a8934..418c7b34a 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -1589,6 +1589,7 @@ export class Vitest { * Invalidate a file in all projects. */ public invalidateFile(filepath: string): void { + this.specifications.invalidateDocblock(filepath) this.projects.forEach(({ vite }) => { const environments = Object.values(vite.environments) diff --git a/packages/vitest/src/node/environments/staticMocks.ts b/packages/vitest/src/node/environments/staticMocks.ts new file mode 100644 index 000000000..0b7d53c07 --- /dev/null +++ b/packages/vitest/src/node/environments/staticMocks.ts @@ -0,0 +1,93 @@ +import type { StaticMockCall } from '@vitest/mocker/node' +import type { DevEnvironment, TransformResult } from 'vite' +import type { ResolvedConfig } from '../types/config' +import { findMockRedirect } from '@vitest/mocker/node' +import { cleanUrl } from '@vitest/utils/helpers' +import { isAbsolute } from 'pathe' + +export interface StaticMockedModules { + /** Resolved ids of modules that a mock replaces, so they are never loaded. */ + replaced: Set + /** Files in `__mocks__` that can be loaded instead of the original module. */ + redirects: string[] + /** Resolved ids of modules that are loaded even if they are mocked, for example, in a setup file. */ + restored: Set +} + +const restoringMethods = new Set(['unmock', 'doUnmock', 'importActual']) + +export function getStaticMocks( + environment: DevEnvironment, + id: string, + transformResult?: TransformResult | null, +): StaticMockCall[] | null | undefined { + return ( + transformResult?.__vitestStaticMocks ?? + environment.pluginContainer.getModuleInfo(id)?.meta?.vitestStaticMocks + ) +} + +/** + * Resolves the static `vi.mock` calls of `importer` and the calls that load the original module. + */ +export async function resolveStaticMocks( + environment: DevEnvironment, + config: ResolvedConfig, + importer: string, + mocks: StaticMockCall[] | null | undefined, +): Promise { + const result: StaticMockedModules = { replaced: new Set(), redirects: [], restored: new Set() } + if (!mocks?.length) { + return result + } + const resolvedIds = await Promise.all( + mocks.map(async (mock) => { + const resolved = await environment.pluginContainer + .resolveId(mock.specifier, importer) + .catch(() => null) + return resolved?.id + }), + ) + mocks.forEach((mock, index) => { + const id = resolvedIds[index] + if (id && restoringMethods.has(mock.method)) { + result.restored.add(id) + } + }) + mocks.forEach((mock, index) => { + if (mock.method !== 'mock') { + return + } + const id = resolvedIds[index] + const replaces = id != null && !result.restored.has(id) + if (mock.hasFactory) { + if (replaces && !mock.factoryLoadsOriginal) { + result.replaced.add(id) + } + return + } + // without a factory the mock is either redirected to `__mocks__` or + // generated from the original; options like `{ spy: true }` always use the original + const redirect = findRedirect(config, mock.specifier, id) + if (redirect) { + result.redirects.push(redirect) + if (replaces && mock.automock) { + result.replaced.add(id) + } + } + }) + return result +} + +// mirrors how the module mocker picks the `__mocks__` file +function findRedirect( + config: ResolvedConfig, + specifier: string, + resolvedId: string | undefined, +): string | null { + const id = resolvedId ?? specifier + const moduleDirectories = config.deps.moduleDirectories ?? ['/node_modules/'] + const external = + !isAbsolute(id) || moduleDirectories.some((dir) => id.includes(dir)) ? specifier : null + return findMockRedirect(config.root, cleanUrl(id), external) +} diff --git a/packages/vitest/src/node/environments/transformService.ts b/packages/vitest/src/node/environments/transformService.ts index 40c5096d4..e82ad41b5 100644 --- a/packages/vitest/src/node/environments/transformService.ts +++ b/packages/vitest/src/node/environments/transformService.ts @@ -33,6 +33,7 @@ import { hash } from '../hash' import { detectModuleType } from '../resolver' import { fsPathFromId } from '../vite' import { normalizeResolvedIdToUrl } from './normalizeUrl' +import { getStaticMocks } from './staticMocks' const debugFs = createDebugger('vitest:cache:fs') @@ -78,20 +79,23 @@ export class ModuleTransformService { /** * Transforms the module like `environment.transformRequest`, but reads and - * populates the `fsModuleCache` when it is enabled. + * populates the `fsModuleCache` when it is enabled. Returns `null` for externalized modules. */ async transform(id: string, environment: DevEnvironment): Promise { await this.traces.waitInit() const result = await this.traces.$('vitest.module.transform', (span) => this.fetchInSpan(span, id, undefined, environment), ) + // externalized modules are loaded by Node without a transform, like in a normal run + if ('externalize' in result) { + return null + } if ('id' in result) { const transformResult = environment.moduleGraph.getModuleById(result.id)?.transformResult if (transformResult) { return transformResult } } - // externalized modules are never transformed by `fetch` return environment.transformRequest(id) } @@ -462,8 +466,7 @@ export class ModuleTransformService { const transformResult = moduleGraphModule.transformResult if (transformResult && moduleGraphModule.id) { transformResult.__vitestStaticMocks ??= - environment.pluginContainer.getModuleInfo(moduleGraphModule.id)?.meta?.vitestStaticMocks ?? - null + getStaticMocks(environment, moduleGraphModule.id) ?? null } return result } diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 190a8bb88..200c99afb 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -166,17 +166,26 @@ export class Logger { printNoTestFound(filters?: string[]): void { const config = this.ctx.config - - if (config.watch && (config.changed || config.related?.length)) { - this.log(`No affected test files found\n`) + // test files were found, but none of them depend on the changed files + const noAffectedTests = !!this.ctx._sourceFilterResult?.total + const message = !noAffectedTests + ? 'No test files found' + : config.related?.length + ? 'No affected test files found' + : 'No changed files found' + + if (config.watch && noAffectedTests) { + this.log(`${message}\n`) } else if (config.watch) { - this.log(c.red(`No test files found. You can change the file name pattern by pressing "p"\n`)) + this.log(c.red(`${message}. You can change the file name pattern by pressing "p"\n`)) + } else if (config.passWithNoTests) { + this.log(`${message}, exiting with code 0\n`) } else { - if (config.passWithNoTests) { - this.log(`No test files found, exiting with code 0\n`) - } else { - this.error(c.red(`No test files found, exiting with code 1\n`)) - } + this.error(c.red(`${message}, exiting with code 1\n`)) + } + + if (noAffectedTests) { + return } const comma = c.dim(', ') diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index febcf521a..7f4390e60 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -1,9 +1,9 @@ -import type { StaticMockCall } from '@vitest/mocker/node' import type { DevEnvironment, EnvironmentModuleNode, FetchResult } from 'vite' import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' import type { RuntimeRPC } from '../../types/rpc' import type { OTELCarrier } from '../../utils/traces' +import type { StaticMockedModules } from '../environments/staticMocks' import type { TestProject } from '../project' import type { ResolveSnapshotPathHandlerContext } from '../types/config' import { existsSync, mkdirSync } from 'node:fs' @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url' import { cleanUrl } from '@vitest/utils/helpers' import { isBuiltin, toBuiltin } from '../../utils/modules' import { normalizeResolvedIdToUrl } from '../environments/normalizeUrl' +import { getStaticMocks, resolveStaticMocks } from '../environments/staticMocks' import { handleRollupError } from '../environments/transformService' interface MethodsOptions { @@ -172,39 +173,25 @@ export function createMethodsRPC( const environment = getEnvironment(environmentName) const moduleGraph = environment.moduleGraph - function getStaticMocks(node: EnvironmentModuleNode): StaticMockCall[] | null | undefined { - return ( - node.transformResult?.__vitestStaticMocks ?? - environment.pluginContainer.getModuleInfo(node.id!)?.meta?.vitestStaticMocks - ) + function getNodeMocks(node: EnvironmentModuleNode) { + return getStaticMocks(environment, node.id!, node.transformResult) } - // modules the root replaces with an inline factory are never requested - async function resolveMockedIds(root: EnvironmentModuleNode): Promise> { - const ids = new Set() - const mocks = getStaticMocks(root)?.filter( - (mock) => mock.method === 'mock' && mock.hasFactory && !mock.factoryLoadsOriginal, - ) - if (mocks?.length) { - await Promise.all( - mocks.map(async (mock) => { - const resolved = await environment.pluginContainer - .resolveId(mock.specifier, root.id ?? undefined) - .catch(() => null) - if (resolved) { - ids.add(resolved.id) - } - }), - ) + const resolvedMocks = new Map>() + function resolveNodeMocks(node: EnvironmentModuleNode): Promise { + let mocks = resolvedMocks.get(node.id!) + if (!mocks) { + mocks = resolveStaticMocks(environment, project.config, node.id!, getNodeMocks(node)) + resolvedMocks.set(node.id!, mocks) } - return ids + return mocks } // `import()` targets load on demand; a hoisted file's imports are all // rewritten to `import()`, so none of them count function getDynamicOnlyIds(node: EnvironmentModuleNode): Set | undefined { const result = node.transformResult - if (!result?.dynamicDeps?.length || getStaticMocks(node)) { + if (!result?.dynamicDeps?.length || getNodeMocks(node)) { return undefined } const staticDeps = new Set(result.deps) @@ -266,33 +253,48 @@ export function createMethodsRPC( } } - async function walkRoot(root: EnvironmentModuleNode): Promise { - const skip = await resolveMockedIds(root) - skip.add(root.id!) - await walkNode(root, skip) + async function walkRoot(root: EnvironmentModuleNode, setupMocks: Set): Promise { + const { replaced, redirects } = await resolveNodeMocks(root) + const skip = new Set([...setupMocks, ...replaced, root.id!]) + await Promise.all([ + walkNode(root, skip), + ...redirects.map(async (redirect) => { + const loaded = await load(redirect, root.id ?? undefined) + if (loaded && !skip.has(loaded.id!)) { + skip.add(loaded.id!) + await walkNode(loaded, skip) + } + }), + ]) } - async function loadRoot(url: string): Promise { - const root = await load(url, undefined) - if (root) { - await walkRoot(root) + async function loadRoots(file: string): Promise { + const nodes = moduleGraph.getModulesByFile(file) + if (nodes?.size) { + return Promise.all( + Array.from(nodes, async (node) => + node.transformResult ? node : load(node.url, undefined), + ), + ).then((roots) => roots.filter((root) => root != null)) } + const root = await load(file, undefined) + return root ? [root] : [] } - await Promise.all( - [...files, ...project.config.setupFiles].map(async (file) => { - const nodes = moduleGraph.getModulesByFile(file) - if (nodes?.size) { - await Promise.all( - Array.from(nodes, (node) => - node.transformResult ? walkRoot(node) : loadRoot(node.url), - ), - ) - } else { - await loadRoot(file) - } + const setupRoots = await Promise.all(project.config.setupFiles.map(loadRoots)) + // mocks from setup files apply to every test file of the project + const setupMocks = new Set() + for (const root of setupRoots.flat()) { + ;(await resolveNodeMocks(root)).replaced.forEach((id) => setupMocks.add(id)) + } + + await Promise.all([ + ...setupRoots.flat().map((root) => walkRoot(root, new Set())), + ...files.map(async (file) => { + const roots = await loadRoots(file) + await Promise.all(roots.map((root) => walkRoot(root, setupMocks))) }), - ) + ]) }, async resolve(id, importer, environmentName) { const environment = project.vite.environments[environmentName] diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index e6a9bdaaf..c047cdcce 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -351,14 +351,17 @@ export class TestProject { return true } const relativeId = relative(this.config.dir || this.config.root, moduleId) - if (pm.isMatch(relativeId, this.config.exclude)) { + if (pm.isMatch(relativeId, this.config.exclude, { dot: true })) { return false } - if (pm.isMatch(relativeId, this.config.include)) { + if (pm.isMatch(relativeId, this.config.include, { dot: true })) { this.markTestFile(moduleId) return true } - if (this.config.includeSource?.length && pm.isMatch(relativeId, this.config.includeSource)) { + if ( + this.config.includeSource?.length && + pm.isMatch(relativeId, this.config.includeSource, { dot: true }) + ) { const code = source?.() || readFileSync(moduleId, 'utf-8') if (isInSourceTestCode(code)) { this.markTestFile(moduleId) diff --git a/packages/vitest/src/node/specifications.ts b/packages/vitest/src/node/specifications.ts index 16d25e7cc..701521cf4 100644 --- a/packages/vitest/src/node/specifications.ts +++ b/packages/vitest/src/node/specifications.ts @@ -1,11 +1,8 @@ import type { Vitest } from './core' -import type { TestProject } from './project' import type { TestSpecification } from './test-specification' -import { existsSync } from 'node:fs' -import os from 'node:os' -import { join, relative, resolve } from 'pathe' +import { relative, resolve } from 'pathe' import pm from 'picomatch' -import { isWindows } from '../utils/env' +import { AffectedModulesResolver } from './affected-modules' import { groupFilters, parseFilter } from './cli/filter' import { IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors' @@ -99,6 +96,12 @@ export class VitestSpecifications { } } + public invalidateDocblock(moduleId: string): void { + this._cachedSpecs.get(moduleId)?.forEach((spec) => { + spec._docblock = undefined + }) + } + private getCachedSpecifications(moduleId: string): TestSpecification[] | undefined { return this._cachedSpecs.get(moduleId) } @@ -133,7 +136,7 @@ export class VitestSpecifications { } const forceRerunTriggers = this.vitest.config.forceRerunTriggers - const matcher = forceRerunTriggers.length ? pm(forceRerunTriggers) : undefined + const matcher = forceRerunTriggers.length ? pm(forceRerunTriggers, { dot: true }) : undefined if (matcher && related.some((file) => matcher(file))) { return specs } @@ -141,125 +144,12 @@ export class VitestSpecifications { // don't run anything if no related sources are found // if we are in watch mode, we want to process all tests if (!this.vitest.config.watch && !related.length) { + this.vitest._sourceFilterResult = { affected: 0, total: specs.length } return [] } - // The module graph, and so the dependency edges, are per project. - const specsByProject = new Map() - for (const spec of specs) { - let projectSpecs = specsByProject.get(spec.project) - if (!projectSpecs) { - specsByProject.set(spec.project, (projectSpecs = [])) - } - projectSpecs.push(spec) - } - - const affectedByProject = new Map>() - for (const [project, projectSpecs] of specsByProject) { - affectedByProject.set(project, await this.getAffectedModules(project, projectSpecs, related)) - } - - const affectedSpecs = specs.filter((spec) => - affectedByProject.get(spec.project)!.has(spec.moduleId), - ) + const affectedSpecs = await new AffectedModulesResolver(this.vitest, related).resolve(specs) this.vitest._sourceFilterResult = { affected: affectedSpecs.length, total: specs.length } return affectedSpecs } - - /** - * Returns every module in `project` that transitively imports one of `related`. - * - * Expands each module's imports at most once into a shared reverse-edge map, - * then walks that map backwards from the changed files. - */ - private async getAffectedModules( - project: TestProject, - specs: TestSpecification[], - related: string[], - ): Promise> { - const importers = new Map>() - const visited = new Set() - const existsCache = new Map() - - // limit concurrency to lower peak memory usage on large graphs - const TRANSFORM_CONCURRENCY = os.availableParallelism?.() ?? os.cpus().length - let active = 0 - const waiters: Array<() => void> = [] - const withLimit = async (fn: () => Promise): Promise => { - if (active >= TRANSFORM_CONCURRENCY) { - await new Promise((resolve) => waiters.push(resolve)) - } - active++ - try { - return await fn() - } finally { - active-- - waiters.shift()?.() - } - } - - const cachedExists = (filepath: string): boolean => { - const cached = existsCache.get(filepath) - if (cached !== undefined) { - return cached - } - const result = existsSync(filepath) - existsCache.set(filepath, result) - return result - } - - const addImports = async (filepath: string) => { - // `visited` is shared by every spec in the project, so a module is - // expanded once per run instead of once per test file that reaches it. - if (visited.has(filepath)) { - return - } - visited.add(filepath) - - const environment = project.vite.environments.ssr - const mod = environment.moduleGraph.getModuleById(filepath) - const transformed = - mod?.transformResult || - (await withLimit(() => project._transformService.transform(filepath, environment))) - if (!transformed) { - return - } - const dependencies = [...(transformed.deps || []), ...(transformed.dynamicDeps || [])] - await Promise.all( - dependencies.map(async (dep) => { - const fsPath = dep.startsWith('/@fs/') - ? dep.slice(isWindows ? 5 : 4) - : join(project.config.root, dep) - if (fsPath.includes('node_modules') || !cachedExists(fsPath)) { - return - } - let importedBy = importers.get(fsPath) - if (!importedBy) { - importers.set(fsPath, (importedBy = new Set())) - } - importedBy.add(filepath) - await addImports(fsPath) - }), - ) - } - - await Promise.all(specs.map((spec) => addImports(spec.moduleId))) - - const affected = new Set(related) - const queue = [...related] - while (queue.length) { - const importedBy = importers.get(queue.pop()!) - if (!importedBy) { - continue - } - for (const importer of importedBy) { - if (!affected.has(importer)) { - affected.add(importer) - queue.push(importer) - } - } - } - - return affected - } } diff --git a/packages/vitest/src/node/test-specification.ts b/packages/vitest/src/node/test-specification.ts index 7408b96b0..9dc9b6021 100644 --- a/packages/vitest/src/node/test-specification.ts +++ b/packages/vitest/src/node/test-specification.ts @@ -1,4 +1,5 @@ import type { SerializedTestSpecification } from '../runtime/types/utils' +import type { ContextTestEnvironment } from '../types/worker' import type { TestProject } from './project' import type { TestModule } from './reporters/reported-tasks' import type { Pool } from './types/config' @@ -12,6 +13,12 @@ export interface TestSpecificationOptions { testTagsFilter?: string[] } +/** @internal */ +export interface SpecificationDocblock { + environment: ContextTestEnvironment + tags: string[] +} + export class TestSpecification { /** * The task id associated with the test module. @@ -46,6 +53,9 @@ export class TestSpecification { */ public testTagsFilter: string[] | undefined + /** @internal */ + public _docblock: SpecificationDocblock | undefined + /** * This class represents a test suite for a test module within a single project. * @internal diff --git a/packages/vitest/src/node/watcher.ts b/packages/vitest/src/node/watcher.ts index 24f9febe2..408751569 100644 --- a/packages/vitest/src/node/watcher.ts +++ b/packages/vitest/src/node/watcher.ts @@ -41,6 +41,10 @@ export class VitestWatcher { if (this.vitest.config.forceRerunTriggers.length) { watcher.add(this.vitest.config.forceRerunTriggers) } + const projectFiles = this.vitest.projects.flatMap(getProjectWatchedFiles) + if (projectFiles.length) { + watcher.add(projectFiles) + } watcher.on('change', this.onFileChange) watcher.on('unlink', this.onFileDelete) @@ -141,25 +145,25 @@ export class VitestWatcher { } } - private handleSetupFile(filepath: string) { - let isSetupFile: boolean = false + private handleProjectFile(filepath: string) { + let isProjectFile: boolean = false this.vitest.projects.forEach((project) => { - if (!project.config.setupFiles.includes(filepath)) { + if (!getProjectWatchedFiles(project).includes(filepath)) { return } this.vitest.state.filesMap.forEach((files) => { files.forEach((file) => { if (file.projectName === project.name) { - isSetupFile = true + isProjectFile = true this.changedTests.add(file.filepath) } }) }) }) - return isSetupFile + return isProjectFile } /** @@ -170,12 +174,12 @@ export class VitestWatcher { return false } - if (pm.isMatch(filepath, this.vitest.config.forceRerunTriggers)) { + if (pm.isMatch(filepath, this.vitest.config.forceRerunTriggers, { dot: true })) { this.vitest.state.getFilepaths().forEach((file) => this.changedTests.add(file)) return true } - if (this.handleSetupFile(filepath)) { + if (this.handleProjectFile(filepath)) { return true } @@ -241,6 +245,16 @@ export class VitestWatcher { } } +// files loaded outside of the test module graph that affect every test in the project +function getProjectWatchedFiles(project: TestProject): string[] { + const { setupFiles, snapshotSerializers, diff } = project.config + const files = [...setupFiles, ...snapshotSerializers] + if (typeof diff === 'string') { + files.push(diff) + } + return files +} + export interface WatcherTriggerPattern { pattern: RegExp testsToRun: (file: string, match: RegExpMatchArray) => string[] | string | null | undefined | void diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index c5737758d..07a026f16 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -1,4 +1,4 @@ -import type { TestSpecification } from '../node/test-specification' +import type { SpecificationDocblock, TestSpecification } from '../node/test-specification' import type { EnvironmentOptions, VitestEnvironment } from '../node/types/config' import type { ContextTestEnvironment } from '../types/worker' import { promises as fs } from 'node:fs' @@ -8,41 +8,55 @@ export async function getSpecificationsOptions(specifications: Array }> { const environments = new WeakMap() - const cache = new Map() const tags = new WeakMap() + // reuse if projects have the same test files + const files = new Map>() await Promise.all( specifications.map(async (spec) => { - const { moduleId: filepath, project, pool } = spec // browser pool handles its own environment - if (pool === 'browser') { + if (spec.pool === 'browser') { return } - - // reuse if projects have the same test files - let code = cache.get(filepath) - if (!code) { - code = await fs.readFile(filepath, 'utf-8').catch(() => '') - cache.set(filepath, code) - } - - const { - env = project.config.environment || 'node', - envOptions, - tags: specTags = [], - } = detectCodeBlock(code) - tags.set(spec, specTags) - - const envKey = env === 'happy-dom' ? 'happyDOM' : env - const environment: ContextTestEnvironment = { - name: env as VitestEnvironment, - options: envOptions ? ({ [envKey]: envOptions } as EnvironmentOptions) : null, - } - environments.set(spec, environment) + const docblock = await getSpecificationDocblock(spec, files) + tags.set(spec, docblock.tags) + environments.set(spec, docblock.environment) }), ) return { environments, tags } } +export async function getSpecificationDocblock( + spec: TestSpecification, + files?: Map>, +): Promise { + if (spec._docblock) { + return spec._docblock + } + + const filepath = spec.moduleId + let code = files?.get(filepath) + if (!code) { + code = fs.readFile(filepath, 'utf-8').catch(() => '') + files?.set(filepath, code) + } + + const { + env = spec.project.config.environment || 'node', + envOptions, + tags = [], + } = detectCodeBlock(await code) + + const envKey = env === 'happy-dom' ? 'happyDOM' : env + spec._docblock = { + environment: { + name: env as VitestEnvironment, + options: envOptions ? ({ [envKey]: envOptions } as EnvironmentOptions) : null, + }, + tags, + } + return spec._docblock +} + export function detectCodeBlock(content: string): { env?: string envOptions?: Record diff --git a/test/e2e/test/git-changed.test.ts b/test/e2e/test/git-changed.test.ts index 06c63e9a4..3035071f3 100644 --- a/test/e2e/test/git-changed.test.ts +++ b/test/e2e/test/git-changed.test.ts @@ -30,7 +30,7 @@ describe.skipIf(process.env.ECOSYSTEM_CI)('forceRerunTrigger', () => { it('should run no tests if file does not exist', async () => { const { stdout } = await run() - expect(stdout).toContain('No test files found, exiting with code 0') + expect(stdout).toMatch(/No (changed|affected test) files found, exiting with code 0/) }) }) @@ -57,7 +57,7 @@ it.skipIf(process.env.ECOSYSTEM_CI)( root: './fixtures/git-changed/workspace', }) - expect(stdout).toContain('No test files found, exiting with code 0') + expect(stdout).toMatch(/No (changed|affected test) files found, exiting with code 0/) }, ) diff --git a/test/e2e/test/related-deps.test.ts b/test/e2e/test/related-deps.test.ts new file mode 100644 index 000000000..74a20e9a7 --- /dev/null +++ b/test/e2e/test/related-deps.test.ts @@ -0,0 +1,1696 @@ +import type { TestModule } from 'vitest/node' +import { resolve } from 'pathe' +import { describe, expect, onTestFinished, test } from 'vitest' +import { createVitest } from 'vitest/node' +import { runInlineTests, runVitest, useFS } from '#test-utils' + +function testFile(name: string, imports = '') { + return ` + ${imports} + import { test } from 'vitest' + test('${name}', () => {}) + ` +} + +// written this way so the comment is not picked up from this file +function environmentComment(name: string) { + return `// @vitest-${'environment'} ${name}` +} + +const customEnvironment = ` +export default { + name: 'custom', + viteEnvironment: 'ssr', + setup() { + return { teardown() {} } + }, +} +` + +test('a file imported by a setup file runs every test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { setupFiles: ['./setup.js'] } }, + 'setup.js': `import './src/helper.js'`, + 'src/helper.js': 'export {}', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }, + { related: ['src/helper.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) +}) + +test('a setup file of a project runs only the tests of that project', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { + test: { + projects: [ + { test: { name: 'first', include: ['first/*.test.js'], setupFiles: ['./setup.js'] } }, + { test: { name: 'second', include: ['second/*.test.js'] } }, + ], + }, + }, + 'setup.js': 'export {}', + 'first/a.test.js': testFile('a'), + 'second/b.test.js': testFile('b'), + }, + { related: ['setup.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "first/a.test.js": { + "a": "passed", + }, + } + `) +}) + +test('a file imported by a global setup runs every test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { globalSetup: ['./global-setup.js'] } }, + 'global-setup.js': ` + import './src/helper.js' + export default () => {} + `, + 'src/helper.js': 'export {}', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }, + { related: ['src/helper.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) +}) + +test('a custom runner runs every test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { runner: './runner.js' } }, + 'runner.js': `export { TestRunner as default } from 'vitest'`, + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }, + { related: ['runner.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) +}) + +test('a custom environment of a project runs every test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { environment: './env.js' } }, + 'env.js': customEnvironment, + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }, + { related: ['env.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) +}) + +test('an environment from a comment runs only the test that uses it', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': ` + import { fileURLToPath } from 'node:url' + export default { + resolve: { + alias: { + 'vitest-environment-custom': fileURLToPath(new URL('./env.js', import.meta.url)), + }, + }, + } + `, + 'env.js': customEnvironment, + 'a.test.js': testFile('a', environmentComment('custom')), + 'b.test.js': testFile('b'), + }, + { related: ['env.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) +}) + +test('a file imported by the config runs every test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': ` + import { shared } from './vitest.shared.js' + export default { test: shared } + `, + 'vitest.shared.js': 'export const shared = {}', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }, + { related: ['vitest.shared.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) +}) + +test('a file imported by a project config runs only the tests of that project', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { projects: ['./first', './second'] } }, + 'first/vitest.config.js': ` + import { shared } from './shared.js' + export default { test: { name: 'first', ...shared } } + `, + 'first/shared.js': 'export const shared = {}', + 'first/a.test.js': testFile('a'), + 'second/vitest.config.js': { test: { name: 'second' } }, + 'second/b.test.js': testFile('b'), + }, + { related: ['first/shared.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) +}) + +test.each([ + ['raw', 'src/data.txt', `import './src/data.txt?raw'`], + ['url', 'src/dep.js', `import './src/dep.js?url'`], + ['inline css', 'src/style.css', `import './src/style.css?inline'`], + ['raw through a module', 'src/data.txt', `import './src/loader.js'`], +])( + 'a file imported with a query (%s) runs the test that imports it', + async (_, changed, imports) => { + const { stderr, testTree } = await runInlineTests( + { + 'src/data.txt': 'hello', + 'src/dep.js': 'export {}', + 'src/style.css': '.a {}', + 'src/loader.js': `import './data.txt?raw'`, + 'a.test.js': testFile('a', imports), + 'b.test.js': testFile('b'), + }, + { related: [changed] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }, +) + +test.each([ + ['a dynamic import', `test('a', async () => { await import('./src/dep.js') })`], + ['import.meta.glob', `import.meta.glob('./src/*.js', { eager: true })\ntest('a', () => {})`], +])('a module loaded with %s runs the test that loads it', async (_, body) => { + const { stderr, testTree } = await runInlineTests( + { + 'src/dep.js': `import './nested/nested.js'`, + 'src/nested/nested.js': 'export {}', + 'a.test.js': ` + import { test } from 'vitest' + ${body} + `, + 'b.test.js': testFile('b'), + }, + { related: ['src/nested/nested.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) +}) + +describe('externalized modules', () => { + const files = { + 'vitest.config.js': ` + export default { + test: { + server: { deps: { external: [/\\.wasm$/, /\\/src\\/external\\//] } }, + }, + } + `, + 'src/empty.wasm': '\0asm\x01\0\0\0', + 'src/external/index.js': `import '../nested.js'\nexport {}`, + 'src/nested.js': 'export {}', + 'src/other.js': 'export {}', + // the wasm module is never loaded, so the test does not need wasm support + 'a.test.js': ` + import { test } from 'vitest' + test('a', async () => { + if (globalThis.__never) await import('./src/empty.wasm') + }) + `, + 'b.test.js': testFile('b', `import './src/external/index.js'`), + 'c.test.js': testFile('c', `import './src/other.js'`), + } + + test('an unrelated change does not run the tests that import an externalized module', async () => { + const { stderr, testTree } = await runInlineTests(files, { related: ['src/other.js'] }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "c.test.js": { + "c": "passed", + }, + } + `) + }) + + test('a change to an externalized module runs the tests that import it', async () => { + const { stderr, testTree } = await runInlineTests(files, { + related: ['src/empty.wasm', 'src/external/index.js'], + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }) + + // like in a normal run, an externalized module is not transformed, so its imports are unknown + test('the imports of an externalized module are not followed', async () => { + const { testTree } = await runInlineTests(files, { related: ['src/nested.js'] }) + + expect(testTree()).toMatchInlineSnapshot(`{}`) + }) +}) + +describe('files added with addWatchFile', () => { + // the plugin builds src/template.js from src/template.html + const templatePlugin = (hook: 'load' | 'transform', test = {}) => ` + import { readFileSync } from 'node:fs' + export default { + plugins: [{ + name: 'template', + ${hook}(${hook === 'load' ? 'id' : '_code, id'}) { + if (id.endsWith('/src/template.js')) { + const html = id.replace(/\\.js$/, '.html') + this.addWatchFile(html) + return \`export default \${JSON.stringify(readFileSync(html, 'utf-8'))}\` + } + }, + }], + test: ${JSON.stringify(test)}, + } + ` + const files = { + 'src/template.html': '
', + 'src/template.js': 'export default ""', + 'src/wrapper.js': `import './template.js'`, + 'src/other.js': 'export {}', + 'a.test.js': testFile('a', `import './src/template.js'`), + 'b.test.js': testFile('b', `import './src/wrapper.js'`), + 'c.test.js': testFile('c', `import './src/other.js'`), + } + + test.each(['load', 'transform'] as const)( + 'a file added in %s runs the tests that import the module', + async (hook) => { + const { stderr, testTree } = await runInlineTests( + { ...files, 'vitest.config.js': templatePlugin(hook) }, + { related: ['src/template.html'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }, + ) + + // the added file is not a module, so it must not be transformed and fail + test.each(['load', 'transform'] as const)( + 'an unrelated change does not run the tests that import a module with a file added in %s', + async (hook) => { + const { stderr, testTree } = await runInlineTests( + { ...files, 'vitest.config.js': templatePlugin(hook) }, + { related: ['src/other.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "c.test.js": { + "c": "passed", + }, + } + `) + }, + ) + + test('a file added in a plugin is followed for modules served from the fs module cache', async () => { + const config = templatePlugin('transform', { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }) + const cold = await runInlineTests({ ...files, 'vitest.config.js': config }) + expect(cold.stderr).toBe('') + await cold.ctx?.close() + + const { stderr, testTree } = await runVitest({ + root: cold.root, + related: ['src/template.html'], + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }) +}) + +test('imports are followed for modules served from the fs module cache', async () => { + const cold = await runInlineTests({ + 'vitest.config.js': { + test: { fsModuleCache: true, fsModuleCachePath: './node_modules/.vitest-fs-cache' }, + }, + 'src/data.txt': 'hello', + 'src/shared.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': testFile('a', `import './src/data.txt?raw'`), + 'b.test.js': testFile('b', `import './src/shared.js'`), + 'c.test.js': testFile('c'), + }) + expect(cold.stderr).toBe('') + await cold.ctx?.close() + + const { stderr, testTree } = await runVitest({ + root: cold.root, + related: ['src/data.txt', 'src/nested.js'], + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) +}) + +test('a manual mock from __mocks__ runs only the test that mocks it', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'src/dep.js': 'export const value = 1', + 'src/__mocks__/dep.js': 'export const value = 2', + 'a.test.js': ` + import { test, vi } from 'vitest' + import './src/dep.js' + + vi.mock('./src/dep.js') + + test('a', () => {}) + `, + 'b.test.js': testFile('b', `import './src/dep.js'`), + }, + { related: ['src/__mocks__/dep.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) +}) + +test('the environment comment is read when filtering and reused by the run', async () => { + const root = `${process.cwd()}/vitest-test-${crypto.randomUUID()}` + useFS(root, { + 'a.test.js': ` + ${environmentComment('node')} + import { test } from 'vitest' + import './src/helper.js' + + test('a', () => {}) + `, + 'src/helper.js': 'export {}', + }) + const vitest = await createVitest({ + root, + watch: false, + config: false, + related: ['src/helper.js'], + }) + onTestFinished(() => vitest.close()) + + const [unfiltered] = await vitest.globTestSpecifications() + expect(unfiltered._docblock).toBeUndefined() + + const [filtered] = await vitest.getRelevantTestSpecifications() + expect(filtered._docblock).toMatchInlineSnapshot(` + { + "environment": { + "name": "node", + "options": null, + }, + "tags": [], + } + `) +}) + +test('a force rerun trigger inside a dot folder runs every test', async () => { + const root = resolve(process.cwd(), `.vitest-test-${crypto.randomUUID()}`) + useFS(root, { + 'vitest.config.js': { test: { forceRerunTriggers: ['**/trigger.js'] } }, + 'trigger.js': 'export {}', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }) + const { stderr, testTree } = await runVitest({ root, related: ['trigger.js'] }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) +}) + +test.each([ + ['jsdom', 'comment'], + ['happy-dom', 'comment'], + ['jsdom', 'config'], + ['node', 'comment'], + ['edge-runtime', 'comment'], +])('a test in %s set by a %s is walked in the environment that runs it', async (name, source) => { + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) + useFS(root, { + 'src/dep.js': 'export {}', + 'a.test.js': testFile( + 'a', + `${source === 'comment' ? environmentComment(name) : ''}\nimport './src/dep.js'`, + ), + }) + const vitest = await createVitest({ + root, + watch: false, + config: false, + related: ['src/dep.js'], + ...(source === 'config' ? { environment: name } : {}), + }) + onTestFinished(() => vitest.close()) + + const specifications = await vitest.getRelevantTestSpecifications() + const { environments } = vitest.getRootProject().vite + const transformed = (environment: string) => + ['a.test.js', 'src/dep.js'].filter( + (file) => + environments[environment].moduleGraph.getModuleById(resolve(root, file))?.transformResult, + ) + expect({ + affected: specifications.length, + client: transformed('client'), + ssr: transformed('ssr'), + }).toEqual({ + affected: 1, + ...(name === 'jsdom' || name === 'happy-dom' + ? { client: ['a.test.js', 'src/dep.js'], ssr: [] } + : { client: [], ssr: ['a.test.js', 'src/dep.js'] }), + }) +}) + +describe('snapshot files', () => { + const snapshotTest = (name: string) => ` + import { expect, test } from 'vitest' + test('${name}', () => { expect('${name}').toMatchSnapshot() }) + ` + const snapshot = (name: string) => + `// Vitest Snapshot v1\n\nexports[\`${name} 1\`] = \`"${name}"\`;\n` + + test('a changed snapshot runs the test that owns it', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'a.test.js': snapshotTest('a'), + 'b.test.js': snapshotTest('b'), + '__snapshots__/a.test.js.snap': snapshot('a'), + '__snapshots__/b.test.js.snap': snapshot('b'), + }, + { related: ['__snapshots__/a.test.js.snap'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test('a snapshot from resolveSnapshotPath runs the test that owns it', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': ` + import { basename, join } from 'node:path' + export default { + test: { + resolveSnapshotPath: (testPath, extension) => + join(import.meta.dirname, 'snapshots', basename(testPath) + extension), + }, + } + `, + 'a.test.js': snapshotTest('a'), + 'b.test.js': snapshotTest('b'), + 'snapshots/a.test.js.snap': snapshot('a'), + 'snapshots/b.test.js.snap': snapshot('b'), + }, + { related: ['snapshots/b.test.js.snap'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "b.test.js": { + "b": "passed", + }, + } + `) + }) +}) + +describe('env files', () => { + test.each(['.env', '.env.local', '.env.test', '.env.test.local'])( + 'a changed %s runs every test', + async (envFile) => { + const { stderr, testTree } = await runInlineTests( + { + [envFile]: 'VITE_VALUE=1', + 'a.test.js': ` + import { expect, test } from 'vitest' + test('a', () => { expect(import.meta.env.VITE_VALUE).toBe('1') }) + `, + 'b.test.js': testFile('b'), + }, + { related: [envFile] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }, + ) + + test('an env file runs only the tests of the project that loads it', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { projects: ['./first', './second'] } }, + 'first/vitest.config.js': { test: { name: 'first' } }, + 'first/.env': 'VITE_VALUE=1', + 'first/a.test.js': testFile('a'), + 'second/vitest.config.js': { test: { name: 'second' } }, + 'second/b.test.js': testFile('b'), + }, + { related: ['first/.env'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test('an env file from envDir runs every test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { envDir: './env' }, + 'env/.env': 'VITE_VALUE=1', + '.env': 'VITE_VALUE=2', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + 'src/other.js': 'export {}', + }, + { related: ['env/.env'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('an env file outside of envDir is ignored', async () => { + const { testTree } = await runInlineTests( + { + 'vitest.config.js': { envDir: './env' }, + 'env/.env': 'VITE_VALUE=1', + '.env': 'VITE_VALUE=2', + 'a.test.js': testFile('a'), + }, + { related: ['.env'] }, + ) + + expect(testTree()).toMatchInlineSnapshot(`{}`) + }) +}) + +test('global setup and environments are walked in the environment that loads them', async () => { + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) + useFS(root, { + 'global-setup.js': `import './src/global-helper.js'\nexport default () => {}`, + 'env.js': `import './src/env-helper.js'\n${customEnvironment}`, + 'setup.js': `import './src/setup-helper.js'`, + 'src/global-helper.js': 'export {}', + 'src/env-helper.js': 'export {}', + 'src/setup-helper.js': 'export {}', + 'src/dep.js': 'export {}', + 'a.test.js': testFile('a', `import './src/dep.js'`), + }) + const vitest = await createVitest({ + root, + watch: false, + config: false, + related: ['src/dep.js'], + globalSetup: ['./global-setup.js'], + environment: './env.js', + setupFiles: ['./setup.js'], + }) + onTestFinished(() => vitest.close()) + + const specifications = await vitest.getRelevantTestSpecifications() + const { environments } = vitest.getRootProject().vite + const transformed = (environment: string) => + [...environments[environment].moduleGraph.idToModuleMap.values()] + .filter((mod) => mod.transformResult && mod.id?.startsWith(`${root}/`)) + .map((mod) => mod.id!.slice(root.length + 1)) + .sort() + expect({ + affected: specifications.length, + __vitest__: transformed('__vitest__'), + ssr: transformed('ssr'), + }).toMatchInlineSnapshot(` + { + "__vitest__": [ + "env.js", + "global-setup.js", + "src/env-helper.js", + "src/global-helper.js", + ], + "affected": 1, + "ssr": [ + "a.test.js", + "setup.js", + "src/dep.js", + "src/setup-helper.js", + ], + } + `) +}) + +test('mocks are applied to tests that run in the client environment', async () => { + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) + useFS(root, { + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': ` + ${environmentComment('jsdom')} + import { test, vi } from 'vitest' + import './src/dep.js' + + vi.mock('./src/dep.js', () => ({})) + + test('a', () => {}) + `, + 'b.test.js': testFile('b', `${environmentComment('jsdom')}\nimport './src/dep.js'`), + 'c.test.js': testFile('c', `import './src/dep.js'`), + }) + const vitest = await createVitest({ + root, + watch: false, + config: false, + related: ['src/nested.js'], + }) + onTestFinished(() => vitest.close()) + + const specifications = await vitest.getRelevantTestSpecifications() + expect(specifications.map((spec) => spec.moduleId.slice(root.length + 1)).sort()).toEqual([ + 'b.test.js', + 'c.test.js', + ]) +}) + +describe('mocked modules', () => { + // records the files transformed by the project, relative to the root + const trackTransforms = (config = '{}') => ` + export default { + plugins: [{ + name: 'track-transforms', + transform(_code, id) { + const root = this.environment.config.root + '/' + if (id.startsWith(root)) { + globalThis.__transformed?.push(id.slice(root.length)) + } + }, + }], + test: ${config}, + } + ` + + function useTransformed() { + const transformed: string[] = [] + ;(globalThis as any).__transformed = transformed + onTestFinished(() => { + delete (globalThis as any).__transformed + }) + return transformed + } + + const mockedTest = (mock: string) => ` + import { test, vi } from 'vitest' + import './src/dep.js' + + ${mock} + + test('a', () => {}) + ` + + test('a module mocked with a factory is not transformed', async () => { + const transformed = useTransformed() + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': trackTransforms(), + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'src/other.js': 'export {}', + 'a.test.js': mockedTest(`vi.mock('./src/dep.js', () => ({}))`), + 'b.test.js': testFile('b', `import './src/other.js'`), + }, + { related: ['src/other.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "b.test.js": { + "b": "passed", + }, + } + `) + expect(transformed.sort()).toMatchInlineSnapshot(` + [ + "a.test.js", + "b.test.js", + "src/other.js", + ] + `) + }) + + test('changing a module mocked with a factory does not run the test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': trackTransforms(), + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': mockedTest(`vi.mock('./src/dep.js', () => ({}))`), + 'b.test.js': testFile('b', `import './src/dep.js'`), + }, + { related: ['src/nested.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('a module redirected to __mocks__ is not transformed', async () => { + const transformed = useTransformed() + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': trackTransforms(), + 'src/dep.js': 'export {}', + 'src/__mocks__/dep.js': 'export {}', + 'src/other.js': 'export {}', + 'a.test.js': mockedTest(`vi.mock('./src/dep.js')`), + 'b.test.js': testFile('b', `import './src/other.js'`), + }, + { related: ['src/dep.js', 'src/other.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "b.test.js": { + "b": "passed", + }, + } + `) + expect(transformed.sort()).toMatchInlineSnapshot(` + [ + "a.test.js", + "b.test.js", + "src/__mocks__/dep.js", + "src/other.js", + ] + `) + }) + + test.each([ + [ + 'a factory with importOriginal', + `vi.mock('./src/dep.js', async (importOriginal) => importOriginal())`, + ], + ['an automock', `vi.mock('./src/dep.js')`], + ['a spy', `vi.mock('./src/dep.js', { spy: true })`], + ])('a module mocked with %s still runs the test', async (_, mock) => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': trackTransforms(), + 'src/dep.js': 'export {}', + 'a.test.js': mockedTest(mock), + }, + { related: ['src/dep.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test('a module mocked in a setup file is not transformed for any test', async () => { + const transformed = useTransformed() + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': trackTransforms(`{ setupFiles: ['./setup.js'] }`), + 'setup.js': ` + import { vi } from 'vitest' + vi.mock('./src/dep.js', () => ({})) + `, + 'src/dep.js': 'export {}', + 'src/other.js': 'export {}', + 'a.test.js': testFile('a', `import './src/dep.js'`), + 'b.test.js': ` + import { test } from 'vitest' + import './src/dep.js' + import './src/other.js' + + test('b', () => {}) + `, + }, + { related: ['src/dep.js', 'src/other.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "b.test.js": { + "b": "passed", + }, + } + `) + expect(transformed.sort()).toMatchInlineSnapshot(` + [ + "a.test.js", + "b.test.js", + "setup.js", + "src/other.js", + ] + `) + }) + + test('a test with its own mocks still runs when a shared module is affected', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'src/dep.js': 'export {}', + 'src/shared.js': `import './changed.js'`, + 'src/changed.js': 'export {}', + 'a.test.js': ` + import { test, vi } from 'vitest' + import './src/dep.js' + import './src/shared.js' + + vi.mock('./src/dep.js', () => ({})) + + test('a', () => {}) + `, + 'b.test.js': testFile('b', `import './src/shared.js'`), + }, + { related: ['src/changed.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('a module mocked in one test is still transformed for a test that imports it', async () => { + const transformed = useTransformed() + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': trackTransforms(), + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': mockedTest(`vi.mock('./src/dep.js', () => ({}))`), + 'b.test.js': testFile('b', `import './src/dep.js'`), + }, + { related: ['src/nested.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "b.test.js": { + "b": "passed", + }, + } + `) + expect(transformed.sort()).toMatchInlineSnapshot(` + [ + "a.test.js", + "b.test.js", + "src/dep.js", + "src/nested.js", + ] + `) + }) + + test('a module mocked behind a shared module is still walked for a test that does not mock it', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'src/shared.js': `import './dep.js'`, + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': ` + import { test, vi } from 'vitest' + import './src/shared.js' + + vi.mock('./src/dep.js', () => ({})) + + test('a', () => {}) + `, + 'b.test.js': testFile('b', `import './src/shared.js'`), + }, + { related: ['src/nested.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('a package redirected to the root __mocks__ is followed', async () => { + const { stderr, testTree } = await runInlineTests( + { + '__mocks__/tinyspy.js': ` + import '../src/helper.js' + export const spyOn = () => {} + `, + 'src/helper.js': 'export {}', + 'a.test.js': ` + import { test, vi } from 'vitest' + import 'tinyspy' + + vi.mock('tinyspy') + + test('a', () => {}) + `, + 'b.test.js': testFile('b', `import 'tinyspy'`), + }, + { related: ['src/helper.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test.each([ + [ + 'vi.importActual', + ` + import { test, vi } from 'vitest' + vi.mock('./src/dep.js', () => ({})) + test('a', async () => { + await vi.importActual('./src/dep.js') + }) + `, + ], + [ + 'vi.doUnmock', + ` + import { test, vi } from 'vitest' + vi.mock('./src/dep.js', () => ({})) + test('a', async () => { + vi.doUnmock('./src/dep.js') + await import('./src/dep.js') + }) + `, + ], + ])('a module mocked with a factory and loaded with %s runs the test', async (_, test) => { + const { stderr, testTree } = await runInlineTests( + { + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': test, + }, + { related: ['src/nested.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test('a module mocked in a setup file and loaded with vi.importActual runs the test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { setupFiles: ['./setup.js'] } }, + 'setup.js': ` + import { vi } from 'vitest' + vi.mock('./src/dep.js', () => ({})) + `, + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': ` + import { test, vi } from 'vitest' + test('a', async () => { + await vi.importActual('./src/dep.js') + }) + `, + 'b.test.js': testFile('b', `import './src/dep.js'`), + }, + { related: ['src/nested.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test('a module mocked in a setup file and unmocked in a test runs the test', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { setupFiles: ['./setup.js'] } }, + 'setup.js': ` + import { vi } from 'vitest' + vi.mock('./src/dep.js', () => ({})) + `, + 'src/dep.js': `import './nested.js'`, + 'src/nested.js': 'export {}', + 'a.test.js': ` + import { test, vi } from 'vitest' + import './src/dep.js' + + vi.unmock('./src/dep.js') + + test('a', () => {}) + `, + 'b.test.js': testFile('b', `import './src/dep.js'`), + }, + { related: ['src/nested.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test('a mock inside an imported module does not skip the mocked module', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'src/dep.js': 'export {}', + 'src/helper.js': ` + import { vi } from 'vitest' + vi.mock('./dep.js', () => ({})) + `, + 'a.test.js': ` + import { test } from 'vitest' + import './src/helper.js' + import './src/dep.js' + + test('a', () => {}) + `, + }, + { related: ['src/dep.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) +}) + +describe('files loaded for every test', () => { + test.each([ + [ + 'snapshotSerializers', + { snapshotSerializers: ['./loaded.js'] }, + `export default { serialize: () => '', test: () => false }`, + ], + [ + 'snapshotEnvironment', + { snapshotEnvironment: './loaded.js' }, + ` + import { VitestSnapshotEnvironment } from 'vitest/runtime' + export default new VitestSnapshotEnvironment() + `, + ], + ['diff', { diff: './loaded.js' }, 'export default {}'], + ['runner', { runner: './loaded.js' }, `export { TestRunner as default } from 'vitest'`], + ['environment', { environment: './loaded.js' }, customEnvironment], + ])('a file imported by %s runs every test', async (_, config, content) => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: config }, + 'loaded.js': ` + import './src/helper.js' + ${content} + `, + 'src/helper.js': 'export {}', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }, + { related: ['src/helper.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('an unrelated change runs only the tests that import it', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { setupFiles: ['./setup.js'], runner: './runner.js' } }, + 'setup.js': `import './src/helper.js'`, + 'runner.js': `export { TestRunner as default } from 'vitest'`, + 'src/helper.js': 'export {}', + 'src/other.js': 'export {}', + 'a.test.js': testFile('a', `import './src/other.js'`), + 'b.test.js': testFile('b'), + }, + { related: ['src/other.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) +}) + +describe('modules that fail to load', () => { + const broken = 'export const = 1' + + test('a test that imports a broken module runs and reports the error', async () => { + const { results } = await runInlineTests( + { + 'src/broken.js': broken, + 'src/changed.js': 'export {}', + 'src/other.js': 'export {}', + 'a.test.js': testFile('a', `import './src/broken.js'`), + 'b.test.js': testFile('b', `import './src/changed.js'`), + 'c.test.js': testFile('c', `import './src/other.js'`), + }, + { related: ['src/changed.js'] }, + ) + + expect(moduleStates(results)).toMatchInlineSnapshot(` + [ + [ + "a.test.js", + "failed", + ], + [ + "b.test.js", + "passed", + ], + ] + `) + }) + + test('a broken test file runs and reports the error', async () => { + const { results } = await runInlineTests( + { + 'src/changed.js': 'export {}', + 'a.test.js': broken, + 'b.test.js': testFile('b', `import './src/changed.js'`), + 'c.test.js': testFile('c'), + }, + { related: ['src/changed.js'] }, + ) + + expect(moduleStates(results)).toMatchInlineSnapshot(` + [ + [ + "a.test.js", + "failed", + ], + [ + "b.test.js", + "passed", + ], + ] + `) + }) + + test('a broken setup file runs every test', async () => { + const { results } = await runInlineTests( + { + 'vitest.config.js': { test: { setupFiles: ['./setup.js'] } }, + 'setup.js': broken, + 'src/changed.js': 'export {}', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b', `import './src/changed.js'`), + }, + { related: ['src/changed.js'] }, + ) + + expect(moduleStates(results)).toMatchInlineSnapshot(` + [ + [ + "a.test.js", + "failed", + ], + [ + "b.test.js", + "failed", + ], + ] + `) + }) + + function moduleStates(modules: TestModule[]) { + return modules + .map((testModule) => [testModule.relativeModuleId, testModule.state()]) + .sort(([a], [b]) => a.localeCompare(b)) + } +}) + +describe('projects', () => { + test('a file imported by the root config runs the tests of every project', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': ` + import { shared } from './vitest.shared.js' + export default { + test: { + projects: [ + { test: { ...shared, name: 'first', include: ['first/*.test.js'] } }, + { test: { ...shared, name: 'second', include: ['second/*.test.js'] } }, + ], + }, + } + `, + 'vitest.shared.js': 'export const shared = {}', + 'first/a.test.js': testFile('a'), + 'second/b.test.js': testFile('b'), + }, + { related: ['vitest.shared.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "first/a.test.js": { + "a": "passed", + }, + "second/b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('a file imported by the root config runs the tests of every config project', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': ` + import './vitest.shared.js' + export default { test: { projects: ['./first', './second'] } } + `, + 'vitest.shared.js': 'export {}', + 'first/vitest.config.js': { test: { name: 'first' } }, + 'first/a.test.js': testFile('a'), + 'second/vitest.config.js': { test: { name: 'second' } }, + 'second/b.test.js': testFile('b'), + }, + { related: ['vitest.shared.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + "b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('a setup file defined in a project config runs only the tests of that project', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { test: { projects: ['./first', './second'] } }, + 'first/vitest.config.js': { test: { name: 'first', setupFiles: ['./setup.js'] } }, + 'first/setup.js': `import '../src/helper.js'`, + 'first/a.test.js': testFile('a'), + 'second/vitest.config.js': { test: { name: 'second' } }, + 'second/b.test.js': testFile('b'), + 'src/helper.js': 'export {}', + }, + { related: ['src/helper.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) + }) + + test('a mock in a setup file applies only to the tests of that project', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'vitest.config.js': { + test: { + projects: [ + { test: { name: 'first', include: ['first/*.test.js'], setupFiles: ['./setup.js'] } }, + { test: { name: 'second', include: ['second/*.test.js'] } }, + ], + }, + }, + 'setup.js': ` + import { vi } from 'vitest' + vi.mock('./src/dep.js', () => ({})) + `, + 'src/dep.js': 'export {}', + 'first/a.test.js': testFile('a', `import '../src/dep.js'`), + 'second/b.test.js': testFile('b', `import '../src/dep.js'`), + }, + { related: ['src/dep.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "second/b.test.js": { + "b": "passed", + }, + } + `) + }) + + test('the same test file runs only in the project affected by the change', async () => { + const { stderr, buildTree } = await runInlineTests( + { + 'vitest.config.js': { + test: { + projects: [ + { test: { name: 'first', setupFiles: ['./setup.js'] } }, + { test: { name: 'second' } }, + ], + }, + }, + 'setup.js': `import './src/helper.js'`, + 'src/helper.js': 'export {}', + 'a.test.js': testFile('a'), + }, + { related: ['src/helper.js'] }, + ) + + expect(stderr).toBe('') + expect(buildTree((testCase) => testCase.project.name)).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "first", + }, + } + `) + }) +}) + +test('the environment comment is ignored for browser tests', async () => { + const root = `${process.cwd()}/vitest-test-${crypto.randomUUID()}` + useFS(root, { + 'vitest.config.js': ` + import { playwright } from '@vitest/browser-playwright' + export default { + test: { + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + }, + } + `, + 'a.test.js': ` + ${environmentComment('custom')} + import { test } from 'vitest' + import './src/helper.js' + + test('a', () => {}) + `, + 'src/helper.js': 'export {}', + }) + const vitest = await createVitest({ root, watch: false, related: ['src/helper.js'] }) + onTestFinished(() => vitest.close()) + + const specifications = await vitest.getRelevantTestSpecifications() + expect(specifications.map((spec) => [spec.pool, spec._docblock])).toMatchInlineSnapshot(` + [ + [ + "browser", + undefined, + ], + ] + `) +}) + +test('the run uses the environment comment read when filtering', async () => { + const root = `${process.cwd()}/vitest-test-${crypto.randomUUID()}` + const { editFile } = useFS(root, { + 'vitest.config.js': ` + import { fileURLToPath } from 'node:url' + export default { + resolve: { + alias: { + 'vitest-environment-custom': fileURLToPath(new URL('./env.js', import.meta.url)), + }, + }, + } + `, + 'env.js': ` + export default { + name: 'custom', + viteEnvironment: 'ssr', + setup() { + globalThis.__environment = 'custom' + return { teardown() { delete globalThis.__environment } } + }, + } + `, + 'a.test.js': ` + ${environmentComment('custom')} + import { expect, test } from 'vitest' + + test('environment', () => expect(globalThis.__environment).toBe('custom')) + `, + }) + const vitest = await createVitest({ root, watch: false, reporters: [{}], related: ['env.js'] }) + onTestFinished(() => vitest.close()) + + const specifications = await vitest.getRelevantTestSpecifications() + // the file is not read again, so the run still uses the environment from the comment + editFile('a.test.js', (content) => content.replace(environmentComment('custom'), '')) + + const { testModules } = await vitest.runTestSpecifications(specifications) + expect(testModules.map((testModule) => testModule.state())).toEqual(['passed']) +}) diff --git a/test/e2e/test/setup-files.test.ts b/test/e2e/test/setup-files.test.ts index 99d31fc74..d1d9bec9d 100644 --- a/test/e2e/test/setup-files.test.ts +++ b/test/e2e/test/setup-files.test.ts @@ -35,7 +35,7 @@ describe('setup files with forceRerunTrigger', () => { 'should run no tests if setup file is not changed', async () => { const { stdout } = await run() - expect(stdout).toContain('No test files found, exiting with code 0') + expect(stdout).toMatch(/No (changed|affected test) files found, exiting with code 0/) }, ) diff --git a/test/e2e/test/vcs-provider.test.ts b/test/e2e/test/vcs-provider.test.ts index 7b49f064c..f16f614ce 100644 --- a/test/e2e/test/vcs-provider.test.ts +++ b/test/e2e/test/vcs-provider.test.ts @@ -4,6 +4,11 @@ import { expect, onTestFinished, test, vi } from 'vitest' import { createVitest } from 'vitest/node' import { runInlineTests, useFS } from '#test-utils' +const testFile = ` + import { test } from 'vitest' + test('basic', () => {}) +` + function affectedSummary(stdout: string) { return stdout.split('\n').find((line) => line.includes('Affected')) } @@ -127,10 +132,85 @@ test('custom vcsProvider that returns no files runs no tests', async () => { }, ) - expect(stdout).toContain(`No test files found, exiting with code 0`) + expect(noTestsMessage(stdout)).toMatchInlineSnapshot( + `"No changed files found, exiting with code 0"`, + ) expect(testTree()).toMatchInlineSnapshot('{}') }) +test('changed files that no test imports are reported as not affecting any test', async () => { + const { stdout, stderr, exitCode } = await runInlineTests( + { + 'vitest.config.js': vcsConfig(['src/unused.js']), + 'src/unused.js': 'export {}', + 'a.test.js': testFile, + 'b.test.js': testFile, + }, + { changed: true }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(noTestsMessage(stdout)).toMatchInlineSnapshot( + `"No affected test files found, exiting with code 0"`, + ) +}) + +test('a related file that no test imports fails without passWithNoTests', async () => { + const { stderr, exitCode } = await runInlineTests( + { + 'src/unused.js': 'export {}', + 'basic.test.js': testFile, + }, + { related: ['src/unused.js'] }, + ) + + expect(exitCode).toBe(1) + expect(noTestsMessage(stderr)).toMatchInlineSnapshot( + `"No affected test files found, exiting with code 1"`, + ) +}) + +test('a filter that matches no test files is reported even with changed files', async () => { + const { stderr, exitCode } = await runInlineTests( + { + 'vitest.config.js': vcsConfig([]), + 'basic.test.js': testFile, + }, + { changed: true, passWithNoTests: false, $cliFilters: ['does-not-exist'] }, + ) + + expect(exitCode).toBe(1) + expect(noTestsMessage(stderr)).toMatchInlineSnapshot(` + "No test files found, exiting with code 1 + filter: does-not-exist" + `) +}) + +function vcsConfig(changed: string[]) { + return ` + import { resolve } from 'node:path' + export default { + test: { + experimental: { + vcsProvider: { + async findChangedFiles({ root }) { + return ${JSON.stringify(changed)}.map((file) => resolve(root, file)) + }, + }, + }, + }, + } + ` +} + +function noTestsMessage(output: string) { + return output + .split('\n') + .filter((line) => /No .* found|filter:/.test(line)) + .join('\n') +} + test('custom vcsProvider that returns all files runs all tests', async () => { const { testTree, stderr } = await runInlineTests( { diff --git a/test/e2e/test/vm-threads.test.ts b/test/e2e/test/vm-threads.test.ts index e91e2496c..38de8d3b7 100644 --- a/test/e2e/test/vm-threads.test.ts +++ b/test/e2e/test/vm-threads.test.ts @@ -1,5 +1,5 @@ import { relative, resolve } from 'pathe' -import { expect, test } from 'vitest' +import { expect, onTestFinished, test } from 'vitest' import { createMethodsRPC, createVitest } from 'vitest/node' import { createFile, @@ -776,3 +776,53 @@ test('prewarm skips factory-mocked and dynamically imported subtrees', async () expect(await prewarmed('fetch')).toEqual(expected) expect(await prewarmed('transformRequest')).toEqual(expected) }) + +test('prewarm follows __mocks__ redirects and setup file mocks', async () => { + const root = resolvePath(import.meta.url, `../fixtures/vm-prewarm-${crypto.randomUUID()}`) + useFS(root, { + 'redirected/index.js': `import './leaf.js'`, + 'redirected/leaf.js': 'export {}', + 'redirected/__mocks__/index.js': `import './mock-leaf.js'`, + 'redirected/__mocks__/mock-leaf.js': 'export {}', + 'setup-mocked/index.js': `import './leaf.js'`, + 'setup-mocked/leaf.js': 'export {}', + 'setup.js': ` + import { vi } from 'vitest' + vi.mock('./setup-mocked/index.js', () => ({})) + `, + 'consumer.test.js': ` + import { test, vi } from 'vitest' + import './redirected/index.js' + import './setup-mocked/index.js' + + vi.mock('./redirected/index.js') + + test('stub', () => {}) + `, + }) + + const ctx = await createVitest('test', { + root, + watch: false, + setupFiles: ['./setup.js'], + reporters: [], + }) + onTestFinished(() => ctx.close()) + const project = ctx.getRootProject() + const testFile = resolve(root, 'consumer.test.js') + const rpc = createMethodsRPC(project) + await rpc.fetch(testFile, undefined, 'ssr') + await rpc.prewarmModuleGraph('ssr', [testFile]) + + const prewarmed = [...project.vite.environments.ssr.moduleGraph.idToModuleMap.values()] + .filter((mod) => mod.transformResult && mod.id?.startsWith(root) && mod.id !== testFile) + .map((mod) => relative(root, mod.id!)) + .sort() + expect(prewarmed).toMatchInlineSnapshot(` + [ + "redirected/__mocks__/index.js", + "redirected/__mocks__/mock-leaf.js", + "setup.js", + ] + `) +}) diff --git a/test/e2e/test/watch/file-watching.test.ts b/test/e2e/test/watch/file-watching.test.ts index 9643ef817..9c74afd39 100644 --- a/test/e2e/test/watch/file-watching.test.ts +++ b/test/e2e/test/watch/file-watching.test.ts @@ -291,3 +291,129 @@ describe('browser', () => { }, ) }) + +test('editing the environment comment of a test file uses the new environment', async () => { + // written this way so the comment is not picked up from this file + const environmentComment = `// @vitest-${'environment'} custom` + const { vitest, fs } = await testUtils.runInlineTests( + { + 'vitest.config.js': ` + import { fileURLToPath } from 'node:url' + export default { + resolve: { + alias: { + 'vitest-environment-custom': fileURLToPath(new URL('./env.js', import.meta.url)), + }, + }, + } + `, + 'env.js': ` + export default { + name: 'custom', + viteEnvironment: 'ssr', + setup() { + globalThis.__environment = 'custom' + return { teardown() { delete globalThis.__environment } } + }, + } + `, + 'basic.test.js': ` + import { expect, test } from 'vitest' + test('environment', () => expect(globalThis.__environment).toBe('custom')) + `, + }, + { watch: true }, + ) + + await vitest.waitForStdout('Tests failed. Watching for file changes...') + vitest.resetOutput() + + fs.editFile('basic.test.js', (content) => `${environmentComment}\n${content}`) + + await vitest.waitForStdout('RERUN ../basic.test.js') + await vitest.waitForStdout('1 passed') +}) + +test.each([ + [ + 'snapshot serializer', + { snapshotSerializers: ['./loaded.js'] }, + `export default { serialize: () => '', test: () => false }`, + ], + ['diff config', { diff: './loaded.js' }, 'export default {}'], +])('editing a %s reruns the tests of its project', async (_, options, content) => { + const { vitest, fs } = await testUtils.runInlineTests( + { + 'loaded.js': content, + 'first/a.test.js': ` + import { test } from 'vitest' + test('[first] reruns', () => {}) + `, + 'second/b.test.js': ` + import { test } from 'vitest' + test("[second] doesn't rerun", () => {}) + `, + 'vitest.config.js': { + test: { + projects: [ + { test: { name: 'first', include: ['first/*.test.js'], ...options } }, + { test: { name: 'second', include: ['second/*.test.js'] } }, + ], + }, + }, + }, + { watch: true }, + ) + + await vitest.waitForStdout('Waiting for file changes...') + vitest.resetOutput() + + fs.editFile('loaded.js', (file) => `${file}\n`) + + await vitest.waitForStdout('RERUN ../loaded.js') + await vitest.waitForStdout('Test Files 1 passed') + expect(vitest.stdout).toContain('[first] reruns') + expect(vitest.stdout).not.toContain("[second] doesn't rerun") +}) + +describe('dot folders', () => { + const testFile = (name: string) => ` + import { test } from 'vitest' + test('${name}', () => {}) + ` + + test('editing a force rerun trigger inside a dot folder reruns all tests', async () => { + const root = resolve(process.cwd(), `.vitest-test-${crypto.randomUUID()}`) + const { editFile } = testUtils.useFS(root, { + 'vitest.config.js': { test: { forceRerunTriggers: ['**/trigger.js'] } }, + 'trigger.js': 'export {}', + 'a.test.js': testFile('a'), + 'b.test.js': testFile('b'), + }) + const { vitest } = await testUtils.runVitest({ root, watch: true }) + + await vitest.waitForStdout('Waiting for file changes...') + vitest.resetOutput() + + editFile('trigger.js', (content) => `${content}\n`) + + await vitest.waitForStdout('Test Files 2 passed') + expect(vitest.stdout).toContain('a.test.js > a') + expect(vitest.stdout).toContain('b.test.js > b') + }) + + test('creating a test file inside a dot folder runs it', async () => { + const { vitest, fs } = await testUtils.runInlineTests( + { 'a.test.js': testFile('a') }, + { watch: true }, + ) + + await vitest.waitForStdout('Waiting for file changes...') + vitest.resetOutput() + + fs.createFile('.storybook/new.test.js', testFile('new')) + + await vitest.waitForStdout('Test Files 1 passed') + expect(vitest.stdout).toContain('.storybook/new.test.js > new') + }) +}) diff --git a/test/e2e/test/watch/related.test.ts b/test/e2e/test/watch/related.test.ts index d1e6c3e63..a97c57a85 100644 --- a/test/e2e/test/watch/related.test.ts +++ b/test/e2e/test/watch/related.test.ts @@ -1,6 +1,6 @@ import { resolve } from 'pathe' -import { test } from 'vitest' -import { editFile, runVitest } from '#test-utils' +import { expect, test } from 'vitest' +import { editFile, runInlineTests, runVitest } from '#test-utils' // `changed: true` is git-driven, so unlike the other watch tests this one has // to run against a committed fixture: an inline tmp directory is either @@ -13,8 +13,8 @@ test('when nothing is changed, run nothing but keep watching', async () => { changed: true, }) - await vitest.waitForStdout('No affected test files found') await vitest.waitForStdout('Waiting for file changes...') + expect(vitest.stdout).toMatch(/No (changed|affected test) files found/) editFile( resolve(import.meta.dirname, '../../fixtures/related/math.ts'), @@ -32,3 +32,21 @@ test('when nothing is changed, run nothing but keep watching', async () => { await vitest.waitForStdout('RERUN ../../math.test.ts') await vitest.waitForStdout('1 passed') }) + +test('a filter that matches no test files is reported even with related files', async () => { + const { vitest } = await runInlineTests( + { + 'src/a.js': 'export {}', + 'a.test.js': ` + import { test } from 'vitest' + test('a', () => {}) + `, + }, + { watch: true, related: ['src/a.js'], $cliFilters: ['does-not-exist'] }, + ) + + await vitest.waitForStdout('Waiting for file changes...') + expect(vitest.stdout).toContain( + 'No test files found. You can change the file name pattern by pressing "p"', + ) +}) diff --git a/test/unit/test/injector-mock.test.ts b/test/unit/test/injector-mock.test.ts index 30b05e910..3192ed763 100644 --- a/test/unit/test/injector-mock.test.ts +++ b/test/unit/test/injector-mock.test.ts @@ -1,4 +1,5 @@ import type { Rolldown } from 'vite' +import type { StaticMockCall } from '../../../packages/mocker/src/node/hoistMocks' import type { HoistMocksPluginOptions } from '../../../packages/mocker/src/node/hoistMocksPlugin' import { stripVTControlCharacters } from 'node:util' import { parseAst } from 'vite' @@ -28,6 +29,74 @@ function hoistSimpleCode(code: string, options?: HoistMocksPluginOptions) { })?.code.trim() } +test('reports static mocks', () => { + const calls: StaticMockCall[] = [] + hoistSimpleCode( + ` + vi.mock('./automocked') + vi.mock('./factory', () => ({})) + vi.mock('./original', async (importOriginal) => importOriginal()) + vi.mock('./spy', { spy: true }) + vi.unmock('./unmocked') + vi.doUnmock('./do-unmocked') + vi.importActual('./actual') + vi.doMock('./do-mocked', () => ({})) + `, + { onStaticMock: (call) => calls.push(call) }, + ) + expect(calls).toMatchInlineSnapshot(` + [ + { + "automock": true, + "factoryLoadsOriginal": false, + "hasFactory": false, + "method": "mock", + "specifier": "./automocked", + }, + { + "automock": false, + "factoryLoadsOriginal": false, + "hasFactory": true, + "method": "mock", + "specifier": "./factory", + }, + { + "automock": false, + "factoryLoadsOriginal": true, + "hasFactory": true, + "method": "mock", + "specifier": "./original", + }, + { + "automock": false, + "factoryLoadsOriginal": false, + "hasFactory": false, + "method": "mock", + "specifier": "./spy", + }, + { + "automock": true, + "factoryLoadsOriginal": false, + "hasFactory": false, + "method": "unmock", + "specifier": "./unmocked", + }, + { + "factoryLoadsOriginal": false, + "hasFactory": false, + "method": "doUnmock", + "specifier": "./do-unmocked", + }, + { + "factoryLoadsOriginal": false, + "hasFactory": false, + "method": "importActual", + "specifier": "./actual", + }, + ] + `) +}) + test('hoists mock, unmock, hoisted', () => { expect( hoistSimpleCode(`