Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow custom environments #1963

Merged
merged 8 commits into from Sep 4, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Expand Up @@ -147,6 +147,10 @@ export default defineConfig({
text: 'Test Context',
link: '/guide/test-context',
},
{
text: 'Environment',
link: '/guide/environment',
},
{
text: 'Extending Matchers',
link: '/guide/extending-matchers',
Expand Down
31 changes: 29 additions & 2 deletions docs/config/index.md
Expand Up @@ -186,7 +186,7 @@ export default defineConfig({

### environment

- **Type:** `'node' | 'jsdom' | 'happy-dom' | 'edge-runtime'`
- **Type:** `'node' | 'jsdom' | 'happy-dom' | 'edge-runtime' | string`
- **Default:** `'node'`

The environment that will be used for testing. The default environment in Vitest
Expand Down Expand Up @@ -235,7 +235,34 @@ test('use jsdom in this test file', () => {
})
```

If you are running Vitest with [`--no-threads`](#threads) flag, your tests will be run in this order: `node`, `jsdom`, `happy-dom`. Meaning, that every test with the same environment is grouped together, but is still run sequentially.
If you are running Vitest with [`--no-threads`](#threads) flag, your tests will be run in this order: `node`, `jsdom`, `happy-dom`, `edge-runtime`, `custom environments`. Meaning, that every test with the same environment is grouped together, but is still running sequentially.

Starting from 0.23.0, you can also define custom environment. When non-builtin environment is used, Vitest will try to load package `vitest-environment-${name}`. That package should export an object with the shape of `Environment`:

```ts
import type { Environment } from 'vitest'

export default <Environment>{
name: 'custom',
setup() {
// cusotm setup
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
return {
teardown() {
// called after all tests with this env have been run
}
}
}
}
```

Vitest also exposes `environments` variable with built-in environments throught `vitest/environments`, in case you just want to extend it. You can read more about extending environments in [our guide](/guide/environment).

### environmentOptions

- **Type:** `Record<'jsdom' | string, string>`
- **Default:** `{}`

These options are passed down to `setup` method of current [`environment`](/#environment). By default, you can configure only JSDOM options, if you are using it as your test environment.

### update

Expand Down
55 changes: 55 additions & 0 deletions docs/guide/environment.md
@@ -0,0 +1,55 @@
# Test Environment

Vitest provides [`environment`](/config/#environment) option to run code inside a specific environment. You can modify how environment behaves with [`environmentOptions`](/config/#environmentoptions) option.

By default, you can use this environments:
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved

- `node` is default environment
- `jsdon` emulates browser environment by providing Browser API, uses [`jsdom`](https://github.com/jsdom/jsdom) package
- `happy-dom` emulates browser environment by providing Browser API, and considered to be faster than jsdom, but lacks some browser API, uses [`happy-dom`](https://github.com/capricorn86/happy-dom) package
- `edge-runtime` emulates Vercel's [edge-runtime](https://edge-runtime.vercel.app/), uses [`@edge-runtime/vm`](https://www.npmjs.com/package/@edge-runtime/vm) package

Starting from 0.23.0, you can create your own package to extend Vitest environment. To do so, create package with the name `vitest-environment-${name}`. That package should export an object with the shape of `Environment`:

```ts
import type { Environment } from 'vitest'

export default <Environment>{
name: 'custom',
setup() {
// cusotm setup
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
return {
teardown() {
// called after all tests with this env have been run
}
}
}
}
```

You also have access to default Vitest environments through `vitest/environments` entry:

```ts
import { environments, populateGlobal } from 'vitest/environments'

console.log(environments) // { jsdom, happy-dom, node, edge-runtime }
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
```

Vitest also provides `populateGlobal` utility function, which can be used to move properties from one object into the global namespace:

```ts
interface PopulateOptions {
// should non-class functions be bind to the global namespace
bindFunctions?: boolean
}

interface PopulateResult {
// a list of all keys that were copied, even if value doesn't exist on original object
keys: Set<string>
// a map of original object that might have been overriden with keys
// you can return these values inside `teardown` function
originals: Map<string | symbol, any>
}

export function populateGlobal(global: any, original: any, options: PopulateOptions): PopulateResult
```
1 change: 1 addition & 0 deletions packages/vitest/rollup.config.js
Expand Up @@ -20,6 +20,7 @@ const entries = [
'src/node/cli.ts',
'src/node/cli-wrapper.ts',
'src/node.ts',
'src/environments.ts',
'src/runtime/worker.ts',
'src/runtime/loader.ts',
'src/runtime/entry.ts',
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/environments.ts
@@ -0,0 +1,2 @@
export { environments } from './integrations/env/index'
export { populateGlobal } from './integrations/env/utils'
5 changes: 4 additions & 1 deletion packages/vitest/src/integrations/chai/index.ts
@@ -1,11 +1,13 @@
import * as chai from 'chai'
import './setup'
import type { Test } from '../../types'
import { getFullName } from '../../utils'
import { getFullName, getWorkerState } from '../../utils'
import type { MatcherState } from '../../types/chai'
import { getState, setState } from './jest-expect'
import { GLOBAL_EXPECT } from './constants'

const workerState = getWorkerState()

export function createExpect(test?: Test) {
const expect = ((value: any, message?: string): Vi.Assertion => {
const { assertionCalls } = getState(expect)
Expand All @@ -28,6 +30,7 @@ export function createExpect(test?: Test) {
isExpectingAssertionsError: null,
expectedAssertionsNumber: null,
expectedAssertionsNumberErrorGen: null,
environment: workerState.config.environment,
testPath: test?.suite.file?.filepath,
currentTestName: test ? getFullName(test) : undefined,
}, expect)
Expand Down
9 changes: 9 additions & 0 deletions packages/vitest/src/integrations/env/index.ts
@@ -1,3 +1,4 @@
import type { VitestEnvironment } from '../../types/config'
import node from './node'
import jsdom from './jsdom'
import happy from './happy-dom'
Expand All @@ -17,3 +18,11 @@ export const envPackageNames: Record<Exclude<keyof typeof environments, 'node'>,
'happy-dom': 'happy-dom',
'edge-runtime': '@edge-runtime/vm',
}

export const getEnvPackageName = (env: VitestEnvironment) => {
if (env === 'node')
return null
if (env in envPackageNames)
return (envPackageNames as any)[env]
return `vitest-environment-${env}`
}
8 changes: 4 additions & 4 deletions packages/vitest/src/integrations/env/utils.ts
Expand Up @@ -34,6 +34,10 @@ function isClassLikeName(name: string) {
}

interface PopulateOptions {
// we bind functions such as addEventListener and others
// because they rely on `this` in happy-dom, and in jsdom it
// has a priority for getting implementation from symbols
// (global doesn't have these symbols, but window - does)
bindFunctions?: boolean
}

Expand All @@ -47,10 +51,6 @@ export function populateGlobal(global: any, win: any, options: PopulateOptions =

const overrideObject = new Map<string | symbol, any>()
for (const key of keys) {
// we bind functions such as addEventListener and others
// because they rely on `this` in happy-dom, and in jsdom it
// has a priority for getting implementation from symbols
// (global doesn't have these symbols, but window - does)
const boundFunction = bindFunctions
&& typeof win[key] === 'function'
&& !isClassLikeName(key)
Expand Down
13 changes: 6 additions & 7 deletions packages/vitest/src/node/cli-api.ts
Expand Up @@ -2,7 +2,7 @@ import { resolve } from 'pathe'
import type { UserConfig as ViteUserConfig } from 'vite'
import { EXIT_CODE_RESTART } from '../constants'
import { CoverageProviderMap } from '../integrations/coverage'
import { envPackageNames } from '../integrations/env'
import { getEnvPackageName } from '../integrations/env'
import type { UserConfig } from '../types'
import { ensurePackageInstalled } from '../utils'
import { createVitest } from './create'
Expand Down Expand Up @@ -50,12 +50,11 @@ export async function startVitest(cliFilters: string[], options: CliOptions, vit
}
}

if (ctx.config.environment && ctx.config.environment !== 'node') {
const packageName = envPackageNames[ctx.config.environment]
if (!await ensurePackageInstalled(packageName, root)) {
process.exitCode = 1
return false
}
const environmentPackage = getEnvPackageName(ctx.config.environment)

if (environmentPackage && !await ensurePackageInstalled(environmentPackage, root)) {
process.exitCode = 1
return false
}

if (process.stdin.isTTY && ctx.config.watch)
Expand Down
16 changes: 9 additions & 7 deletions packages/vitest/src/runtime/entry.ts
@@ -1,5 +1,5 @@
import { promises as fs } from 'fs'
import type { BuiltinEnvironment, ResolvedConfig } from '../types'
import type { ResolvedConfig, VitestEnvironment } from '../types'
import { getWorkerState, resetModules } from '../utils'
import { envs } from '../integrations/env'
import { setupGlobalEnv, withEnv } from './setup'
Expand All @@ -22,22 +22,24 @@ export async function run(files: string[], config: ResolvedConfig): Promise<void
const filesWithEnv = await Promise.all(files.map(async (file) => {
const code = await fs.readFile(file, 'utf-8')
const env = code.match(/@(?:vitest|jest)-environment\s+?([\w-]+)\b/)?.[1] || config.environment || 'node'
if (!envs.includes(env))
throw new Error(`Unsupported environment: "${env}" in ${file}`)
return {
file,
env: env as BuiltinEnvironment,
env: env as VitestEnvironment,
}
}))

const filesByEnv = filesWithEnv.reduce((acc, { file, env }) => {
acc[env] ||= []
acc[env].push(file)
return acc
}, {} as Record<BuiltinEnvironment, string[]>)
}, {} as Record<VitestEnvironment, string[]>)

for (const env of envs) {
const environment = env as BuiltinEnvironment
const orderedEnvs = envs.concat(
Object.keys(filesByEnv).filter(env => !envs.includes(env)),
)

for (const env of orderedEnvs) {
const environment = env as VitestEnvironment
const files = filesByEnv[environment]

if (!files || !files.length)
Expand Down
16 changes: 14 additions & 2 deletions packages/vitest/src/runtime/setup.ts
@@ -1,5 +1,5 @@
import { environments } from '../integrations/env'
import type { ResolvedConfig } from '../types'
import type { Environment, ResolvedConfig } from '../types'
import { clearTimeout, getWorkerState, isNode, setTimeout, toArray } from '../utils'
import * as VitestIndex from '../index'
import { resetRunOnceCounter } from '../integrations/run-once'
Expand Down Expand Up @@ -151,12 +151,24 @@ export async function setupConsoleLogSpy() {
})
}

async function loadEnvironment(name: string) {
const pkg = await import(`vitest-environment-${name}`)
if (!pkg || !pkg.default || typeof pkg.default !== 'object' || typeof pkg.default.setup !== 'function') {
throw new Error(
`Environment "${name}" is not a valid environment. `
+ `Package "vitest-environment-${name}" should have default export with "setup" method.`,
)
}
return pkg.default
}

export async function withEnv(
name: ResolvedConfig['environment'],
options: ResolvedConfig['environmentOptions'],
fn: () => Promise<void>,
) {
const env = await environments[name].setup(globalThis, options)
const config: Environment = (environments as any)[name] || await loadEnvironment(name)
const env = await config.setup(globalThis, options)
try {
await fn()
}
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/types/chai.ts
Expand Up @@ -10,6 +10,7 @@ import type { use as chaiUse } from 'chai'

import type * as jestMatcherUtils from '../integrations/chai/jest-matcher-utils'
import type SnapshotState from '../integrations/snapshot/port/state'
import type { VitestEnvironment } from './config'

export type FirstFunctionArgument<T> = T extends (arg: infer A) => unknown ? A : never
export type ChaiPlugin = FirstFunctionArgument<typeof chaiUse>
Expand All @@ -33,6 +34,7 @@ export interface MatcherState {
isExpectingAssertions?: boolean
isExpectingAssertionsError?: Error | null
isNot: boolean
environment: VitestEnvironment
promise: string
snapshotState: SnapshotState
suppressedErrors: Array<Error>
Expand Down
7 changes: 6 additions & 1 deletion packages/vitest/src/types/config.ts
Expand Up @@ -10,6 +10,8 @@ import type { SnapshotStateOptions } from './snapshot'
import type { Arrayable } from './general'

export type BuiltinEnvironment = 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime'
// Record is used, so user can get intellisense for builtin environments, but still allow custom environments
export type VitestEnvironment = BuiltinEnvironment | (string & Record<never, never>)

export type ApiConfig = Pick<CommonServerOptions, 'port' | 'strictPort' | 'host'>

Expand All @@ -20,6 +22,7 @@ export interface EnvironmentOptions {
* jsdom options.
*/
jsdom?: JSDOMOptions
[x: string]: unknown
}

export interface InlineConfig {
Expand Down Expand Up @@ -107,9 +110,11 @@ export interface InlineConfig {
*
* Supports 'node', 'jsdom', 'happy-dom', 'edge-runtime'
*
* If used unsupported string, will try to load the package `vitest-environment-${env}`
*
* @default 'node'
*/
environment?: BuiltinEnvironment
environment?: VitestEnvironment

/**
* Environment options.
Expand Down
32 changes: 31 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions test/env-custom/package.json
@@ -0,0 +1,12 @@
{
"name": "@vitest/test-env-custom",
"private": true,
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
},
"devDependencies": {
"vitest": "workspace:*",
"vitest-environment-custom": "file:./vitest-environment-custom"
}
}