Skip to content

Commit f8338be

Browse files
committed
test: cover the interactive init flow and its fallbacks
1 parent 8112e50 commit f8338be

1 file changed

Lines changed: 314 additions & 0 deletions

File tree

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import{mkdir,mkdtemp,readFile,rm,writeFile}from'node:fs/promises'
2+
import{tmpdir}from'node:os'
3+
importprocessfrom'node:process'
4+
5+
import{join}from'pathe'
6+
import{afterEach,beforeEach,describe,expect,it,vi}from'vitest'
7+
8+
constCANCELLED=Symbol('cancelled')
9+
10+
const{
11+
answers,
12+
downloadTemplate,
13+
fetchModules,
14+
getTemplates,
15+
runInstall,
16+
startShell,
17+
tinyexec,
18+
}=vi.hoisted(()=>({
19+
answers: {select: []asunknown[],text: []asunknown[],confirm: []asunknown[]},
20+
downloadTemplate: vi.fn(),
21+
fetchModules: vi.fn(()=>Promise.resolve([])),
22+
getTemplates: vi.fn(),
23+
runInstall: vi.fn(),
24+
startShell: vi.fn(),
25+
tinyexec: vi.fn(()=>Promise.resolve({exitCode: 0,stdout: '',stderr: ''})),
26+
}))
27+
28+
vi.mock('std-env',asyncimportOriginal=>({
29+
...awaitimportOriginal<typeofimport('std-env')>(),
30+
hasTTY: true,
31+
}))
32+
33+
vi.mock('giget',()=>({ downloadTemplate, startShell }))
34+
vi.mock('tinyexec',()=>({x: tinyexec}))
35+
36+
vi.mock('@clack/prompts',async(importOriginal)=>{
37+
constoriginal=awaitimportOriginal<typeofimport('@clack/prompts')>()
38+
constnext=(queue: unknown[],fallback: unknown)=>Promise.resolve(queue.length ? queue.shift() : fallback)
39+
return{
40+
...original,
41+
intro: vi.fn(),
42+
outro: vi.fn(),
43+
cancel: vi.fn(),
44+
note: vi.fn(),
45+
isCancel: (value: unknown)=>value===CANCELLED||original.isCancel(value),
46+
select: vi.fn(()=>next(answers.select,undefined)),
47+
text: vi.fn(()=>next(answers.text,'')),
48+
confirm: vi.fn(()=>next(answers.confirm,false)),
49+
spinner: vi.fn(()=>({start: vi.fn(),stop: vi.fn(),error: vi.fn(),message: vi.fn()})),
50+
}
51+
})
52+
53+
vi.mock('../../../nuxt-cli/src/utils/starter-templates',asyncimportOriginal=>({
54+
...awaitimportOriginal<typeofimport('../../../nuxt-cli/src/utils/starter-templates')>(),
55+
getTemplates,
56+
}))
57+
58+
vi.mock('../../../nuxt-cli/src/commands/module/_utils',asyncimportOriginal=>({
59+
...awaitimportOriginal<typeofimport('../../../nuxt-cli/src/commands/module/_utils')>(),
60+
fetchModules,
61+
}))
62+
63+
vi.mock('../../../nuxt-cli/src/utils/install',asyncimportOriginal=>({
64+
...awaitimportOriginal<typeofimport('../../../nuxt-cli/src/utils/install')>(),
65+
runInstall,
66+
}))
67+
68+
const{ runCommandDef }=awaitimport('../../../nuxt-cli/src/run-command')
69+
const{ render, screen }=awaitimport('../../../nuxt-cli/test/utils/terminal')
70+
constinitCommand=awaitimport('../../src/init').then(r=>r.default)
71+
72+
letcwd: string
73+
74+
classExitErrorextendsError{
75+
constructor(readonlycode: number|undefined){
76+
super(`process.exit(${code})`)
77+
}
78+
}
79+
80+
asyncfunctionrunInit(argv: string[]): Promise<{output: string,exitCode: number|undefined}>{
81+
letexitCode: number|undefined
82+
constexit=vi.spyOn(process,'exit').mockImplementation((code)=>{
83+
exitCode=codeasnumber|undefined
84+
thrownewExitError(exitCode)
85+
})
86+
87+
constrenderer=awaitrender(async()=>{
88+
awaitrunCommandDef(initCommand,[`--cwd=${cwd}`, ...argv]).catch((error)=>{
89+
if(!(errorinstanceofExitError)){
90+
throwerror
91+
}
92+
})
93+
})
94+
95+
exit.mockRestore()
96+
return{output: `${renderer.frames.join('\n')}\n${screen(renderer)}`, exitCode }
97+
}
98+
99+
/** Write the files `downloadTemplate` would have unpacked. */
100+
functionstubTemplate(files: Record<string,string>={},name='minimal'){
101+
downloadTemplate.mockImplementation(async(_template: string,options: {dir: string})=>{
102+
awaitmkdir(options.dir,{recursive: true})
103+
awaitwriteFile(join(options.dir,'package.json'),JSON.stringify({name: 'template',dependencies: {nuxt: '^4.0.0'}},null,2))
104+
for(const[file,contents]ofObject.entries(files)){
105+
awaitmkdir(join(options.dir,file,'..'),{recursive: true})
106+
awaitwriteFile(join(options.dir,file),contents)
107+
}
108+
return{dir: options.dir, name,source: 'github'}
109+
})
110+
}
111+
112+
beforeEach(async()=>{
113+
cwd=awaitmkdtemp(join(tmpdir(),'nuxt-init-flow-'))
114+
answers.select.length=0
115+
answers.text.length=0
116+
answers.confirm.length=0
117+
vi.clearAllMocks()
118+
stubTemplate()
119+
getTemplates.mockResolvedValue({
120+
minimal: {name: 'minimal',description: 'Minimal starter',defaultDir: 'nuxt-app',url: '',tar: ''},
121+
v4: {name: 'v4',description: 'Nuxt 4 starter',defaultDir: 'nuxt-app',url: '',tar: ''},
122+
})
123+
runInstall.mockResolvedValue({success: true,output: '',command: 'npm install',ignoredBuilds: []})
124+
fetchModules.mockResolvedValue([])
125+
})
126+
127+
afterEach(async()=>{
128+
awaitrm(cwd,{recursive: true,force: true})
129+
process.exitCode=0
130+
vi.restoreAllMocks()
131+
})
132+
133+
describe('interactive scaffolding',()=>{
134+
it('should scaffold from the answers it was given',async()=>{
135+
answers.select.push('minimal','npm')
136+
answers.text.push('my-app')
137+
answers.confirm.push(false,false)
138+
139+
const{ output }=awaitrunInit([])
140+
141+
expect(downloadTemplate).toHaveBeenCalledWith('minimal',expect.objectContaining({dir: join(cwd,'my-app')}))
142+
expect(runInstall).toHaveBeenCalledTimes(1)
143+
expect(output).toContain('Next steps')
144+
expect(output).toMatch(/cd\S*my-app/)
145+
expect(output).toContain('npm run dev')
146+
})
147+
148+
it('should show the command that scaffolds the same project without prompts',async()=>{
149+
answers.select.push('minimal','pnpm')
150+
answers.text.push('my-app')
151+
answers.confirm.push(false,false)
152+
153+
const{ output }=awaitrunInit([])
154+
155+
expect(output).toContain('without prompts')
156+
expect(output).toContain('--template=minimal')
157+
expect(output).toContain('--packageManager=pnpm')
158+
})
159+
160+
it('should not offer the headless command when nothing was prompted for',async()=>{
161+
const{ output }=awaitrunInit(['my-app','--template=minimal','--packageManager=npm','--gitInit=false','--no-modules'])
162+
163+
expect(output).not.toContain('without prompts')
164+
})
165+
166+
it('should stop when the user cancels the template prompt',async()=>{
167+
answers.select.push(CANCELLED)
168+
169+
const{ exitCode }=awaitrunInit([])
170+
171+
expect(exitCode).toBe(1)
172+
expect(downloadTemplate).not.toHaveBeenCalled()
173+
})
174+
175+
it('should stop when the user cancels the directory prompt',async()=>{
176+
answers.select.push('minimal')
177+
answers.text.push(CANCELLED)
178+
179+
const{ exitCode }=awaitrunInit([])
180+
181+
expect(exitCode).toBe(1)
182+
expect(downloadTemplate).not.toHaveBeenCalled()
183+
})
184+
185+
it('should initialise a git repository when asked to',async()=>{
186+
awaitrunInit(['my-app','--template=minimal','--packageManager=npm','--gitInit','--no-modules'])
187+
188+
expect(tinyexec).toHaveBeenCalledWith('git',['init'],expect.objectContaining({
189+
nodeOptions: expect.objectContaining({cwd: join(cwd,'my-app')}),
190+
}))
191+
})
192+
193+
it('should report a git repository that could not be initialised',async()=>{
194+
tinyexec.mockResolvedValue({exitCode: 128,stdout: '',stderr: 'fatal: not a git repository'})
195+
196+
const{ output }=awaitrunInit(['my-app','--template=minimal','--packageManager=npm','--gitInit','--no-modules'])
197+
198+
expect(output).toContain('fatal: not a git repository')
199+
})
200+
})
201+
202+
describe('template listing fallback',()=>{
203+
it('should fall back to the bundled list when the starter repo is unreachable',async()=>{
204+
getTemplates.mockRejectedValue(newError('getaddrinfo ENOTFOUND raw.githubusercontent.com'))
205+
answers.select.push('minimal','npm')
206+
answers.text.push('my-app')
207+
answers.confirm.push(false,false)
208+
209+
awaitrunInit([])
210+
211+
const{ select }=awaitimport('@clack/prompts')
212+
constoptions=vi.mocked(select).mock.calls[0]![0].options
213+
expect(options.length).toBeGreaterThan(0)
214+
expect(options.map(option=>option.value)).toContain('minimal')
215+
expect(downloadTemplate).toHaveBeenCalledWith('minimal',expect.anything())
216+
})
217+
218+
it('should not ask the network for templates when offline',async()=>{
219+
answers.select.push('minimal','npm')
220+
answers.text.push('my-app')
221+
answers.confirm.push(false)
222+
223+
awaitrunInit(['--offline'])
224+
225+
expect(getTemplates).not.toHaveBeenCalled()
226+
expect(downloadTemplate).toHaveBeenCalledWith('minimal',expect.objectContaining({offline: true}))
227+
})
228+
229+
it('should not browse modules when offline',async()=>{
230+
answers.select.push('minimal','npm')
231+
answers.text.push('my-app')
232+
answers.confirm.push(false)
233+
234+
awaitrunInit(['--preferOffline'])
235+
236+
expect(fetchModules).not.toHaveBeenCalled()
237+
})
238+
})
239+
240+
describe('package manager selection',()=>{
241+
it('should use the package manager the template pins',async()=>{
242+
stubTemplate({'pnpm-lock.yaml': ''})
243+
244+
const{ output }=awaitrunInit(['my-app','--template=minimal','--gitInit=false','--no-modules'])
245+
246+
expect(output).toContain('Using pnpm')
247+
expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({
248+
packageManager: expect.objectContaining({name: 'pnpm'}),
249+
}))
250+
})
251+
252+
it('should skip the install when the requested package manager conflicts with the template',async()=>{
253+
stubTemplate({'pnpm-lock.yaml': ''})
254+
255+
const{ output }=awaitrunInit(['my-app','--template=minimal','--packageManager=npm','--gitInit=false','--no-modules'])
256+
257+
expect(output).toContain('Skipping dependency installation')
258+
expect(runInstall).not.toHaveBeenCalled()
259+
})
260+
261+
it('should opt a yarn project out of plug and play',async()=>{
262+
const{ output }=awaitrunInit(['my-app','--template=minimal','--packageManager=yarn','--gitInit=false','--no-modules'])
263+
264+
expect(awaitreadFile(join(cwd,'my-app','.yarnrc.yml'),'utf8')).toContain('nodeLinker: node-modules')
265+
expect(output).toContain('.yarnrc.yml')
266+
})
267+
268+
it('should reject a package manager that does not exist',async()=>{
269+
const{ exitCode, output }=awaitrunInit(['my-app','--template=minimal','--packageManager=nope'])
270+
271+
expect(exitCode).toBe(2)
272+
expect(output).toContain('Invalid package manager')
273+
expect(downloadTemplate).not.toHaveBeenCalled()
274+
})
275+
})
276+
277+
describe('recovery advice',()=>{
278+
it('should tell the user to install by hand when the install fails',async()=>{
279+
runInstall.mockResolvedValue({success: false,error: 'npm ERR! network timeout',output: 'npm ERR! network timeout',command: 'npm install',ignoredBuilds: []})
280+
281+
const{ output }=awaitrunInit(['my-app','--template=minimal','--packageManager=npm','--gitInit=false','--no-modules'])
282+
283+
expect(output).toContain('dependencies are not installed')
284+
expect(output).toContain('npm install')
285+
expect(process.exitCode).toBe(1)
286+
})
287+
288+
it('should not add modules to a project whose install failed',async()=>{
289+
runInstall.mockResolvedValue({success: false,error: 'npm ERR! network timeout',output: 'npm ERR! network timeout',command: 'npm install',ignoredBuilds: []})
290+
291+
const{ output }=awaitrunInit(['my-app','--template=minimal','--packageManager=npm','--gitInit=false','--modules=@nuxt/image'])
292+
293+
expect(output).toContain('Skipping module installation')
294+
expect(output).toContain('nuxt module add')
295+
})
296+
297+
it('should offer `pnpm approve-builds` when builds were ignored',async()=>{
298+
stubTemplate({'pnpm-lock.yaml': ''})
299+
runInstall.mockResolvedValue({success: true,output: '',command: 'pnpm install',ignoredBuilds: ['better-sqlite3']})
300+
answers.confirm.push(false)
301+
302+
const{ output }=awaitrunInit(['my-app','--template=minimal','--gitInit=false','--no-modules'])
303+
304+
expect(output).toContain('did not run build scripts')
305+
expect(output).toContain('pnpm approve-builds')
306+
})
307+
308+
it('should tell the user to install when the install was skipped',async()=>{
309+
const{ output }=awaitrunInit(['my-app','--template=minimal','--packageManager=npm','--gitInit=false','--install=false','--no-modules'])
310+
311+
expect(runInstall).not.toHaveBeenCalled()
312+
expect(output).toContain('npm install')
313+
})
314+
})

0 commit comments

Comments
 (0)