Skip to content

Commit 8112e50

Browse files
committed
test: cover the dev command takeover, handover and fork pool paths
1 parent 4af0dc3 commit 8112e50

1 file changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
importtype{TakeoverOptions,TakeoverResult}from'../../../src/dev/takeover'
2+
importtype{LockInfo}from'../../../src/utils/lockfile'
3+
4+
importprocessfrom'node:process'
5+
6+
import{runCommand}from'citty'
7+
import{afterEach,beforeEach,describe,expect,it,vi}from'vitest'
8+
9+
const{
10+
close,
11+
createFork,
12+
getFork,
13+
initialize,
14+
onFileChange,
15+
onReady,
16+
onRestart,
17+
isReusePortSupported,
18+
preflight,
19+
setupShortcuts,
20+
startWarming,
21+
takeOverDevServer,
22+
}=vi.hoisted(()=>({
23+
close: vi.fn(()=>Promise.resolve()),
24+
createFork: vi.fn(),
25+
getFork: vi.fn(),
26+
initialize: vi.fn(),
27+
isReusePortSupported: vi.fn(()=>Promise.resolve(true)),
28+
onFileChange: vi.fn(),
29+
onReady: vi.fn(),
30+
onRestart: vi.fn(),
31+
preflight: vi.fn((options: {cwd: string})=>Promise.resolve(options.cwd)),
32+
setupShortcuts: vi.fn(),
33+
startWarming: vi.fn(),
34+
takeOverDevServer: vi.fn<(buildDir: string,options?: TakeoverOptions)=>Promise<TakeoverResult>>(()=>Promise.resolve({action: 'none'})),
35+
}))
36+
37+
vi.mock('../../../src/dev/index',()=>({ initialize }))
38+
// Whether a restart hands over or is serialised depends on `SO_REUSEPORT`, which
39+
// Linux has and macOS and Windows do not, so the probe is stubbed rather than left
40+
// to whichever platform the suite runs on.
41+
vi.mock('../../../src/dev/listen',asyncimportOriginal=>({
42+
...awaitimportOriginal<typeofimport('../../../src/dev/listen')>(),
43+
isReusePortSupported,
44+
}))
45+
vi.mock('../../../src/dev/preflight',()=>({ preflight }))
46+
vi.mock('../../../src/dev/shortcuts',()=>({ setupShortcuts }))
47+
vi.mock('../../../src/utils/dev-server',()=>({resolveLockDir: (cwd: string)=>Promise.resolve(`${cwd}/.nuxt`)}))
48+
vi.mock('../../../src/dev/takeover',asyncimportOriginal=>({
49+
...awaitimportOriginal<typeofimport('../../../src/dev/takeover')>(),
50+
takeOverDevServer,
51+
}))
52+
vi.mock('../../../src/dev/pool',()=>({
53+
ForkPool: class{
54+
constructor(options: unknown){
55+
createFork(options)
56+
}
57+
58+
startWarming=startWarming
59+
getFork=getFork
60+
killAll=vi.fn()
61+
},
62+
}))
63+
64+
constdev=awaitimport('../../../src/commands/dev').then(r=>r.default)
65+
66+
constlistener={
67+
address: {address: '127.0.0.1',port: 3000},
68+
url: 'http://127.0.0.1:3000',
69+
close: ()=>Promise.resolve(),
70+
}
71+
72+
functionexistingLock(overrides: Partial<LockInfo>={}): LockInfo{
73+
return{
74+
pid: 4321,
75+
startedAt: Date.now(),
76+
command: 'dev',
77+
cwd: process.cwd(),
78+
interactive: false,
79+
port: 3000,
80+
url: 'http://localhost:3000',
81+
...overrides,
82+
}
83+
}
84+
85+
asyncfunctionrunDev(args: string[]=[]){
86+
returnawaitrunCommand(dev,{rawArgs: [`--cwd=${process.cwd()}`, ...args]})
87+
}
88+
89+
letexit: ReturnType<typeofvi.spyOn>
90+
91+
beforeEach(()=>{
92+
vi.clearAllMocks()
93+
takeOverDevServer.mockResolvedValue({action: 'none'})
94+
isReusePortSupported.mockResolvedValue(true)
95+
preflight.mockImplementation((options: {cwd: string})=>Promise.resolve(options.cwd))
96+
initialize.mockImplementation(()=>Promise.resolve({ listener, close, onRestart, onReady, onFileChange,reload: vi.fn()}))
97+
exit=vi.spyOn(process,'exit').mockImplementation((()=>{
98+
thrownewError('process.exit')
99+
})asnever)
100+
})
101+
102+
afterEach(()=>{
103+
vi.restoreAllMocks()
104+
vi.unstubAllEnvs()
105+
})
106+
107+
describe('dev command startup',()=>{
108+
it('should start the server in this process when forking is disabled',async()=>{
109+
awaitrunDev(['--no-fork'])
110+
111+
expect(initialize).toHaveBeenCalledTimes(1)
112+
expect(createFork).not.toHaveBeenCalled()
113+
expect(setupShortcuts).toHaveBeenCalledTimes(1)
114+
})
115+
116+
it('should pass the requested port through to the listener',async()=>{
117+
awaitrunDev(['--no-fork','--port=4000'])
118+
119+
expect(initialize.mock.calls[0]![1].listenOverrides).toMatchObject({port: '4000'})
120+
})
121+
122+
it('should keep the port unresolved when none was requested',async()=>{
123+
awaitrunDev(['--no-fork'])
124+
125+
expect(initialize.mock.calls[0]![1].listenOverrides.port).toBeUndefined()
126+
})
127+
128+
it('should carry `--strictPort` into the listen options',async()=>{
129+
awaitrunDev(['--no-fork','--port=4000','--strictPort'])
130+
131+
expect(initialize.mock.calls[0]![1].listenOverrides).toMatchObject({port: '4000',strictPort: true})
132+
})
133+
134+
it('should reject a port that is not a number before starting anything',async()=>{
135+
awaitexpect(runDev(['--no-fork','--port=notaport'])).rejects.toThrow('Invalid port')
136+
137+
expect(initialize).not.toHaveBeenCalled()
138+
})
139+
140+
it('should let a bind failure surface to the caller',async()=>{
141+
initialize.mockImplementation(()=>Promise.reject(newError('Port 4000 is already in use (`--strictPort` is enabled).')))
142+
143+
awaitexpect(runDev(['--no-fork','--port=4000','--strictPort'])).rejects.toThrow('already in use')
144+
})
145+
146+
it('should not run the profiler and the fork pool together',async()=>{
147+
awaitrunDev(['--fork','--profile'])
148+
149+
expect(createFork).not.toHaveBeenCalled()
150+
})
151+
152+
it('should treat a valued `--profile` the same way',async()=>{
153+
awaitrunDev(['--fork','--profile=verbose'])
154+
155+
expect(createFork).not.toHaveBeenCalled()
156+
})
157+
})
158+
159+
describe('dev command takeover',()=>{
160+
it('should not start when a takeover is refused',async()=>{
161+
takeOverDevServer.mockResolvedValue({action: 'refused',existing: existingLock(),reason: 'declined'})
162+
163+
awaitexpect(runDev(['--no-fork'])).rejects.toThrow('process.exit')
164+
165+
expect(exit).toHaveBeenCalledWith(1)
166+
expect(initialize).not.toHaveBeenCalled()
167+
})
168+
169+
it('should adopt the port of the server it took over',async()=>{
170+
takeOverDevServer.mockResolvedValue({action: 'taken',port: 3210,pid: 4321})
171+
172+
awaitrunDev(['--no-fork'])
173+
174+
const[context,options]=initialize.mock.calls[0]!
175+
expect(options.listenOverrides).toMatchObject({port: 3210})
176+
expect(context.handoverFrom).toBe(4321)
177+
})
178+
179+
it('should ask the takeover for the port it was given',async()=>{
180+
awaitrunDev(['--no-fork','--port=4001'])
181+
182+
expect(vi.mocked(takeOverDevServer).mock.calls[0]![1]).toMatchObject({requestedPort: 4001})
183+
})
184+
185+
it('should bypass the lock when the user starts a second server anyway',async()=>{
186+
takeOverDevServer.mockResolvedValue({action: 'start-anyway',existing: existingLock()})
187+
vi.stubEnv('NUXT_IGNORE_LOCK','')
188+
189+
awaitrunDev(['--no-fork'])
190+
191+
expect(process.env.NUXT_IGNORE_LOCK).toBe('1')
192+
})
193+
194+
it('should not pass a handover pid when nothing was taken over',async()=>{
195+
awaitrunDev(['--no-fork'])
196+
197+
expect(initialize.mock.calls[0]![0].handoverFrom).toBeUndefined()
198+
})
199+
})
200+
201+
describe('dev command fork pool',()=>{
202+
it('should warm the pool only once a file changes',async()=>{
203+
awaitrunDev(['--fork'])
204+
205+
expect(createFork).toHaveBeenCalledTimes(1)
206+
expect(startWarming).not.toHaveBeenCalled()
207+
208+
onFileChange.mock.calls[0]![0]()
209+
expect(startWarming).toHaveBeenCalledTimes(1)
210+
})
211+
212+
it('should hand the pool the arguments the session was started with',async()=>{
213+
awaitrunDev(['--fork','--port=4002'])
214+
215+
expect(createFork.mock.calls[0]![0]).toMatchObject({listenOverrides: expect.objectContaining({port: '4002'})})
216+
})
217+
218+
it('should replace the current server with a fork on a hard restart',async()=>{
219+
constforkClose=vi.fn(()=>Promise.resolve())
220+
getFork.mockResolvedValue({pid: 999,serving: Promise.resolve(),promote: vi.fn(),close: forkClose})
221+
awaitrunDev(['--fork'])
222+
223+
awaitonRestart.mock.calls[0]![0]({type: 'shortcut'})
224+
225+
expect(getFork).toHaveBeenCalledTimes(1)
226+
expect(close).toHaveBeenCalledTimes(1)
227+
})
228+
229+
it('should keep the current server when the incoming fork cannot serve',async()=>{
230+
constforkClose=vi.fn(()=>Promise.resolve())
231+
constserving=Promise.reject(newError('fork died'))
232+
serving.catch(()=>{})
233+
getFork.mockResolvedValue({pid: 999, serving,promote: vi.fn(),close: forkClose})
234+
awaitrunDev(['--fork'])
235+
236+
awaitonRestart.mock.calls[0]![0]({type: 'shortcut'})
237+
238+
expect(forkClose).toHaveBeenCalledTimes(1)
239+
expect(close).not.toHaveBeenCalled()
240+
expect(exit).not.toHaveBeenCalled()
241+
})
242+
243+
it('should exit when a serialised restart leaves nothing serving the app',async()=>{
244+
isReusePortSupported.mockResolvedValue(false)
245+
constforkClose=vi.fn(()=>Promise.resolve())
246+
constserving=Promise.reject(newError('fork died'))
247+
serving.catch(()=>{})
248+
getFork.mockResolvedValue({pid: 999, serving,promote: vi.fn(),close: forkClose})
249+
awaitrunDev(['--fork'])
250+
251+
awaitexpect(onRestart.mock.calls[0]![0]({type: 'shortcut'})).rejects.toThrow('process.exit')
252+
253+
expect(close).toHaveBeenCalledTimes(1)
254+
expect(forkClose).toHaveBeenCalledTimes(1)
255+
expect(exit).toHaveBeenCalledWith(1)
256+
})
257+
258+
it('should collapse overlapping hard restarts into one handover at a time',async()=>{
259+
letreleaseFirst: (()=>void)|undefined
260+
getFork.mockImplementation(()=>newPromise((resolve)=>{
261+
releaseFirst=()=>resolve({pid: 999,serving: Promise.resolve(),promote: vi.fn(),close: vi.fn(()=>Promise.resolve())})
262+
}))
263+
awaitrunDev(['--fork'])
264+
265+
constrestart=onRestart.mock.calls[0]![0]
266+
constfirst=restart({type: 'shortcut'})
267+
constsecond=restart({type: 'shortcut'})
268+
awaitvi.waitFor(()=>expect(releaseFirst).toBeTypeOf('function'))
269+
releaseFirst!()
270+
awaitPromise.all([first,second])
271+
272+
expect(getFork).toHaveBeenCalledTimes(2)
273+
})
274+
})

0 commit comments

Comments
 (0)