Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/app-vitest-browser/components/MyCounter.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div>
<p>Count: {{ count }}</p>
<p>Runtime Config: {{ config }}</p>
<button @click="increment">
Increment
</button>
Expand All @@ -12,6 +13,7 @@

<script setup lang="ts">
const count = ref(0)
const config = useRuntimeConfig()

const increment = () => {
count.value++
Expand Down
5 changes: 5 additions & 0 deletions examples/app-vitest-browser/test/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,9 @@ describe('Component (MyCounter)', () => {
await decrementButton.click()
expect(getByText('Count: -1')).toBeInTheDocument()
})

it('can use Nuxt-specific composables', () => {
const { getByText } = render(MyCounter)
expect(getByText('"buildAssetsDir": "/_nuxt/"')).toBeInTheDocument()
})
})
33 changes: 33 additions & 0 deletions examples/app-vitest-browser/test/mount-suspended.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'

import { mountSuspended } from '@nuxt/test-utils/runtime'

import { MyCounter } from '#components'

describe('Component (MyCounter)', () => {
it('renders', async () => {
const component = await mountSuspended(MyCounter)
expect(component.text()).toContain('Count: 0')
})

it('can be interacted with (increment)', async () => {
const component = await mountSuspended(MyCounter)
const incrementButton = component.findAll('button').filter(btn => btn.text().includes('Increment'))[0]
incrementButton.element.click()
await nextTick()
expect(component.text()).toContain('Count: 1')
})

it('can be interacted with (decrement)', async () => {
const component = await mountSuspended(MyCounter)
const decrementButton = component.findAll('button').filter(btn => btn.text().includes('Decrement'))[0]
decrementButton.element.click()
await nextTick()
expect(component.text()).toContain('Count: -1')
})

it('can use Nuxt-specific composables', async () => {
const component = await mountSuspended(MyCounter)
expect(component.text()).toContain('"buildAssetsDir": "/_nuxt/"')
})
})
37 changes: 35 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export async function getVitestConfigFromNuxt(
define: {
'process.env.NODE_ENV': '"test"',
},
optimizeDeps: {
noDiscovery: true,
},
test: {
dir: process.cwd(),
environmentOptions: {
Expand Down Expand Up @@ -265,16 +268,46 @@ async function resolveConfig<T extends ViteUserConfig & { test?: VitestConfig }
config.test.setupFiles = [config.test.setupFiles].filter(Boolean) as string[]
}

return defu(
const resolvedConfig = defu(
config satisfies T,
await getVitestConfigFromNuxt(undefined, {
dotenv: config.test?.environmentOptions?.nuxt?.dotenv,
overrides: structuredClone(overrides),
}) satisfies ViteUserConfig & { test: NonNullable<T['test']> },
) as T & { test: NonNullable<T['test']> }

const PLUGIN_NAME = 'nuxt:vitest:nuxt-environment-options'
const STUB_ID = 'nuxt-vitest-environment-options'
resolvedConfig.plugins!.push({
name: PLUGIN_NAME,
enforce: 'pre',
resolveId(id) {
if (id.endsWith(STUB_ID)) {
return STUB_ID
}
},
load(id) {
if (id.endsWith(STUB_ID)) {
return `export default ${JSON.stringify(resolvedConfig.test.environmentOptions || {})}`
}
},
})

if (resolvedConfig.test.browser?.enabled) {
if (resolvedConfig.test.environment === 'nuxt') {
resolvedConfig.test.setupFiles = Array.isArray(resolvedConfig.test.setupFiles)
? resolvedConfig.test.setupFiles
: [resolvedConfig.test.setupFiles].filter(Boolean) as string[]
const resolver = createResolver(import.meta.url)
const browserEntry = await findPath(resolver.resolve('./runtime/browser-entry')) || resolver.resolve('./runtime/browser-entry')
resolvedConfig.test.setupFiles.unshift(browserEntry)
}
}

return resolvedConfig
}

interface NuxtEnvironmentOptions {
export interface NuxtEnvironmentOptions {
rootDir?: string
/**
* The starting URL for your Nuxt window environment
Expand Down
139 changes: 4 additions & 135 deletions src/environments/vitest/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import type { Environment } from 'vitest/environments'
import { createFetch } from 'ofetch'
import { indexedDB } from 'fake-indexeddb'
import { joinURL } from 'ufo'
import { createApp, defineEventHandler, toNodeListener, splitCookiesString } from 'h3'
import defu from 'defu'
import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from 'radix3'
import { populateGlobal } from 'vitest/environments'
import { fetchNodeRequestHandler } from 'node-mock-http'

import { setupWindow } from '../../runtime/shared/environment'
import type { NuxtBuiltinEnvironment } from './types'
import happyDom from './env/happy-dom'
import jsdom from './env/jsdom'
Expand All @@ -25,39 +22,13 @@ export default <Environment>{
environmentOptions?.nuxtRuntimeConfig.app?.baseURL || '/',
)

const consoleInfo = console.info
console.info = (...args) => {
if (args[0] === '<Suspense> is an experimental feature and its API will likely change.') {
return
}
return consoleInfo(...args)
}

const environmentName = environmentOptions.nuxt.domEnvironment as NuxtBuiltinEnvironment
const environment = environmentMap[environmentName] || environmentMap['happy-dom']
const { window: win, teardown } = await environment(global, defu(environmentOptions, {
happyDom: { url },
jsdom: { url },
}))

win.__NUXT_VITEST_ENVIRONMENT__ = true

win.__NUXT__ = {
serverRendered: false,
config: {
public: {},
app: { baseURL: '/' },
...environmentOptions?.nuxtRuntimeConfig,
},
data: {},
state: {},
}

const app = win.document.createElement('div')
// this is a workaround for a happy-dom bug with ids beginning with _
app.id = environmentOptions.nuxt.rootId
win.document.body.appendChild(app)

if (environmentOptions?.nuxt?.mock?.intersectionObserver) {
win.IntersectionObserver
= win.IntersectionObserver
Expand All @@ -73,123 +44,21 @@ export default <Environment>{
win.indexedDB = indexedDB
}

const h3App = createApp()

if (!win.fetch) {
await import('node-fetch-native/polyfill')
// @ts-expect-error URLSearchParams is not a proeprty of window
win.URLSearchParams = globalThis.URLSearchParams
}

const nodeHandler = toNodeListener(h3App)

const registry = new Set<string>()

win.fetch = async (url, init) => {
if (typeof url === 'string') {
const base = url.split('?')[0]
if (registry.has(base) || registry.has(url)) {
url = '/_' + url
}
if (url.startsWith('/')) {
const response = await fetchNodeRequestHandler(nodeHandler, url, init)
return normalizeFetchResponse(response)
}
}
return fetch(url, init)
}

// @ts-expect-error fetch types differ slightly
win.$fetch = createFetch({ fetch: win.fetch, Headers: win.Headers })

win.__registry = registry
win.__app = h3App

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const teardownWindow = await setupWindow(win, environmentOptions as any)
const { keys, originals } = populateGlobal(global, win, {
bindFunctions: true,
})

// App manifest support
const timestamp = Date.now()
const routeRulesMatcher = toRouteMatcher(
createRadixRouter({ routes: environmentOptions.nuxtRouteRules || {} }),
)
const matcher = exportMatcher(routeRulesMatcher)
const manifestOutputPath = joinURL(
environmentOptions?.nuxtRuntimeConfig.app?.baseURL || '/',
environmentOptions?.nuxtRuntimeConfig.app?.buildAssetsDir || '_nuxt',
'builds',
)
const manifestBaseRoutePath = joinURL('/_', manifestOutputPath)

// @ts-expect-error untyped __NUXT__ variable
const buildId = win.__NUXT__.config.app.buildId || 'test'

h3App.use(
`${manifestBaseRoutePath}/latest.json`,
defineEventHandler(() => ({
id: buildId,
timestamp,
})),
)
h3App.use(
`${manifestBaseRoutePath}/meta/${buildId}.json`,
defineEventHandler(() => ({
id: buildId,
timestamp,
matcher,
prerendered: [],
})),
)

registry.add(`${manifestOutputPath}/latest.json`)
registry.add(`${manifestOutputPath}/meta/${buildId}.json`)

return {
// called after all tests with this env have been run
teardown() {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
keys.forEach(key => delete global[key])
console.info = consoleInfo
teardownWindow()
originals.forEach((v, k) => (global[k] = v))
teardown()
},
}
},
}

/** utils from nitro */

function normalizeFetchResponse(response: Response) {
if (!response.headers.has('set-cookie')) {
return response
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: normalizeCookieHeaders(response.headers),
})
}

function normalizeCookieHeader(header: number | string | string[] = '') {
return splitCookiesString(joinHeaders(header))
}

function normalizeCookieHeaders(headers: Headers) {
const outgoingHeaders = new Headers()
for (const [name, header] of headers) {
if (name === 'set-cookie') {
for (const cookie of normalizeCookieHeader(header)) {
outgoingHeaders.append('set-cookie', cookie)
}
}
else {
outgoingHeaders.set(name, joinHeaders(header))
}
}
return outgoingHeaders
}

function joinHeaders(value: number | string | string[]) {
return Array.isArray(value) ? value.join(', ') : String(value)
}
12 changes: 12 additions & 0 deletions src/runtime/browser-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// @ts-expect-error virtual file
import environmentOptions from 'nuxt-vitest-environment-options'

import type { NuxtWindow } from '../vitest-environment'
import { setupNuxt } from './shared/nuxt'
import { setupWindow } from './shared/environment'

const el = document.querySelector(environmentOptions.nuxt.rootId || 'nuxt-test')
if (!el) {
await setupWindow(window as unknown as NuxtWindow, environmentOptions)
await setupNuxt()
}
11 changes: 2 additions & 9 deletions src/runtime/entry.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { vi } from 'vitest'
import { setupNuxt } from './shared/nuxt'

if (
typeof window !== 'undefined'
// @ts-expect-error undefined property
&& window.__NUXT_VITEST_ENVIRONMENT__
) {
const { useRouter } = await import('#app/composables/router')
// @ts-expect-error alias to allow us to transform the entrypoint
await import('#app/nuxt-vitest-app-entry').then(r => r.default())
// We must manually call `page:finish` to snc route after navigation
// as there is no `<NuxtPage>` instantiated by default.
const nuxtApp = useNuxtApp()
await nuxtApp.callHook('page:finish')
useRouter().afterEach(() => nuxtApp.callHook('page:finish'))

await setupNuxt()
vi.resetModules()
}

Expand Down
Loading