Skip to content
Draft
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
1 change: 1 addition & 0 deletions examples/app-vitest-full/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"devDependencies": {
"@nuxt/test-utils": "latest",
"@testing-library/vue": "8.1.0",
"h3-next": "npm:h3@^2.0.1-rc.6",
"happy-dom": "20.0.11",
"jsdom": "27.3.0",
"listhen": "1.9.0",
Expand Down
9 changes: 3 additions & 6 deletions examples/app-vitest-full/tests/nuxt/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@ import { describe, expect, it, vi } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'

import { listen } from 'listhen'
import { createApp, eventHandler, toNodeListener, readBody, getHeaders, getQuery } from 'h3'
import { H3, eventHandler, toNodeListener, readBody, getHeaders, getQuery } from 'h3-next/generic'

import FetchComponent from '~/components/FetchComponent.vue'

describe('server mocks and data fetching', () => {
it('can use $fetch', async () => {
const app = createApp().use(
'/todos/1',
eventHandler(() => ({ id: 1 })),
)
const app = new H3().use('/todos/1', eventHandler(() => ({ id: 1 })))
const server = await listen(toNodeListener(app))
const urls = await server.getURLs()
const { url } = urls[0]!
Expand Down Expand Up @@ -54,7 +51,7 @@ describe('server mocks and data fetching', () => {
expect(await $fetch<unknown>('/overrides')).toStrictEqual({ title: 'first' })

unsubFirst()
await expect($fetch<unknown>('/overrides')).rejects.toMatchInlineSnapshot(`[FetchError: [GET] "/overrides": 404 Cannot find any path matching /overrides.]`)
await expect($fetch<unknown>('/overrides')).rejects.toMatchInlineSnapshot(`[FetchError: [GET] "/overrides": 404 Not Found]`)
})

it('can mock fetch requests with explicit methods', async () => {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"exsolve": "^1.0.8",
"fake-indexeddb": "^6.2.5",
"get-port-please": "^3.2.0",
"h3": "^1.15.4",
"h3-next": "npm:h3@^2.0.1-rc.6",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"node-fetch-native": "^1.6.7",
Expand Down Expand Up @@ -181,7 +181,7 @@
"vue": "^3.5.25"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
"node": "^20.11.1 || ^22.0.0 || >=24.0.0"
},
"packageManager": "[email protected]"
}
28 changes: 25 additions & 3 deletions pnpm-lock.yaml

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

10 changes: 7 additions & 3 deletions src/e2e/server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { x } from 'tinyexec'
import { getRandomPort, waitForPort } from 'get-port-please'
import type { FetchOptions } from 'ofetch'
import { $fetch as _$fetch, fetch as _fetch } from 'ofetch'
import { fetch as _fetch, createFetch } from 'ofetch'
import { resolve } from 'pathe'
import { joinURL } from 'ufo'
import { useTestContext } from './context'

const globalFetch = globalThis.fetch || _fetch

export interface StartServerOptions {
env?: Record<string, unknown>
}
Expand Down Expand Up @@ -84,10 +86,12 @@ export async function stopServer() {
}

export function fetch(path: string, options?: RequestInit) {
return _fetch(url(path), options)
return globalFetch(url(path), options)
}

export const $fetch = function (path: string, options?: FetchOptions) {
const _$fetch = createFetch({ fetch: globalFetch })

export const $fetch = function $fetch(path: string, options?: FetchOptions) {
return _$fetch(url(path), options)
} as (typeof globalThis)['$fetch']

Expand Down
1 change: 0 additions & 1 deletion src/e2e/setup/bun.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { TestHooks } from '../types'

export default async function setupBun(hooks: TestHooks) {
// @ts-expect-error we do not want bun types present in global context
const bunTest = await import('bun:test')

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
4 changes: 2 additions & 2 deletions src/environments/vitest/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { App } from 'h3'
import type { H3 } from 'h3-next'
import type { $Fetch } from 'nitropack'
import type { JSDOMOptions, HappyDOMOptions } from 'vitest/node'

export type NuxtBuiltinEnvironment = 'happy-dom' | 'jsdom'
export interface NuxtWindow extends Window {
__app: App
__app: H3
__registry: Set<string>
__NUXT_VITEST_ENVIRONMENT__?: boolean
__NUXT__: Record<string, unknown>
Expand Down
71 changes: 38 additions & 33 deletions src/runtime-utils/mock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import { defineEventHandler } from 'h3'
import type { App, EventHandler, HTTPMethod } from 'h3'
import { defineEventHandler } from 'h3-next/generic'
import type { H3, EventHandler, HTTPMethod } from 'h3-next'
import type {
ComponentInjectOptions,
ComponentOptionsMixin,
Expand Down Expand Up @@ -44,7 +44,7 @@ const endpointRegistry: Record<string, Array<{ handler: EventHandler, method?: H
*/
export function registerEndpoint(url: string, options: EventHandler | { handler: EventHandler, method?: HTTPMethod, once?: boolean }) {
// @ts-expect-error private property
const app: App = window.__app
const app: H3 = window.__app

if (!app) {
throw new Error('registerEndpoint() can only be used in a `@nuxt/test-utils` runtime environment')
Expand All @@ -53,40 +53,15 @@ export function registerEndpoint(url: string, options: EventHandler | { handler:
const config = typeof options === 'function' ? { handler: options, method: undefined, once: false } : options
config.handler = defineEventHandler(config.handler)

// @ts-expect-error private property
const hasBeenRegistered: boolean = window.__registry.has(url)

endpointRegistry[url] ||= []
endpointRegistry[url].push(config)

if (!hasBeenRegistered) {
// @ts-expect-error private property
window.__registry.add(url)

app.use('/_' + url, defineEventHandler(async (event) => {
const latestHandler = [...endpointRegistry[url] || []].reverse().find(config => config.method ? event.method === config.method : true)
if (!latestHandler) return

const result = await latestHandler.handler(event)

if (!latestHandler.once) return result

const index = endpointRegistry[url]?.indexOf(latestHandler)
if (index === undefined || index === -1) return result

endpointRegistry[url]?.splice(index, 1)
if (endpointRegistry[url]?.length === 0) {
// @ts-expect-error private property
window.__registry.delete(url)
}
// @ts-expect-error private property
window.__registry.add(url)

return result
}), {
match(_, event) {
return endpointRegistry[url]?.some(config => config.method ? event?.method === config.method : true) ?? false
},
})
}
// @ts-expect-error private property
app._registered
||= registerGlobalHandler(app)

return () => {
endpointRegistry[url]?.splice(endpointRegistry[url].indexOf(config), 1)
Expand Down Expand Up @@ -262,3 +237,33 @@ export function mockComponent(_path: string, _component: unknown): void {
'mockComponent() is a macro and it did not get transpiled. This may be an internal bug of @nuxt/test-utils.',
)
}

function registerGlobalHandler(app: H3) {
app.use(async (event) => {
const url = event.url.pathname.replace(/^\/_/, '')
const latestHandler = [...endpointRegistry[url] || []].reverse().find(config => config.method ? event.method === config.method : true)
if (!latestHandler) return

const result = await latestHandler.handler(event)

if (!latestHandler.once) return result

const index = endpointRegistry[url]?.indexOf(latestHandler)
if (index === undefined || index === -1) return result

endpointRegistry[url]?.splice(index, 1)
if (endpointRegistry[url]?.length === 0) {
// @ts-expect-error private property
window.__registry.delete(url)
}

return result
}, {
match: (event) => {
const url = event.url.pathname.replace(/^\/_/, '')
return endpointRegistry[url]?.some(config => config.method ? event?.method === config.method : true) ?? false
},
})

return true
}
48 changes: 4 additions & 44 deletions src/runtime/shared/environment.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createFetch } from 'ofetch'
import { joinURL } from 'ufo'
import { createApp, defineEventHandler, toNodeListener } from 'h3'
import { H3, defineEventHandler } from 'h3-next/generic'
import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from 'radix3'
import { fetchNodeRequestHandler } from 'node-mock-http'
import type { NuxtWindow } from '../../vitest-environment'
import type { NuxtEnvironmentOptions } from '../../config'

Expand Down Expand Up @@ -45,7 +44,7 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N
app.id = rootId
win.document.body.appendChild(app)

const h3App = createApp()
const h3App = new H3()

if (!win.fetch || !('Request' in win)) {
await import('node-fetch-native/polyfill')
Expand All @@ -64,8 +63,6 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N
}
}

const nodeHandler = toNodeListener(h3App)

const registry = new Set<string>()

const _fetch = fetch
Expand All @@ -89,11 +86,10 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N

const base = url.split('?')[0]!
if (registry.has(base) || registry.has(url)) {
url = '/_' + url
return h3App.fetch(new Request('/_' + url, init))
}
if (url.startsWith('/')) {
const response = await fetchNodeRequestHandler(nodeHandler, url, init)
return normalizeFetchResponse(response)
return new Response('Not Found', { status: 404, statusText: 'Not Found' })
}
return _fetch(input, _init)
}
Expand Down Expand Up @@ -144,39 +140,3 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N
console.info = consoleInfo
}
}

/** 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)
}
Loading