|
| 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