Skip to content

Commit 197caf2

Browse files
authored
fix(jsdom): pass down Node.js FormData to Request (#8880)
1 parent ca041f5 commit 197caf2

File tree

5 files changed

+153
-3
lines changed

5 files changed

+153
-3
lines changed

packages/vitest/src/integrations/env/happy-dom.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export default <Environment>{
7474
'AbortSignal',
7575
'URL',
7676
'URLSearchParams',
77+
'FormData',
7778
],
7879
})
7980

packages/vitest/src/integrations/env/jsdom-keys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ const LIVING_KEYS = [
141141
'ValidityState',
142142
'DOMParser',
143143
'XMLSerializer',
144-
'FormData',
145144
'XMLHttpRequestEventTarget',
146145
'XMLHttpRequestUpload',
147146
'XMLHttpRequest',
@@ -185,6 +184,7 @@ const LIVING_KEYS = [
185184
// 'AbortSignal',
186185
// 'URL',
187186
// 'URLSearchParams',
187+
// 'FormData',
188188
]
189189

190190
const OTHER_KEYS = [

packages/vitest/src/integrations/env/jsdom.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,15 @@ function catchWindowErrors(window: DOMWindow) {
3434
}
3535
}
3636

37+
let _FormData!: typeof FormData
38+
3739
export default <Environment>{
3840
name: 'jsdom',
3941
viteEnvironment: 'client',
4042
async setupVM({ jsdom = {} }) {
43+
// delay initialization because it takes ~1s
44+
_FormData = globalThis.FormData
45+
4146
const { CookieJar, JSDOM, ResourceLoader, VirtualConsole } = await import(
4247
'jsdom',
4348
)
@@ -79,6 +84,7 @@ export default <Environment>{
7984
// TODO: browser doesn't expose Buffer, but a lot of dependencies use it
8085
dom.window.Buffer = Buffer
8186
dom.window.jsdom = dom
87+
dom.window.FormData = createFormData(dom.window)
8288

8389
// inject web globals if they missing in JSDOM but otherwise available in Nodejs
8490
// https://nodejs.org/dist/latest/docs/api/globals.html
@@ -132,6 +138,9 @@ export default <Environment>{
132138
}
133139
},
134140
async setup(global, { jsdom = {} }) {
141+
// delay initialization because it takes ~1s
142+
_FormData = globalThis.FormData
143+
135144
const { CookieJar, JSDOM, ResourceLoader, VirtualConsole } = await import(
136145
'jsdom',
137146
)
@@ -175,6 +184,7 @@ export default <Environment>{
175184
const clearWindowErrors = catchWindowErrors(global)
176185

177186
global.jsdom = dom
187+
global.FormData = createFormData(dom.window)
178188

179189
return {
180190
teardown(global) {
@@ -189,6 +199,28 @@ export default <Environment>{
189199
},
190200
}
191201

202+
// Node.js 24 has a global FormData that Request accepts
203+
// FormData is not used anywhere else in JSDOM, so we can safely
204+
// override it with Node.js implementation, but keep the DOM behaviour
205+
// this is required because Request (and other fetch API)
206+
// are not implemented by JSDOM
207+
function createFormData(window: DOMWindow) {
208+
const JSDOMFormData = window.FormData
209+
if (!_FormData) {
210+
return JSDOMFormData
211+
}
212+
213+
return class FormData extends _FormData {
214+
constructor(...args: any[]) {
215+
super()
216+
const formData = new JSDOMFormData(...args)
217+
formData.forEach((value, key) => {
218+
this.append(key, value)
219+
})
220+
}
221+
}
222+
}
223+
192224
function patchAddEventListener(window: DOMWindow) {
193225
const JSDOMAbortSignal = window.AbortSignal
194226
const JSDOMAbortController = window.AbortController

test/core/test/environments/happy-dom.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,16 @@ test('request doesn\'t fail when using absolute url because it supports it', ()
2626
const _r = new Request('/api', { method: 'GET' })
2727
}).not.toThrow()
2828
})
29+
30+
test('can pass down a simple form data', async () => {
31+
const formData = new FormData()
32+
formData.set('hello', 'world')
33+
34+
await expect((async () => {
35+
const req = new Request('http://localhost:3000/', {
36+
method: 'POST',
37+
body: formData,
38+
})
39+
await req.formData()
40+
})()).resolves.not.toThrowError()
41+
})

test/core/test/environments/jsdom.spec.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { stripVTControlCharacters } from 'node:util'
44
import { processError } from '@vitest/utils/error'
5-
import { expect, test, vi } from 'vitest'
5+
import { describe, expect, test, vi } from 'vitest'
66

77
test('MessageChannel and MessagePort are available', () => {
88
expect(MessageChannel).toBeDefined()
@@ -22,7 +22,7 @@ test('fetch, Request, Response, and BroadcastChannel are available', () => {
2222
expect(BroadcastChannel).toBeDefined()
2323
})
2424

25-
test('Fetch API accepts other APIs', () => {
25+
test('Fetch API accepts other APIs', async () => {
2626
expect.soft(() => new Request('http://localhost', { signal: new AbortController().signal })).not.toThrowError()
2727
expect.soft(() => new Request('http://localhost', { method: 'POST', body: new FormData() })).not.toThrowError()
2828
expect.soft(() => new Request('http://localhost', { method: 'POST', body: new Blob() })).not.toThrowError()
@@ -40,6 +40,110 @@ test('Fetch API accepts other APIs', () => {
4040
expect.soft(() => new Request('http://localhost', { method: 'POST', body: searchParams })).not.toThrowError()
4141
})
4242

43+
describe('FormData', () => {
44+
test('can pass down a simple form data', async () => {
45+
const formData = new FormData()
46+
formData.set('hello', 'world')
47+
48+
await expect((async () => {
49+
const req = new Request('http://localhost:3000/', {
50+
method: 'POST',
51+
body: formData,
52+
})
53+
await req.formData()
54+
})()).resolves.not.toThrowError()
55+
})
56+
57+
test('can pass down form data from a FORM element', async () => {
58+
const form = document.createElement('form')
59+
document.body.append(form)
60+
61+
const hello = document.createElement('input')
62+
hello.value = 'world'
63+
hello.type = 'text'
64+
hello.name = 'hello'
65+
form.append(hello)
66+
67+
const formData = new FormData(form)
68+
expect([...formData.entries()]).toEqual([
69+
['hello', 'world'],
70+
])
71+
72+
await expect((async () => {
73+
const req = new Request('http://localhost:3000/', {
74+
method: 'POST',
75+
body: formData,
76+
})
77+
await req.formData()
78+
})()).resolves.not.toThrowError()
79+
})
80+
81+
test('can pass down form data from a FORM element with a submitter', async () => {
82+
const form = document.createElement('form')
83+
document.body.append(form)
84+
85+
const hello = document.createElement('input')
86+
hello.value = 'world'
87+
hello.type = 'text'
88+
hello.name = 'hello'
89+
form.append(hello)
90+
91+
const submitter = document.createElement('button')
92+
submitter.type = 'submit'
93+
submitter.name = 'include'
94+
submitter.value = 'submitter'
95+
form.append(submitter)
96+
97+
const formData = new FormData(form, submitter)
98+
expect([...formData.entries()]).toEqual([
99+
['hello', 'world'],
100+
['include', 'submitter'],
101+
])
102+
103+
await expect((async () => {
104+
const req = new Request('http://localhost:3000/', {
105+
method: 'POST',
106+
body: formData,
107+
})
108+
await req.formData()
109+
})()).resolves.not.toThrowError()
110+
})
111+
112+
// https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData#exceptions
113+
test('cannot pass down form data from a FORM element with a non-sumbit sumbitter', async () => {
114+
const form = document.createElement('form')
115+
document.body.append(form)
116+
const submitter = document.createElement('button')
117+
submitter.type = 'button'
118+
form.append(submitter)
119+
120+
expect(() => new FormData(form, submitter)).toThrowError(
121+
new TypeError('The specified element is not a submit button'),
122+
)
123+
})
124+
125+
test('cannot pass down form data from a FORM element with a sumbitter from a wrong form', async () => {
126+
const form1 = document.createElement('form')
127+
const form2 = document.createElement('form')
128+
document.body.append(form1, form2)
129+
const submitter = document.createElement('button')
130+
submitter.type = 'submit'
131+
form2.append(submitter)
132+
133+
try {
134+
// can't use toThrow here because DOMException is not an Error
135+
const _ = new FormData(form1, submitter)
136+
}
137+
catch (error: any) {
138+
const expectedError = new DOMException(
139+
'The specified element is not owned by this form element',
140+
'NotFoundError',
141+
)
142+
expect(error).toEqual(expectedError)
143+
}
144+
})
145+
})
146+
43147
test('DOM APIs accept AbortController', () => {
44148
const element = document.createElement('div')
45149
document.body.append(element)

0 commit comments

Comments
 (0)