Skip to content

Commit ff8529a

Browse files
committed
test: cover console raw mode, config loading and info box formatting
1 parent e991256 commit ff8529a

3 files changed

Lines changed: 183 additions & 77 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
importprocessfrom'node:process'
2+
3+
import{consola}from'consola'
4+
import{afterEach,describe,expect,it,vi}from'vitest'
5+
6+
import{restoreRawMode,withDirectStdout}from'../../../src/utils/console'
7+
8+
conststdin=process.stdinasNodeJS.ReadStream&{isTTY?: boolean,isRaw?: boolean}
9+
constdescriptors=Object.getOwnPropertyDescriptors(stdin)
10+
11+
constsetRawMode=vi.fn(()=>stdin)
12+
13+
functionstubStdin(properties: {isTTY?: boolean,isRaw?: boolean}): void{
14+
setRawMode.mockClear()
15+
Object.defineProperty(stdin,'setRawMode',{value: setRawMode,configurable: true,writable: true})
16+
for(const[key,value]ofObject.entries(properties)){
17+
Object.defineProperty(stdin,key,{ value,configurable: true,writable: true})
18+
}
19+
}
20+
21+
afterEach(()=>{
22+
vi.restoreAllMocks()
23+
for(constkeyof['isTTY','isRaw','setRawMode']asconst){
24+
if(descriptors[key]){
25+
Object.defineProperty(stdin,key,descriptors[key])
26+
}
27+
else{
28+
delete(stdinasRecord<string,unknown>)[key]
29+
}
30+
}
31+
})
32+
33+
describe('restoreRawMode',()=>{
34+
it('should do nothing when stdin is not a tty',()=>{
35+
stubStdin({isTTY: false})
36+
37+
restoreRawMode()
38+
39+
expect(setRawMode).not.toHaveBeenCalled()
40+
})
41+
42+
it('should leave raw mode and resume a paused stdin',()=>{
43+
stubStdin({isTTY: true,isRaw: true})
44+
vi.spyOn(stdin,'isPaused').mockReturnValue(true)
45+
constresume=vi.spyOn(stdin,'resume').mockReturnValue(stdin)
46+
47+
restoreRawMode()
48+
49+
expect(resume).toHaveBeenCalled()
50+
expect(setRawMode).toHaveBeenCalledWith(false)
51+
})
52+
53+
it('should not touch a tty that is already out of raw mode',()=>{
54+
stubStdin({isTTY: true,isRaw: false})
55+
vi.spyOn(stdin,'isPaused').mockReturnValue(false)
56+
57+
restoreRawMode()
58+
59+
expect(setRawMode).not.toHaveBeenCalled()
60+
})
61+
})
62+
63+
describe('withDirectStdout',()=>{
64+
it('should run the callback unchanged when stdout is not wrapped',async()=>{
65+
constrestore=vi.spyOn(consola,'restoreStd')
66+
67+
awaitexpect(withDirectStdout(()=>'result')).resolves.toBe('result')
68+
expect(restore).not.toHaveBeenCalled()
69+
})
70+
71+
it('should unwrap stdout for the callback and wrap it again afterwards',async()=>{
72+
constwrapped=process.stdoutastypeofprocess.stdout&{__write?: typeofprocess.stdout.write}
73+
constoriginal=wrapped.__write
74+
wrapped.__write=(()=>true)astypeofprocess.stdout.write
75+
constrestore=vi.spyOn(consola,'restoreStd').mockImplementation(()=>{})
76+
constwrap=vi.spyOn(consola,'wrapStd').mockImplementation(()=>{})
77+
78+
try{
79+
awaitexpect(withDirectStdout(()=>'result')).resolves.toBe('result')
80+
expect(restore).toHaveBeenCalled()
81+
expect(wrap).toHaveBeenCalled()
82+
}
83+
finally{
84+
wrapped.__write=original
85+
}
86+
})
87+
88+
it('should wrap stdout again when the callback throws',async()=>{
89+
constwrapped=process.stdoutastypeofprocess.stdout&{__write?: typeofprocess.stdout.write}
90+
constoriginal=wrapped.__write
91+
wrapped.__write=(()=>true)astypeofprocess.stdout.write
92+
vi.spyOn(consola,'restoreStd').mockImplementation(()=>{})
93+
constwrap=vi.spyOn(consola,'wrapStd').mockImplementation(()=>{})
94+
95+
try{
96+
awaitexpect(withDirectStdout(()=>{
97+
thrownewError('boom')
98+
})).rejects.toThrow('boom')
99+
expect(wrap).toHaveBeenCalled()
100+
}
101+
finally{
102+
wrapped.__write=original
103+
}
104+
})
105+
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import{stripVTControlCharacters}from'node:util'
2+
3+
import{describe,expect,it}from'vitest'
4+
5+
import{formatInfoBox}from'../../../src/utils/formatting'
6+
7+
functionplain(box: string): string[]{
8+
returnstripVTControlCharacters(box).split('\n').map(line=>line.trimEnd())
9+
}
10+
11+
describe('formatInfoBox',()=>{
12+
it('should render one line per entry',()=>{
13+
expect(plain(formatInfoBox({Nuxt: '4.0.0',CLI: '4.0.0'}))).toEqual([
14+
expect.stringMatching(/^Nuxt+4\.0\.0$/),
15+
expect.stringMatching(/^CLI+4\.0\.0$/),
16+
'',
17+
])
18+
})
19+
20+
it('should render a placeholder for a missing value',()=>{
21+
expect(plain(formatInfoBox({Builder: undefined}))).toEqual([expect.stringMatching(/^Builder+-$/),''])
22+
})
23+
24+
it('should strip backticks from values',()=>{
25+
expect(plain(formatInfoBox({Modules: '`@nuxt/image`'}))).toEqual([expect.stringMatching(/^Modules+@nuxt\/image$/),''])
26+
})
27+
28+
it('should align values to the widest label',()=>{
29+
const[first,second]=plain(formatInfoBox({A: 'x',LongerLabel: 'y'}))
30+
31+
expect(first!.indexOf('x')).toBe(second!.indexOf('y'))
32+
})
33+
34+
it('should wrap a long value onto continuation lines',()=>{
35+
constvalue=Array.from({length: 40},(_,index)=>`module-${index}`).join(' ')
36+
constlines=plain(formatInfoBox({Modules: value})).filter(Boolean)
37+
38+
expect(lines.length).toBeGreaterThan(1)
39+
expect(lines.slice(1).every(line=>line.startsWith(' '))).toBe(true)
40+
})
41+
})
Lines changed: 37 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,66 @@
1-
import{execFile}from'node:child_process'
2-
import{mkdtemp,writeFile}from'node:fs/promises'
1+
import{mkdtemp,rm,writeFile}from'node:fs/promises'
32
import{tmpdir}from'node:os'
43
import{join}from'node:path'
5-
importprocessfrom'node:process'
64

7-
import{promisify}from'node:util'
5+
import{consola}from'consola'
6+
import{afterEach,beforeEach,describe,expect,it,vi}from'vitest'
87

9-
import{describe,expect,it}from'vitest'
8+
import{CONFIG_EXTENSIONS,getNuxtConfig}from'../../../src/utils/nuxt-config'
109

11-
constexecFileAsync=promisify(execFile)
10+
letroot: string
1211

13-
constloaderUrl=newURL('../../../src/utils/nuxt-config.ts',import.meta.url).href
14-
15-
/**
16-
* `getNuxtConfig` picks between Node's own loader and `jiti` by inspecting the
17-
* error codes Node raises, and Vitest resolves dynamic imports through Vite
18-
* rather than Node. Every case therefore runs in a real Node process, otherwise
19-
* the branch under test is never the one taken.
20-
*/
21-
asyncfunctionloadConfig(files: Record<string,string>){
22-
constcwd=awaitmkdtemp(join(tmpdir(),'nuxi-nuxt-config-'))
23-
for(const[name,contents]ofObject.entries(files)){
24-
awaitwriteFile(join(cwd,name),contents,'utf8')
25-
}
12+
beforeEach(async()=>{
13+
root=awaitmkdtemp(join(tmpdir(),'nuxt-config-'))
14+
})
2615

27-
constscript=`
28-
const { getNuxtConfig } = await import(${JSON.stringify(loaderUrl)})
29-
process.stdout.write(JSON.stringify(await getNuxtConfig(${JSON.stringify(cwd)})))
30-
`
31-
const{ stdout, stderr }=awaitexecFileAsync(process.execPath,['--input-type=module','--eval',script],{ cwd })
32-
return{config: JSON.parse(stdout), stderr }
33-
}
16+
afterEach(async()=>{
17+
vi.restoreAllMocks()
18+
awaitrm(root,{recursive: true,force: true})
19+
})
3420

3521
describe('getNuxtConfig',()=>{
36-
it('should load a config with Node\'s own loader',async()=>{
37-
const{ config }=awaitloadConfig({
38-
'package.json': '{"name":"native","type":"module"}',
39-
'nuxt.config.ts': 'export default defineNuxtConfig({ modules: [\'@nuxt/image\'] })\n',
40-
})
41-
42-
expect(config).toMatchObject({modules: ['@nuxt/image']})
22+
it('should list the extensions a config can have',()=>{
23+
expect(CONFIG_EXTENSIONS).toContain('.ts')
24+
expect(CONFIG_EXTENSIONS).toContain('.mjs')
4325
})
4426

45-
it('should strip erasable types',async()=>{
46-
const{ config }=awaitloadConfig({
47-
'package.json': '{"name":"typed","type":"module"}',
48-
'nuxt.config.ts': 'const ssr: boolean = false\nexport default defineNuxtConfig({ ssr })\n',
49-
})
50-
51-
expect(config).toMatchObject({ssr: false})
27+
it('should return an empty object when there is no config',async()=>{
28+
awaitexpect(getNuxtConfig(root)).resolves.toEqual({})
5229
})
5330

54-
it('should fall back to `jiti` for aliases Node cannot resolve',async()=>{
55-
const{ config }=awaitloadConfig({
56-
'package.json': '{"name":"aliased","type":"module"}',
57-
'module.ts': 'export const mod = \'@nuxt/content\'\n',
58-
'nuxt.config.ts': 'import { mod } from \'~/module\'\n\nexport default defineNuxtConfig({ modules: [mod] })\n',
59-
})
31+
it('should read a plain esm config',async()=>{
32+
awaitwriteFile(join(root,'nuxt.config.mjs'),'export default { buildDir: ".custom" }')
6033

61-
expect(config).toMatchObject({modules: ['@nuxt/content']})
34+
awaitexpect(getNuxtConfig(root)).resolves.toMatchObject({buildDir: '.custom'})
6235
})
6336

64-
it('should fall back to `jiti` for TypeScript that cannot be stripped',async()=>{
65-
const{ config }=awaitloadConfig({
66-
'package.json': '{"name":"enums","type":"module"}',
67-
'nuxt.config.ts': 'enum Mod { Image = \'@nuxt/image\' }\n\nexport default defineNuxtConfig({ modules: [Mod.Image] })\n',
68-
})
37+
it('should read a config using the global defineNuxtConfig',async()=>{
38+
awaitwriteFile(join(root,'nuxt.config.mjs'),'export default defineNuxtConfig({ buildDir: ".defined" })')
6939

70-
expect(config).toMatchObject({modules: ['@nuxt/image']})
40+
awaitexpect(getNuxtConfig(root)).resolves.toMatchObject({buildDir: '.defined'})
7141
})
7242

73-
it('should load a plain JavaScript config',async()=>{
74-
const{ config }=awaitloadConfig({
75-
'package.json': '{"name":"plain","type":"module"}',
76-
'nuxt.config.mjs': 'export default defineNuxtConfig({ ssr: false })\n',
77-
})
78-
79-
expect(config).toMatchObject({ssr: false})
80-
})
43+
it('should not leak defineNuxtConfig onto globalThis',async()=>{
44+
awaitwriteFile(join(root,'nuxt.config.mjs'),'export default { buildDir: ".custom" }')
8145

82-
it('should return an empty config when there is no config file',async()=>{
83-
const{ config }=awaitloadConfig({'package.json': '{"name":"bare","type":"module"}'})
46+
awaitgetNuxtConfig(root)
8447

85-
expect(config).toEqual({})
48+
expect((globalThisasRecord<string,unknown>).defineNuxtConfig).toBeUndefined()
8649
})
8750

88-
it('should return an empty config when the config throws',async()=>{
89-
const{config}=awaitloadConfig({
90-
'package.json': '{"name":"broken","type":"module"}',
91-
'nuxt.config.ts': 'throw new Error(\'boom\')\n',
92-
})
51+
it('should warn and return an empty config when the config throws',async()=>{
52+
awaitwriteFile(join(root,'nuxt.config.mjs'),'throw new Error("broken config")')
53+
constwarn=vi.spyOn(consola,'warn').mockImplementation(()=>{})
54+
55+
awaitexpect(getNuxtConfig(root)).resolves.toEqual({})
9356

94-
expect(config).toEqual({})
57+
expect(warn).toHaveBeenCalledTimes(1)
58+
expect(warn.mock.calls[0]![0]).toContain('broken config')
9559
})
9660

97-
it('should not warn about module type for a config without `type: module`',async()=>{
98-
const{ config, stderr }=awaitloadConfig({
99-
'package.json': '{"name":"typeless"}',
100-
'nuxt.config.ts': 'export default defineNuxtConfig({ ssr: true })\n',
101-
})
61+
it('should read a typescript config',async()=>{
62+
awaitwriteFile(join(root,'nuxt.config.ts'),'export default { buildDir: ".ts-build" } as Record<string, string>')
10263

103-
expect(config).toMatchObject({ssr: true})
104-
expect(stderr).not.toContain('MODULE_TYPELESS_PACKAGE_JSON')
64+
awaitexpect(getNuxtConfig(root)).resolves.toMatchObject({buildDir: '.ts-build'})
10565
})
10666
})

0 commit comments

Comments
 (0)