From ea7002b012f8205cdc4394e5f353212b43e6a16d Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Sun, 11 Oct 2026 11:13:56 +0900 Subject: [PATCH 01/10] test: add tests for registerEndpoint with FormData --- .../app-vitest-full/tests/nuxt/fetch.spec.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts index 739899bd1..5bb7b13bc 100644 --- a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts @@ -3,7 +3,7 @@ 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, setCookie } from 'h3' +import { createApp, eventHandler, toNodeListener, readBody, getHeaders, getQuery, setCookie, readFormData, readMultipartFormData } from 'h3' import FetchComponent from '~/components/FetchComponent.vue' @@ -190,6 +190,49 @@ describe('server mocks and data fetching', () => { expect(await fetch(request, { method: 'POST', body: '[1]' }).then(res => res.json())).toMatchObject({ title: 'with-request', data: [1] }) }) + it('can mock fetch requests with FormData', async () => { + registerEndpoint('/with-form-data', { + method: 'POST', + handler: event => readFormData(event).then(form => [...form.entries()]), + }) + + const createBody = () => { + const form = new FormData() + form.append('Name', 'Nuxt') + form.append('Message', 'Hello') + return form + } + const expected = [['Name', 'Nuxt'], ['Message', 'Hello']] + + expect(await $fetch('/with-form-data', { method: 'post', body: createBody() })).toEqual(expected) + expect(await fetch('/with-form-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) + }) + + it('can mock fetch requests with FormData with files', async () => { + registerEndpoint('/with-form-data-with-file', { + method: 'POST', + handler: event => readMultipartFormData(event).then( + parts => parts?.map(part => ({ ...part, data: part.data.toString('utf8') })) ?? []), + }) + + const createBody = () => { + const form = new FormData() + form.append('meta', 'this is meta data') + form.append('file', new File([Buffer.from('this is file 1', 'utf8')], 'hello1.txt', { type: 'text/plain' })) + form.append('file', new File([Buffer.from('this is file 2', 'utf8')], 'hello2.txt', { type: 'text/plain' })) + return form + } + + const expected = [ + { name: 'meta', data: 'this is meta data' }, + { name: 'file', type: 'text/plain', filename: 'hello1.txt', data: 'this is file 1' }, + { name: 'file', type: 'text/plain', filename: 'hello2.txt', data: 'this is file 2' }, + ] + + expect(await $fetch('/with-form-data-with-file', { method: 'post', body: createBody() })).toMatchObject(expected) + expect(await fetch('/with-form-data-with-file', { method: 'post', body: createBody() }).then(r => r.json())).toMatchObject(expected) + }) + it('can mock fetch requests with URL', async () => { registerEndpoint('http://localhost:3000/with-url', { method: 'GET', From 3a54e3b96af46780c9a45f4c6b30880812cb3a67 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Sun, 11 Oct 2026 19:15:24 +0900 Subject: [PATCH 02/10] support FormData in jsdom registerEndpoint --- .../components/FormBlobSubmit.vue | 30 ++++++++++ .../components/FormMultipartSubmit.vue | 37 ++++++++++++ examples/app-vitest-full/nuxt.config.ts | 2 +- examples/app-vitest-full/package.json | 2 +- .../app-vitest-full/pages/forms/files.vue | 12 ++++ .../server/api/forms/blob.post.ts | 6 ++ .../server/api/forms/mutlipart.post.ts | 6 ++ .../app-vitest-full/tests/nuxt/fetch.spec.ts | 20 ++++++- src/environments/vitest/env/jsdom.ts | 59 +++++++++++++++++++ src/runtime/shared/environment.ts | 16 ----- 10 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 examples/app-vitest-full/components/FormBlobSubmit.vue create mode 100644 examples/app-vitest-full/components/FormMultipartSubmit.vue create mode 100644 examples/app-vitest-full/pages/forms/files.vue create mode 100644 examples/app-vitest-full/server/api/forms/blob.post.ts create mode 100644 examples/app-vitest-full/server/api/forms/mutlipart.post.ts diff --git a/examples/app-vitest-full/components/FormBlobSubmit.vue b/examples/app-vitest-full/components/FormBlobSubmit.vue new file mode 100644 index 000000000..2a1db186e --- /dev/null +++ b/examples/app-vitest-full/components/FormBlobSubmit.vue @@ -0,0 +1,30 @@ + + + diff --git a/examples/app-vitest-full/components/FormMultipartSubmit.vue b/examples/app-vitest-full/components/FormMultipartSubmit.vue new file mode 100644 index 000000000..c7de32d2d --- /dev/null +++ b/examples/app-vitest-full/components/FormMultipartSubmit.vue @@ -0,0 +1,37 @@ + + + diff --git a/examples/app-vitest-full/nuxt.config.ts b/examples/app-vitest-full/nuxt.config.ts index d5c88599c..1a892795f 100644 --- a/examples/app-vitest-full/nuxt.config.ts +++ b/examples/app-vitest-full/nuxt.config.ts @@ -26,7 +26,7 @@ export default defineNuxtConfig({ }, }, testUtils: { - startOnBoot: true, + startOnBoot: process.env.START_ON_BOOT === '1', logToConsole: true, vitestConfig: { setupFiles: ['./tests/setup/mocks'], diff --git a/examples/app-vitest-full/package.json b/examples/app-vitest-full/package.json index 2477e216e..38f932f8c 100644 --- a/examples/app-vitest-full/package.json +++ b/examples/app-vitest-full/package.json @@ -8,7 +8,7 @@ "dev:prepare": "nuxt prepare", "generate": "nuxt generate", "preview": "nuxt preview", - "test:dev": "nuxt dev --no-fork", + "test:dev": "START_ON_BOOT=1 nuxt dev --no-fork", "test:unit": "vitest", "test:types": "nuxt prepare && vue-tsc --noEmit", "test:jsdom": "VITEST_DOM_ENV=jsdom pnpm test:unit --run", diff --git a/examples/app-vitest-full/pages/forms/files.vue b/examples/app-vitest-full/pages/forms/files.vue new file mode 100644 index 000000000..e16fa2f33 --- /dev/null +++ b/examples/app-vitest-full/pages/forms/files.vue @@ -0,0 +1,12 @@ + diff --git a/examples/app-vitest-full/server/api/forms/blob.post.ts b/examples/app-vitest-full/server/api/forms/blob.post.ts new file mode 100644 index 000000000..effa54e12 --- /dev/null +++ b/examples/app-vitest-full/server/api/forms/blob.post.ts @@ -0,0 +1,6 @@ +import { defineEventHandler, getHeaders, readRawBody } from 'h3' + +export default defineEventHandler(async (event) => { + const body = await readRawBody(event, 'utf8') + return { body, headers: getHeaders(event) } +}) diff --git a/examples/app-vitest-full/server/api/forms/mutlipart.post.ts b/examples/app-vitest-full/server/api/forms/mutlipart.post.ts new file mode 100644 index 000000000..2fd78049d --- /dev/null +++ b/examples/app-vitest-full/server/api/forms/mutlipart.post.ts @@ -0,0 +1,6 @@ +import { defineEventHandler, readMultipartFormData } from 'h3' + +export default defineEventHandler(async (event) => { + const parts = await readMultipartFormData(event) + return parts?.map(part => ({ ...part, data: part.data.toString('utf8') })) ?? [] +}) diff --git a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts index 5bb7b13bc..c4773235b 100644 --- a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts @@ -3,7 +3,7 @@ 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, setCookie, readFormData, readMultipartFormData } from 'h3' +import { createApp, eventHandler, toNodeListener, readBody, getHeaders, getQuery, setCookie, readFormData } from 'h3' import FetchComponent from '~/components/FetchComponent.vue' @@ -208,11 +208,25 @@ describe('server mocks and data fetching', () => { expect(await fetch('/with-form-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) }) + it('can mock fetch requests with Blob', async () => { + registerEndpoint('/with-blob-data', { + method: 'POST', + handler: await import('#server/api/forms/blob.post').then(r => r.default), + }) + + const createBody = () => { + return new File([Buffer.from('this is file', 'utf8')], 'hello1.txt', { type: 'text/plain' }) + } + const expected = { body: 'this is file', headers: { 'content-type': 'text/plain' } } + + expect(await $fetch('/with-blob-data', { method: 'post', body: createBody() })).toEqual(expected) + expect(await fetch('/with-blob-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) + }) + it('can mock fetch requests with FormData with files', async () => { registerEndpoint('/with-form-data-with-file', { method: 'POST', - handler: event => readMultipartFormData(event).then( - parts => parts?.map(part => ({ ...part, data: part.data.toString('utf8') })) ?? []), + handler: await import('#server/api/forms/mutlipart.post').then(r => r.default), }) const createBody = () => { diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index 2228e5f9c..a50627e3b 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -43,6 +43,47 @@ export default async function (global, { jsdom = {} }) { // The scrollTo implementation from JSDOM throws a "Not Implemented" error window.scrollTo = () => {} + window.URLSearchParams = globalThis.URLSearchParams + + const { Blob: NodeBlob, File: NodeFile, FormData: NodeFormData } = globalThis + + const getImplement = await loadGetImplement() + + const toNodeBinData = (val: Blob | File) => { + const impl = getImplement<{ _bytes?: ArrayBuffer, _buffer: ArrayBuffer }>(val) + const bits = [impl?._bytes ?? impl?._buffer ?? val] + if (val instanceof window.File) { + const { name: filename, type, lastModified } = val + return new NodeFile(bits, filename, { type, lastModified }) + } + return new NodeBlob(bits, { type: val.type }) + } + + window.Request = class Request extends globalThis.Request { + constructor(input: RequestInfo, init?: RequestInit) { + if (init?.body) { + init = { ...init } + if (init.body instanceof window.FormData) { + const form = new NodeFormData() + init.body.forEach((val, key) => { + form.append(key, val instanceof window.Blob ? toNodeBinData(val) : val) + }) + init.body = form + } + else if (init.body instanceof window.Blob) { + init.body = toNodeBinData(init.body) + } + } + + if (typeof input === 'string') { + super(new URL(input, window.location.origin), init) + } + else { + super(input, init) + } + } + } + return { window, teardown() { @@ -50,3 +91,21 @@ export default async function (global, { jsdom = {} }) { }, } } + +let _getImplement: (o: unknown) => I | undefined +async function loadGetImplement() { + async function load(): Promise { + for (const path of ['lib/generated/idl', 'lib/jsdom/living/generated']) { + try { + const { implForWrapper } = await import(/* @vite-ignore */ ['jsdom', path, 'utils.js'].join('/')).then(r => r.default) + return implForWrapper + } + catch { + // ignore + } + } + console.warn('[@nuxt/test-utils] jsdom\'s `implForWrapper` not found in nuxt environment.') + return () => {} + } + return _getImplement ||= await load() +} diff --git a/src/runtime/shared/environment.ts b/src/runtime/shared/environment.ts index c8b5fdac9..a8b63cc16 100644 --- a/src/runtime/shared/environment.ts +++ b/src/runtime/shared/environment.ts @@ -36,22 +36,6 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: NuxtEnvir }) createElementAndAppend(win, appConfig?.teleportTag || 'div', appConfig?.teleportAttrs || { id: 'teleports' }) - if (!win.fetch || !('Request' in win)) { - // @ts-expect-error missing from window types - win.URLSearchParams = globalThis.URLSearchParams - // @ts-expect-error missing from window types - win.Request ??= class Request extends globalThis.Request { - constructor(input: RequestInfo, init?: RequestInit) { - if (typeof input === 'string') { - super(new URL(input, win.location.origin), init) - } - else { - super(input, init) - } - } - } - } - const res = environmentOptions.nuxt.h3Version === 2 ? await createFetchForH3V2() : await createFetchForH3V1() From e79e924d6353ae56e690157a9b92d18011e46e4d Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Sun, 11 Oct 2026 20:42:04 +0900 Subject: [PATCH 03/10] cleanup and add test to nitro-v3 --- .../server/api/forms/blob.post.ts | 4 +- .../app-vitest-full/tests/nuxt/fetch.spec.ts | 64 ++++++++--------- examples/nitro-v3/test/nuxt/fetch.spec.ts | 70 ++++++++++++++++++- src/environments/vitest/env/jsdom.ts | 34 ++++----- 4 files changed, 121 insertions(+), 51 deletions(-) diff --git a/examples/app-vitest-full/server/api/forms/blob.post.ts b/examples/app-vitest-full/server/api/forms/blob.post.ts index effa54e12..1562ed1ab 100644 --- a/examples/app-vitest-full/server/api/forms/blob.post.ts +++ b/examples/app-vitest-full/server/api/forms/blob.post.ts @@ -1,6 +1,6 @@ -import { defineEventHandler, getHeaders, readRawBody } from 'h3' +import { defineEventHandler, getHeader, readRawBody } from 'h3' export default defineEventHandler(async (event) => { const body = await readRawBody(event, 'utf8') - return { body, headers: getHeaders(event) } + return { body, contentType: getHeader(event, 'content-type') } }) diff --git a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts index c4773235b..478dcf824 100644 --- a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts @@ -16,7 +16,7 @@ describe('server mocks and data fetching', () => { const server = await listen(toNodeListener(app)) const urls = await server.getURLs() const { url } = urls[0]! - expect(await $fetch('/todos/1', { baseURL: url })).toMatchObject({ + expect(await $fetch('/todos/1', { baseURL: url })).toMatchObject({ id: 1, }) await server.close() @@ -37,7 +37,7 @@ describe('server mocks and data fetching', () => { title: 'mocked', })) expect( - await $fetch('/with-query', { query: { test: true } }), + await $fetch('/with-query', { query: { test: true } }), ).toMatchObject({ title: 'mocked', }) @@ -56,16 +56,16 @@ describe('server mocks and data fetching', () => { it('can override and remove request mocks', async () => { const unsubFirst = registerEndpoint('/overrides', () => ({ title: 'first' })) - expect(await $fetch('/overrides')).toStrictEqual({ title: 'first' }) + expect(await $fetch('/overrides')).toStrictEqual({ title: 'first' }) const unsubSecond = registerEndpoint('/overrides', () => ({ title: 'second' })) - expect(await $fetch('/overrides')).toStrictEqual({ title: 'second' }) + expect(await $fetch('/overrides')).toStrictEqual({ title: 'second' }) unsubSecond() - expect(await $fetch('/overrides')).toStrictEqual({ title: 'first' }) + expect(await $fetch('/overrides')).toStrictEqual({ title: 'first' }) unsubFirst() - await expect($fetch('/overrides')).rejects.toMatchInlineSnapshot(`[FetchError: [GET] "/overrides": 404 Cannot find any path matching /overrides.]`) + await expect($fetch('/overrides')).rejects.toMatchInlineSnapshot(`[FetchError: [GET] "/overrides": 404 Cannot find any path matching /overrides.]`) }) it('can mock fetch requests with explicit methods', async () => { @@ -77,10 +77,10 @@ describe('server mocks and data fetching', () => { method: 'GET', handler: () => ({ method: 'GET' }), }) - expect(await $fetch('/method', { method: 'POST' })).toMatchObject({ + expect(await $fetch('/method', { method: 'POST' })).toMatchObject({ method: 'POST', }) - expect(await $fetch('/method')).toMatchObject({ method: 'GET' }) + expect(await $fetch('/method')).toMatchObject({ method: 'GET' }) }) it('can mock native fetch requests', async () => { @@ -120,7 +120,7 @@ describe('server mocks and data fetching', () => { }, }) - expect(await $fetch('/with-data', { + expect(await $fetch('/with-data', { method: 'POST', body: { data: 'data' }, headers: { 'x-test': 'test' }, @@ -149,21 +149,21 @@ describe('server mocks and data fetching', () => { }, }) - expect(await $fetch('/with-data', { query: { q: 1 } })).toMatchObject({ query: { q: '1' } }) + expect(await $fetch('/with-data', { query: { q: 1 } })).toMatchObject({ query: { q: '1' } }) expect(await fetch('/with-data?q=1').then(res => res.json())).toMatchObject({ query: { q: '1' } }) }) it('can mock fetch requests with url including query param', async () => { registerEndpoint('/with-url-including-query-param/1?q=1', () => 'ok') - expect(await $fetch('/with-url-including-query-param/1?q=1')).toBe('ok') + expect(await $fetch('/with-url-including-query-param/1?q=1')).toBe('ok') expect(await fetch('/with-url-including-query-param/1?q=1').then(res => res.text())).toBe('ok') }) it('fails when query params do not match', async () => { registerEndpoint('/with-url-including-query-param/2?q=2', () => 'ok') - await expect($fetch('/with-url-including-query-param/2?q=3')).rejects.toMatchObject({ status: 404 }) + await expect($fetch('/with-url-including-query-param/2?q=3')).rejects.toMatchObject({ status: 404 }) expect(await fetch('/with-url-including-query-param/2?q=3').then(r => r.status)).toBe(404) }) @@ -183,10 +183,10 @@ describe('server mocks and data fetching', () => { const request = new Request('/with-request?q=1', { headers: { 'content-type': 'application/json' } }) - expect(await $fetch(request)).toMatchObject({ title: 'with-request', data: { q: '1' } }) + expect(await $fetch(request)).toMatchObject({ title: 'with-request', data: { q: '1' } }) expect(await fetch(request).then(res => res.json())).toMatchObject({ title: 'with-request', data: { q: '1' } }) - expect(await $fetch(request, { method: 'POST', body: [1] })).toMatchObject({ title: 'with-request', data: [1] }) + expect(await $fetch(request, { method: 'POST', body: [1] })).toMatchObject({ title: 'with-request', data: [1] }) expect(await fetch(request, { method: 'POST', body: '[1]' }).then(res => res.json())).toMatchObject({ title: 'with-request', data: [1] }) }) @@ -217,7 +217,7 @@ describe('server mocks and data fetching', () => { const createBody = () => { return new File([Buffer.from('this is file', 'utf8')], 'hello1.txt', { type: 'text/plain' }) } - const expected = { body: 'this is file', headers: { 'content-type': 'text/plain' } } + const expected = { body: 'this is file', contentType: 'text/plain' } expect(await $fetch('/with-blob-data', { method: 'post', body: createBody() })).toEqual(expected) expect(await fetch('/with-blob-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) @@ -289,18 +289,18 @@ describe('server mocks and data fetching', () => { }, }) - expect(await fetch('/1')).toMatchObject({ + expect(await fetch('/1')).toMatchObject({ title: 'title from mocked api1', headers: { authorization: 'Bearer ' }, }) - expect(await fetch('/2')).toMatchObject({ + expect(await fetch('/2')).toMatchObject({ title: 'title from mocked api2', headers: { authorization: 'Bearer ' }, }) - await expect(fetch('/error')).rejects.toMatchObject({ status: 500, statusText: 'Mock Server Error' }) - await expect(fetch('/error', { baseURL: '"INVALID"' })).rejects.toThrow() + await expect(fetch('/error')).rejects.toMatchObject({ status: 500, statusText: 'Mock Server Error' }) + await expect(fetch('/error', { baseURL: '"INVALID"' })).rejects.toThrow() expect(onRequest).toHaveBeenCalledTimes(4) expect(onResponse).toHaveBeenCalledTimes(3) @@ -315,30 +315,30 @@ describe('server mocks and data fetching', () => { registerEndpoint('/with-once-options?q=2', { handler: () => '4', once: true }) registerEndpoint('/with-once-options?q=2', { handler: () => '5', once: true }) - expect(await $fetch('/with-once-options?q=1')).toBe('1') - expect(await $fetch('/with-once-options?q=1')).toBe('2') - await expect($fetch('/with-once-options?q=1')).rejects.toMatchObject({ status: 404 }) - expect(await $fetch('/with-once-options?q=2')).toBe('5') - expect(await $fetch('/with-once-options?q=2')).toBe('4') - expect(await $fetch('/with-once-options?q=2')).toBe('3') - await expect($fetch('/with-once-options?q=2')).rejects.toMatchObject({ status: 404 }) + expect(await $fetch('/with-once-options?q=1')).toBe('1') + expect(await $fetch('/with-once-options?q=1')).toBe('2') + await expect($fetch('/with-once-options?q=1')).rejects.toMatchObject({ status: 404 }) + expect(await $fetch('/with-once-options?q=2')).toBe('5') + expect(await $fetch('/with-once-options?q=2')).toBe('4') + expect(await $fetch('/with-once-options?q=2')).toBe('3') + await expect($fetch('/with-once-options?q=2')).rejects.toMatchObject({ status: 404 }) }) it('endpoint priority 1', async () => { registerEndpoint('/endpoint/priority/1?q=1', { handler: () => '1' }) registerEndpoint('/endpoint/priority/1', { handler: () => '2' }) - expect(await $fetch('/endpoint/priority/1')).toBe('2') - expect(await $fetch('/endpoint/priority/1?q=1')).toBe('1') - expect(await $fetch('/endpoint/priority/1?q=1')).toBe('1') + expect(await $fetch('/endpoint/priority/1')).toBe('2') + expect(await $fetch('/endpoint/priority/1?q=1')).toBe('1') + expect(await $fetch('/endpoint/priority/1?q=1')).toBe('1') }) it('endpoint priority 2', async () => { registerEndpoint('/endpoint/priority/2', { handler: () => '1' }) registerEndpoint('/endpoint/priority/2?q=1', { handler: () => '2' }) - expect(await $fetch('/endpoint/priority/2')).toBe('1') - expect(await $fetch('/endpoint/priority/2?q=1')).toBe('1') - expect(await $fetch('/endpoint/priority/2?q=1')).toBe('1') + expect(await $fetch('/endpoint/priority/2')).toBe('1') + expect(await $fetch('/endpoint/priority/2?q=1')).toBe('1') + expect(await $fetch('/endpoint/priority/2?q=1')).toBe('1') }) }) diff --git a/examples/nitro-v3/test/nuxt/fetch.spec.ts b/examples/nitro-v3/test/nuxt/fetch.spec.ts index f0b135035..d934acbb9 100644 --- a/examples/nitro-v3/test/nuxt/fetch.spec.ts +++ b/examples/nitro-v3/test/nuxt/fetch.spec.ts @@ -1,7 +1,7 @@ import { registerEndpoint } from '@nuxt/test-utils/runtime' import { it, expect, describe, vi } from 'vitest' -import { readBody, getQuery, getRequestHeaders } from 'nuxt/server' +import { readBody, getQuery, getRequestHeaders, getRequestHeader } from 'nuxt/server' describe('registerEndpoint tests', () => { it('works with h3 v2 syntax', async () => { @@ -162,6 +162,74 @@ describe('registerEndpoint tests', () => { expect(await fetch(request, { method: 'POST', body: '[1]' }).then(res => res.json())).toMatchObject({ title: 'with-request', data: [1] }) }) + it('can mock fetch requests with FormData', async () => { + registerEndpoint('/with-form-data', { + method: 'POST', + handler: event => event.req.formData().then(form => [...form.entries()]), + }) + + const createBody = () => { + const form = new FormData() + form.append('Name', 'Nuxt') + form.append('Message', 'Hello') + return form + } + const expected = [['Name', 'Nuxt'], ['Message', 'Hello']] + + expect(await $fetch('/with-form-data', { method: 'post', body: createBody() })).toEqual(expected) + expect(await fetch('/with-form-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) + }) + + it('can mock fetch requests with Blob', async () => { + registerEndpoint('/with-blob-data', { + method: 'POST', + handler: async (event) => { + const body = await event.req.text() + return { body, contentType: getRequestHeader(event, 'content-type') } + }, + }) + + const createBody = () => { + return new File([Buffer.from('this is file', 'utf8')], 'hello1.txt', { type: 'text/plain' }) + } + const expected = { body: 'this is file', contentType: 'text/plain' } + + expect(await $fetch('/with-blob-data', { method: 'post', body: createBody() })).toEqual(expected) + expect(await fetch('/with-blob-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) + }) + + it('can mock fetch requests with FormData with files', async () => { + registerEndpoint('/with-form-data-with-file', { + method: 'POST', + handler: async (event) => { + const result = [] as Record[] + for (const [name, val] of await event.req.formData()) { + result.push(val instanceof Blob + ? { name, type: val.type, filename: val.name, data: await val.text() } + : { name, data: val }) + } + return result + }, + }) + + const createBody = () => { + const form = new FormData() + form.append('meta', 'this is meta data') + form.append('file', new File([Buffer.from('this is file 1', 'utf8')], 'hello1.txt', { type: 'text/plain' })) + form.append('file', new File([Buffer.from('this is file 2', 'utf8')], 'hello2.txt', { type: 'text/plain' })) + return form + } + + const expected = [ + { name: 'meta', data: 'this is meta data' }, + { name: 'file', type: 'text/plain', filename: 'hello1.txt', data: 'this is file 1' }, + { name: 'file', type: 'text/plain', filename: 'hello2.txt', data: 'this is file 2' }, + ] + + expect(await $fetch('/with-form-data-with-file', { method: 'post', body: createBody() })).toMatchObject(expected) + expect(await fetch('/with-form-data-with-file', { method: 'post', body: createBody() }).then(r => r.json())).toMatchObject(expected) + }) + it('can mock fetch requests with URL', async () => { registerEndpoint('http://localhost:3000/with-url', { method: 'GET', diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index a50627e3b..6b96776ae 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -59,27 +59,29 @@ export default async function (global, { jsdom = {} }) { return new NodeBlob(bits, { type: val.type }) } - window.Request = class Request extends globalThis.Request { - constructor(input: RequestInfo, init?: RequestInit) { - if (init?.body) { - init = { ...init } - if (init.body instanceof window.FormData) { - const form = new NodeFormData() - init.body.forEach((val, key) => { - form.append(key, val instanceof window.Blob ? toNodeBinData(val) : val) - }) - init.body = form - } - else if (init.body instanceof window.Blob) { - init.body = toNodeBinData(init.body) - } + const toNodePayload = (init?: RequestInit) => { + if (init?.body) { + if (init.body instanceof window.FormData) { + const form = new NodeFormData() + init.body.forEach((val, key) => { + form.append(key, val instanceof window.Blob ? toNodeBinData(val) : val) + }) + return { ...init, body: form } } + else if (init.body instanceof window.Blob) { + return { ...init, body: toNodeBinData(init.body) } + } + } + return init + } + window.Request = class Request extends globalThis.Request { + constructor(input: RequestInfo, init?: RequestInit) { if (typeof input === 'string') { - super(new URL(input, window.location.origin), init) + super(new URL(input, window.location.origin), toNodePayload(init)) } else { - super(input, init) + super(input, toNodePayload(init)) } } } From 925fe8ee421377a0bad6cc75cb70906be0f9e804 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Mon, 12 Oct 2026 06:44:01 +0900 Subject: [PATCH 04/10] rename local func name --- src/environments/vitest/env/jsdom.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index 6b96776ae..3b0f39351 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -49,8 +49,8 @@ export default async function (global, { jsdom = {} }) { const getImplement = await loadGetImplement() - const toNodeBinData = (val: Blob | File) => { - const impl = getImplement<{ _bytes?: ArrayBuffer, _buffer: ArrayBuffer }>(val) + const toNodeBlobLike = (val: Blob | File) => { + const impl = getImplement<{ _bytes?: ArrayBuffer, _buffer?: ArrayBuffer }>(val) const bits = [impl?._bytes ?? impl?._buffer ?? val] if (val instanceof window.File) { const { name: filename, type, lastModified } = val @@ -64,12 +64,12 @@ export default async function (global, { jsdom = {} }) { if (init.body instanceof window.FormData) { const form = new NodeFormData() init.body.forEach((val, key) => { - form.append(key, val instanceof window.Blob ? toNodeBinData(val) : val) + form.append(key, val instanceof window.Blob ? toNodeBlobLike(val) : val) }) return { ...init, body: form } } else if (init.body instanceof window.Blob) { - return { ...init, body: toNodeBinData(init.body) } + return { ...init, body: toNodeBlobLike(init.body) } } } return init From f3ea847c5cd71d9fade23d9483a4eacee7ea1d04 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Mon, 12 Oct 2026 07:18:22 +0900 Subject: [PATCH 05/10] pass teardownTimeout to `AfterAll` in cucumber --- src/e2e/setup/cucumber.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/e2e/setup/cucumber.ts b/src/e2e/setup/cucumber.ts index 466feba25..3eda195c9 100644 --- a/src/e2e/setup/cucumber.ts +++ b/src/e2e/setup/cucumber.ts @@ -7,5 +7,5 @@ export default async function setupCucumber(hooks: TestHooks) { Before(hooks.beforeEach) After(hooks.afterEach) - AfterAll(hooks.afterAll) + AfterAll({ timeout: hooks.ctx.options.teardownTimeout }, hooks.afterAll) } From a9c9c9cae640d80b817adf65fe094ddcfd33bb83 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Mon, 12 Oct 2026 07:42:06 +0900 Subject: [PATCH 06/10] add blob file testcase to fetch.spec --- .../app-vitest-full/tests/nuxt/fetch.spec.ts | 19 ++++++++++++++-- examples/nitro-v3/test/nuxt/fetch.spec.ts | 22 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts index 478dcf824..447f1c647 100644 --- a/examples/app-vitest-full/tests/nuxt/fetch.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/fetch.spec.ts @@ -215,14 +215,29 @@ describe('server mocks and data fetching', () => { }) const createBody = () => { - return new File([Buffer.from('this is file', 'utf8')], 'hello1.txt', { type: 'text/plain' }) + return new Blob([Buffer.from('this is blob', 'utf8')], { type: 'text/plain' }) } - const expected = { body: 'this is file', contentType: 'text/plain' } + const expected = { body: 'this is blob', contentType: 'text/plain' } expect(await $fetch('/with-blob-data', { method: 'post', body: createBody() })).toEqual(expected) expect(await fetch('/with-blob-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) }) + it('can mock fetch requests with File', async () => { + registerEndpoint('/with-file-data', { + method: 'POST', + handler: await import('#server/api/forms/blob.post').then(r => r.default), + }) + + const createBody = () => { + return new File([Buffer.from('this is file', 'utf8')], 'hello1.txt', { type: 'text/plain' }) + } + const expected = { body: 'this is file', contentType: 'text/plain' } + + expect(await $fetch('/with-file-data', { method: 'post', body: createBody() })).toEqual(expected) + expect(await fetch('/with-file-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) + }) + it('can mock fetch requests with FormData with files', async () => { registerEndpoint('/with-form-data-with-file', { method: 'POST', diff --git a/examples/nitro-v3/test/nuxt/fetch.spec.ts b/examples/nitro-v3/test/nuxt/fetch.spec.ts index d934acbb9..782d1fe4b 100644 --- a/examples/nitro-v3/test/nuxt/fetch.spec.ts +++ b/examples/nitro-v3/test/nuxt/fetch.spec.ts @@ -190,14 +190,32 @@ describe('registerEndpoint tests', () => { }) const createBody = () => { - return new File([Buffer.from('this is file', 'utf8')], 'hello1.txt', { type: 'text/plain' }) + return new Blob([Buffer.from('this is blob', 'utf8')], { type: 'text/plain' }) } - const expected = { body: 'this is file', contentType: 'text/plain' } + const expected = { body: 'this is blob', contentType: 'text/plain' } expect(await $fetch('/with-blob-data', { method: 'post', body: createBody() })).toEqual(expected) expect(await fetch('/with-blob-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) }) + it('can mock fetch requests with File', async () => { + registerEndpoint('/with-file-data', { + method: 'POST', + handler: async (event) => { + const body = await event.req.text() + return { body, contentType: getRequestHeader(event, 'content-type') } + }, + }) + + const createBody = () => { + return new File([Buffer.from('this is file', 'utf8')], 'hello1.txt', { type: 'text/plain' }) + } + const expected = { body: 'this is file', contentType: 'text/plain' } + + expect(await $fetch('/with-file-data', { method: 'post', body: createBody() })).toEqual(expected) + expect(await fetch('/with-file-data', { method: 'post', body: createBody() }).then(res => res.json())).toEqual(expected) + }) + it('can mock fetch requests with FormData with files', async () => { registerEndpoint('/with-form-data-with-file', { method: 'POST', From a02cf75b70b35fdbaad286b0b4bda372029a1079 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Mon, 12 Oct 2026 14:38:22 +0900 Subject: [PATCH 07/10] add typeof check for implForWrapper --- src/environments/vitest/env/jsdom.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index 3b0f39351..e15c06e4a 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -100,7 +100,9 @@ async function loadGetImplement() { for (const path of ['lib/generated/idl', 'lib/jsdom/living/generated']) { try { const { implForWrapper } = await import(/* @vite-ignore */ ['jsdom', path, 'utils.js'].join('/')).then(r => r.default) - return implForWrapper + if (typeof implForWrapper === 'function') { + return implForWrapper + } } catch { // ignore From 941470501ca4c5a2a9d2ded8c315ad562dfb9389 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Mon, 12 Oct 2026 15:33:06 +0900 Subject: [PATCH 08/10] add warn when failing to get jsdom impl --- src/environments/vitest/env/jsdom.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index e15c06e4a..76db5d381 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -51,6 +51,10 @@ export default async function (global, { jsdom = {} }) { const toNodeBlobLike = (val: Blob | File) => { const impl = getImplement<{ _bytes?: ArrayBuffer, _buffer?: ArrayBuffer }>(val) + if (!impl) { + console.warn('Blob/File data in fetch requests may yield incorrect results.' + + 'Please update jsdom or open an issue on @nuxt/test-utils.') + } const bits = [impl?._bytes ?? impl?._buffer ?? val] if (val instanceof window.File) { const { name: filename, type, lastModified } = val From e46564deae41afa241b515ab431b7eedbd5a465a Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Mon, 12 Oct 2026 15:39:25 +0900 Subject: [PATCH 09/10] reword warning message --- src/environments/vitest/env/jsdom.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index 76db5d381..ea473087f 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -52,8 +52,7 @@ export default async function (global, { jsdom = {} }) { const toNodeBlobLike = (val: Blob | File) => { const impl = getImplement<{ _bytes?: ArrayBuffer, _buffer?: ArrayBuffer }>(val) if (!impl) { - console.warn('Blob/File data in fetch requests may yield incorrect results.' - + 'Please update jsdom or open an issue on @nuxt/test-utils.') + console.warn('Cannot read the bytes of Blob/File. Please update jsdom or report it to @nuxt/test-utils.') } const bits = [impl?._bytes ?? impl?._buffer ?? val] if (val instanceof window.File) { From e27cb202dbc2fce0b55f19a449c889fbd0dec824 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Wed, 14 Oct 2026 20:07:04 +0900 Subject: [PATCH 10/10] to fail on invalid state --- src/environments/vitest/env/jsdom.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index ea473087f..38d965343 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -50,16 +50,17 @@ export default async function (global, { jsdom = {} }) { const getImplement = await loadGetImplement() const toNodeBlobLike = (val: Blob | File) => { - const impl = getImplement<{ _bytes?: ArrayBuffer, _buffer?: ArrayBuffer }>(val) - if (!impl) { - console.warn('Cannot read the bytes of Blob/File. Please update jsdom or report it to @nuxt/test-utils.') + // `_buffer` usage can be removed when dropping support for jsdom v27.x + const impl = getImplement<{ _bytes?: Uint8Array, _buffer?: Uint8Array }>(val) + const bytes = impl?._bytes ?? impl?._buffer + if (!bytes) { + throw createJsdomCompatError('Cannot read the bytes of Blob/File') } - const bits = [impl?._bytes ?? impl?._buffer ?? val] if (val instanceof window.File) { const { name: filename, type, lastModified } = val - return new NodeFile(bits, filename, { type, lastModified }) + return new NodeFile([bytes], filename, { type, lastModified }) } - return new NodeBlob(bits, { type: val.type }) + return new NodeBlob([bytes], { type: val.type }) } const toNodePayload = (init?: RequestInit) => { @@ -100,7 +101,10 @@ export default async function (global, { jsdom = {} }) { let _getImplement: (o: unknown) => I | undefined async function loadGetImplement() { async function load(): Promise { - for (const path of ['lib/generated/idl', 'lib/jsdom/living/generated']) { + for (const path of [ + 'lib/generated/idl', // v28.1.0+ + 'lib/jsdom/living/generated', // v28.0.x and earlier + ]) { try { const { implForWrapper } = await import(/* @vite-ignore */ ['jsdom', path, 'utils.js'].join('/')).then(r => r.default) if (typeof implForWrapper === 'function') { @@ -111,8 +115,13 @@ async function loadGetImplement() { // ignore } } - console.warn('[@nuxt/test-utils] jsdom\'s `implForWrapper` not found in nuxt environment.') - return () => {} + return () => { + throw createJsdomCompatError('Cannot load jsdom\'s `implForWrapper` in nuxt environment') + } } return _getImplement ||= await load() } + +function createJsdomCompatError(message: string) { + return new Error(`${message}. Please report this to @nuxt/test-utils with your jsdom version.`) +}