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..1562ed1ab --- /dev/null +++ b/examples/app-vitest-full/server/api/forms/blob.post.ts @@ -0,0 +1,6 @@ +import { defineEventHandler, getHeader, readRawBody } from 'h3' + +export default defineEventHandler(async (event) => { + const body = await readRawBody(event, 'utf8') + return { body, contentType: getHeader(event, 'content-type') } +}) 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 739899bd1..447f1c647 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 } from 'h3' import FetchComponent from '~/components/FetchComponent.vue' @@ -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,13 +183,85 @@ 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] }) }) + 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 Blob', async () => { + registerEndpoint('/with-blob-data', { + method: 'POST', + handler: await import('#server/api/forms/blob.post').then(r => r.default), + }) + + const createBody = () => { + return new Blob([Buffer.from('this is blob', 'utf8')], { type: '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', + handler: await import('#server/api/forms/mutlipart.post').then(r => r.default), + }) + + 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', @@ -232,18 +304,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) @@ -258,30 +330,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..782d1fe4b 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,92 @@ 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 Blob([Buffer.from('this is blob', 'utf8')], { type: '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', + 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/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) } diff --git a/src/environments/vitest/env/jsdom.ts b/src/environments/vitest/env/jsdom.ts index 2228e5f9c..38d965343 100644 --- a/src/environments/vitest/env/jsdom.ts +++ b/src/environments/vitest/env/jsdom.ts @@ -43,6 +43,53 @@ 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 toNodeBlobLike = (val: Blob | File) => { + // `_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') + } + if (val instanceof window.File) { + const { name: filename, type, lastModified } = val + return new NodeFile([bytes], filename, { type, lastModified }) + } + return new NodeBlob([bytes], { type: val.type }) + } + + 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 ? toNodeBlobLike(val) : val) + }) + return { ...init, body: form } + } + else if (init.body instanceof window.Blob) { + return { ...init, body: toNodeBlobLike(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), toNodePayload(init)) + } + else { + super(input, toNodePayload(init)) + } + } + } + return { window, teardown() { @@ -50,3 +97,31 @@ 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', // 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') { + return implForWrapper + } + } + catch { + // ignore + } + } + 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.`) +} 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()