Skip to content

Commit 1f858e0

Browse files
authoredFeb 21, 2023
feat!: always run separate environments in isolation (#2860)
1 parent f817618 commit 1f858e0

File tree

6 files changed

+120
-94
lines changed

6 files changed

+120
-94
lines changed
 

‎packages/vitest/src/node/pool.ts

+31-6
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import type { Options as TinypoolOptions } from 'tinypool'
66
import { Tinypool } from 'tinypool'
77
import { createBirpc } from 'birpc'
88
import type { RawSourceMap } from 'vite-node'
9-
import type { ResolvedConfig, WorkerContext, WorkerRPC } from '../types'
9+
import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types'
1010
import { distDir, rootDir } from '../constants'
11-
import { AggregateError } from '../utils'
11+
import { AggregateError, groupBy } from '../utils'
12+
import { envsOrder, groupFilesByEnv } from '../utils/test-helpers'
1213
import type { Vitest } from './core'
1314

1415
export type RunWithFiles = (files: string[], invalidates?: string[]) => Promise<void>
@@ -88,7 +89,7 @@ export function createPool(ctx: Vitest): WorkerPool {
8889
const runWithFiles = (name: string): RunWithFiles => {
8990
let id = 0
9091

91-
async function runFiles(config: ResolvedConfig, files: string[], invalidates: string[] = []) {
92+
async function runFiles(config: ResolvedConfig, files: string[], environment: WorkerTestEnvironment, invalidates: string[] = []) {
9293
ctx.state.clearFiles(files)
9394
const { workerPort, port } = createChannel(ctx)
9495
const workerId = ++id
@@ -97,6 +98,7 @@ export function createPool(ctx: Vitest): WorkerPool {
9798
config,
9899
files,
99100
invalidates,
101+
environment,
100102
workerId,
101103
}
102104
try {
@@ -119,12 +121,35 @@ export function createPool(ctx: Vitest): WorkerPool {
119121

120122
files = await sequencer.sort(files)
121123

124+
const filesByEnv = await groupFilesByEnv(files, config)
125+
const envs = envsOrder.concat(
126+
Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)),
127+
)
128+
122129
if (!ctx.config.threads) {
123-
await runFiles(config, files)
130+
// always run environments isolated between each other
131+
for (const env of envs) {
132+
const files = filesByEnv[env]
133+
134+
if (!files?.length)
135+
continue
136+
137+
const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options))
138+
139+
for (const option in filesByOptions) {
140+
const files = filesByOptions[option]
141+
142+
if (files?.length) {
143+
const filenames = files.map(f => f.file)
144+
await runFiles(config, filenames, files[0].environment, invalidates)
145+
}
146+
}
147+
}
124148
}
125149
else {
126-
const results = await Promise.allSettled(files
127-
.map(file => runFiles(config, [file], invalidates)))
150+
const promises = Object.values(filesByEnv).flat()
151+
const results = await Promise.allSettled(promises
152+
.map(({ file, environment }) => runFiles(config, [file], environment, invalidates)))
128153

129154
const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason)
130155
if (errors.length > 0)

‎packages/vitest/src/runtime/entry.ts

+22-84
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import { promises as fs } from 'node:fs'
2-
import mm from 'micromatch'
31
import type { VitestRunner, VitestRunnerConstructor } from '@vitest/runner'
42
import { startTests } from '@vitest/runner'
53
import { resolve } from 'pathe'
6-
import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from '../types'
4+
import type { ResolvedConfig, WorkerTestEnvironment } from '../types'
75
import { getWorkerState, resetModules } from '../utils'
86
import { vi } from '../integrations/vi'
9-
import { envs } from '../integrations/env'
107
import { distDir } from '../constants'
118
import { startCoverageInsideWorker, stopCoverageInsideWorker, takeCoverageInsideWorker } from '../integrations/coverage'
129
import { setupGlobalEnv, withEnv } from './setup.node'
@@ -15,15 +12,6 @@ import type { VitestExecutor } from './execute'
1512

1613
const runnersFile = resolve(distDir, 'runners.js')
1714

18-
function groupBy<T, K extends string | number | symbol>(collection: T[], iteratee: (item: T) => K) {
19-
return collection.reduce((acc, item) => {
20-
const key = iteratee(item)
21-
acc[key] ||= []
22-
acc[key].push(item)
23-
return acc
24-
}, {} as Record<K, T[]>)
25-
}
26-
2715
async function getTestRunnerConstructor(config: ResolvedConfig, executor: VitestExecutor): Promise<VitestRunnerConstructor> {
2816
if (!config.runner) {
2917
const { VitestTestRunner, NodeBenchmarkRunner } = await executor.executeFile(runnersFile)
@@ -77,89 +65,39 @@ async function getTestRunner(config: ResolvedConfig, executor: VitestExecutor):
7765
}
7866

7967
// browser shouldn't call this!
80-
export async function run(files: string[], config: ResolvedConfig, executor: VitestExecutor): Promise<void> {
68+
export async function run(files: string[], config: ResolvedConfig, environment: WorkerTestEnvironment, executor: VitestExecutor): Promise<void> {
8169
await setupGlobalEnv(config)
8270
await startCoverageInsideWorker(config.coverage, executor)
8371

8472
const workerState = getWorkerState()
8573

8674
const runner = await getTestRunner(config, executor)
8775

88-
// if calling from a worker, there will always be one file
89-
// if calling with no-threads, this will be the whole suite
90-
const filesWithEnv = await Promise.all(files.map(async (file) => {
91-
const code = await fs.readFile(file, 'utf-8')
92-
93-
// 1. Check for control comments in the file
94-
let env = code.match(/@(?:vitest|jest)-environment\s+?([\w-]+)\b/)?.[1]
95-
// 2. Check for globals
96-
if (!env) {
97-
for (const [glob, target] of config.environmentMatchGlobs || []) {
98-
if (mm.isMatch(file, glob)) {
99-
env = target
100-
break
101-
}
76+
// @ts-expect-error untyped global
77+
globalThis.__vitest_environment__ = environment
78+
79+
await withEnv(environment.name, environment.options || config.environmentOptions || {}, executor, async () => {
80+
for (const file of files) {
81+
// it doesn't matter if running with --threads
82+
// if running with --no-threads, we usually want to reset everything before running a test
83+
// but we have --isolate option to disable this
84+
if (config.isolate) {
85+
workerState.mockMap.clear()
86+
resetModules(workerState.moduleCache, true)
10287
}
103-
}
104-
// 3. Fallback to global env
105-
env ||= config.environment || 'node'
106-
107-
const envOptions = JSON.parse(code.match(/@(?:vitest|jest)-environment-options\s+?(.+)/)?.[1] || 'null')
108-
return {
109-
file,
110-
env: env as VitestEnvironment,
111-
envOptions: envOptions ? { [env]: envOptions } as EnvironmentOptions : null,
112-
}
113-
}))
114-
115-
const filesByEnv = groupBy(filesWithEnv, ({ env }) => env)
116-
117-
const orderedEnvs = envs.concat(
118-
Object.keys(filesByEnv).filter(env => !envs.includes(env)),
119-
)
120-
121-
for (const env of orderedEnvs) {
122-
const environment = env as VitestEnvironment
123-
const files = filesByEnv[environment]
124-
125-
if (!files || !files.length)
126-
continue
12788

128-
// @ts-expect-error untyped global
129-
globalThis.__vitest_environment__ = environment
89+
workerState.filepath = file
13090

131-
const filesByOptions = groupBy(files, ({ envOptions }) => JSON.stringify(envOptions))
91+
await startTests([file], runner)
13292

133-
for (const options of Object.keys(filesByOptions)) {
134-
const files = filesByOptions[options]
93+
workerState.filepath = undefined
13594

136-
if (!files || !files.length)
137-
continue
138-
139-
await withEnv(environment, files[0].envOptions || config.environmentOptions || {}, executor, async () => {
140-
for (const { file } of files) {
141-
// it doesn't matter if running with --threads
142-
// if running with --no-threads, we usually want to reset everything before running a test
143-
// but we have --isolate option to disable this
144-
if (config.isolate) {
145-
workerState.mockMap.clear()
146-
resetModules(workerState.moduleCache, true)
147-
}
148-
149-
workerState.filepath = file
150-
151-
await startTests([file], runner)
152-
153-
workerState.filepath = undefined
154-
155-
// reset after tests, because user might call `vi.setConfig` in setupFile
156-
vi.resetConfig()
157-
// mocks should not affect different files
158-
vi.restoreAllMocks()
159-
}
160-
})
95+
// reset after tests, because user might call `vi.setConfig` in setupFile
96+
vi.resetConfig()
97+
// mocks should not affect different files
98+
vi.restoreAllMocks()
16199
}
162-
}
163100

164-
await stopCoverageInsideWorker(config.coverage, executor)
101+
await stopCoverageInsideWorker(config.coverage, executor)
102+
})
165103
}

‎packages/vitest/src/runtime/worker.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { workerId as poolId } from 'tinypool'
55
import { processError } from '@vitest/runner/utils'
66
import { ModuleCacheMap } from 'vite-node/client'
77
import { isPrimitive } from 'vite-node/utils'
8-
import type { ResolvedConfig, WorkerContext, WorkerRPC } from '../types'
8+
import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types'
99
import { distDir } from '../constants'
1010
import { getWorkerState } from '../utils/global'
1111
import type { MockMap } from '../types/mocker'
@@ -14,7 +14,7 @@ import { createVitestExecutor } from './execute'
1414
import { rpc, rpcDone } from './rpc'
1515

1616
let _viteNode: {
17-
run: (files: string[], config: ResolvedConfig, executor: VitestExecutor) => Promise<void>
17+
run: (files: string[], config: ResolvedConfig, environment: WorkerTestEnvironment, executor: VitestExecutor) => Promise<void>
1818
executor: VitestExecutor
1919
}
2020

@@ -109,6 +109,6 @@ function init(ctx: WorkerContext) {
109109
export async function run(ctx: WorkerContext) {
110110
init(ctx)
111111
const { run, executor } = await startViteNode(ctx)
112-
await run(ctx.files, ctx.config, executor)
112+
await run(ctx.files, ctx.config, ctx.environment, executor)
113113
await rpcDone()
114114
}

‎packages/vitest/src/types/worker.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@ import type { File, TaskResultPack, Test } from '@vitest/runner'
33
import type { FetchFunction, ModuleCacheMap, RawSourceMap, ViteNodeResolveId } from 'vite-node'
44
import type { BirpcReturn } from 'birpc'
55
import type { MockMap } from './mocker'
6-
import type { ResolvedConfig } from './config'
6+
import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config'
77
import type { SnapshotResult } from './snapshot'
88
import type { UserConsoleLog } from './general'
99

10+
export interface WorkerTestEnvironment {
11+
name: VitestEnvironment
12+
options: EnvironmentOptions | null
13+
}
14+
1015
export interface WorkerContext {
1116
workerId: number
1217
port: MessagePort
1318
config: ResolvedConfig
1419
files: string[]
20+
environment: WorkerTestEnvironment
1521
invalidates?: string[]
1622
}
1723

‎packages/vitest/src/utils/base.ts

+9
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ function collectOwnProperties(obj: any, collector: Set<string | symbol> | ((key:
1010
Object.getOwnPropertySymbols(obj).forEach(collect)
1111
}
1212

13+
export function groupBy<T, K extends string | number | symbol>(collection: T[], iteratee: (item: T) => K) {
14+
return collection.reduce((acc, item) => {
15+
const key = iteratee(item)
16+
acc[key] ||= []
17+
acc[key].push(item)
18+
return acc
19+
}, {} as Record<K, T[]>)
20+
}
21+
1322
export function getAllMockableProperties(obj: any, isModule: boolean) {
1423
const allProps = new Map<string | symbol, { key: string | symbol; descriptor: PropertyDescriptor }>()
1524
let curr = obj
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { promises as fs } from 'node:fs'
2+
import mm from 'micromatch'
3+
import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from '../types'
4+
import { groupBy } from './base'
5+
6+
export const envsOrder = [
7+
'node',
8+
'jsdom',
9+
'happy-dom',
10+
'edge-runtime',
11+
]
12+
13+
export interface FileByEnv {
14+
file: string
15+
env: VitestEnvironment
16+
envOptions: EnvironmentOptions | null
17+
}
18+
19+
export async function groupFilesByEnv(files: string[], config: ResolvedConfig) {
20+
const filesWithEnv = await Promise.all(files.map(async (file) => {
21+
const code = await fs.readFile(file, 'utf-8')
22+
23+
// 1. Check for control comments in the file
24+
let env = code.match(/@(?:vitest|jest)-environment\s+?([\w-]+)\b/)?.[1]
25+
// 2. Check for globals
26+
if (!env) {
27+
for (const [glob, target] of config.environmentMatchGlobs || []) {
28+
if (mm.isMatch(file, glob)) {
29+
env = target
30+
break
31+
}
32+
}
33+
}
34+
// 3. Fallback to global env
35+
env ||= config.environment || 'node'
36+
37+
const envOptions = JSON.parse(code.match(/@(?:vitest|jest)-environment-options\s+?(.+)/)?.[1] || 'null')
38+
return {
39+
file,
40+
environment: {
41+
name: env as VitestEnvironment,
42+
options: envOptions ? { [env]: envOptions } as EnvironmentOptions : null,
43+
},
44+
}
45+
}))
46+
47+
return groupBy(filesWithEnv, ({ environment }) => environment.name)
48+
}

0 commit comments

Comments
 (0)
Please sign in to comment.