Skip to content

Commit 1754688

Browse files
committed
test: assert on rendered terminal output with ansivision
1 parent f1c378d commit 1754688

5 files changed

Lines changed: 326 additions & 6 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@nuxt/test-utils": "^4.0.3",
2929
"@types/node": "^24.13.3",
3030
"@vitest/coverage-v8": "^4.1.10",
31+
"ansivision": "^0.4.0",
3132
"changelogen": "^0.6.2",
3233
"eslint": "^10.7.0",
3334
"exsolve": "^1.1.0",
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
importtype{Renderer}from'ansivision'
2+
importtype{Listener}from'../../src/dev/listen'
3+
importtype{ShortcutContext}from'../../src/dev/shortcuts'
4+
5+
import{PassThrough}from'node:stream'
6+
7+
import{afterEach,describe,expect,it,vi}from'vitest'
8+
9+
import{colourOf,render,screenasvisibleScreen}from'../utils/terminal'
10+
11+
// Colours have to be forced before `picocolors` is imported, so the modules
12+
// under test are loaded dynamically below.
13+
process.env.FORCE_COLOR='3'
14+
15+
vi.mock('std-env',()=>({isCI: false,isTest: false}))
16+
17+
const{ listen, openBrowser, printQRCode }=awaitimport('../../src/dev/listen')
18+
const{ setupShortcuts }=awaitimport('../../src/dev/shortcuts')
19+
20+
/** SGR foreground colour indexes, as reported by `ansivision`. */
21+
constGREEN=2
22+
constMAGENTA=5
23+
constCYAN=6
24+
25+
/** The visible screen, with ports and QR codes replaced by stable markers. */
26+
functionscreen(renderer: Renderer): string{
27+
returnvisibleScreen(renderer)
28+
.replaceAll(/:\d{4,5}\b/g,':<port>')
29+
.replaceAll(/[\u2580-\u259F]+/g,'<qr>')
30+
}
31+
32+
/** Pretend to be a Linux desktop running `browser`, or a headless one. */
33+
functionstubDisplay({ browser }: {browser?: string}={}): ()=>void{
34+
constplatform=process.platform
35+
constenv={ ...process.env}
36+
Object.defineProperty(process,'platform',{value: 'linux',configurable: true})
37+
process.env={
38+
...env,
39+
DISPLAY: browser ? ':0' : undefined,
40+
WAYLAND_DISPLAY: undefined,
41+
WSL_DISTRO_NAME: undefined,
42+
BROWSER: browser,
43+
}
44+
return()=>{
45+
Object.defineProperty(process,'platform',{value: platform,configurable: true})
46+
process.env=env
47+
}
48+
}
49+
50+
describe('dev server terminal output',()=>{
51+
constlisteners: Listener[]=[]
52+
53+
afterEach(async()=>{
54+
awaitPromise.all(listeners.splice(0).map(listener=>listener.close()))
55+
vi.restoreAllMocks()
56+
})
57+
58+
asyncfunctionstart(options: Parameters<typeoflisten>[1]={}){
59+
constlistener=awaitlisten((_req,res)=>res.end('ok'),{port: 0,qr: false, ...options})
60+
listeners.push(listener)
61+
returnlistener
62+
}
63+
64+
asyncfunctionwithShortcuts(context: Partial<ShortcutContext>={}){
65+
conststdin=newPassThrough()asunknownastypeofprocess.stdin
66+
Object.assign(stdin,{isTTY: true})
67+
vi.spyOn(process,'stdin','get').mockReturnValue(stdin)
68+
69+
constlistener=awaitstart({showURL: false})
70+
setupShortcuts({
71+
listener,
72+
close: async()=>{},
73+
onReady: callback=>callback(listener.url),
74+
...context,
75+
})
76+
77+
return{
78+
listener,
79+
press: async(input: string)=>{
80+
stdin.write(`${input}\n`)
81+
awaitnewPromise(resolve=>setImmediate(resolve))
82+
},
83+
}
84+
}
85+
86+
describe('startup',()=>{
87+
it('should print the url block',async()=>{
88+
constrenderer=awaitrender(()=>start())
89+
90+
expect(screen(renderer)).toMatchInlineSnapshot(`
91+
" ➜ Local: http://localhost:<port>/
92+
➜ Network: use --host to expose"
93+
`)
94+
})
95+
96+
it('should colour each url type differently',async()=>{
97+
constrenderer=awaitrender(()=>start())
98+
renderer.goToFrame(renderer.frames.length-1)
99+
100+
expect(renderer.currentStyledFrame).toContain('\u001B[')
101+
expect(colourOf(renderer,'Local:')).toBe(GREEN)
102+
expect(colourOf(renderer,'Network:')).toBe(MAGENTA)
103+
expect(colourOf(renderer,'http://localhost')).toBe(CYAN)
104+
})
105+
106+
it('should flag which url the qr code points at',async()=>{
107+
constrenderer=awaitrender(()=>start({qr: true,publicURL: 'https://example.com/'}))
108+
109+
expect(screen(renderer)).toContain('<qr>')
110+
expect(screen(renderer)).toContain('https://example.com/ [QR code]')
111+
})
112+
113+
it('should not caption the qr code, which the url block already labels',async()=>{
114+
constrenderer=awaitrender(()=>start({qr: true}))
115+
116+
expect(screen(renderer)).not.toMatch(/<qr>\n\s+http/)
117+
})
118+
})
119+
120+
describe('shortcuts',()=>{
121+
it('should caption a standalone qr code with its url',async()=>{
122+
constrenderer=awaitrender(()=>printQRCode('http://192.168.1.20:3000/',{showURL: true}))
123+
constlines=screen(renderer).split('\n').filter(Boolean)
124+
125+
expect(lines.at(-1)).toContain('http://192.168.1.20:<port>/')
126+
// The caption is centred under the code rather than flush left.
127+
expect(lines.at(-1)!.startsWith(' ')).toBe(true)
128+
})
129+
130+
it('should leave only the url block on screen after clearing',async()=>{
131+
const{ press }=awaitwithShortcuts()
132+
133+
constrenderer=awaitrender(async()=>{
134+
// eslint-disable-next-line no-console
135+
console.log('noise from a previous build')
136+
awaitpress('clear')
137+
})
138+
139+
expect(screen(renderer)).toMatchInlineSnapshot(`
140+
" ➜ Local: http://localhost:<port>/
141+
➜ Network: use --host to expose"
142+
`)
143+
expect(renderer.frames.at(-1)).not.toContain('noise from a previous build')
144+
})
145+
146+
it('should list the available shortcuts',async()=>{
147+
const{ press }=awaitwithShortcuts()
148+
149+
constrenderer=awaitrender(()=>press('h'))
150+
151+
expect(screen(renderer)).toMatchInlineSnapshot(`
152+
" press o + enter to open in browser
153+
press u + enter to show server URLs
154+
press qr + enter to show a QR code for the server URL
155+
press copy + enter to copy the server URL to the clipboard
156+
press c + enter to clear the console
157+
press q + enter to quit
158+
press h + enter to show this help"
159+
`)
160+
})
161+
162+
it('should list restart first when a restart handler is available',async()=>{
163+
const{ press }=awaitwithShortcuts({restart: ()=>{}})
164+
165+
constrenderer=awaitrender(()=>press('h'))
166+
167+
expect(screen(renderer).split('\n')[0]).toContain('press r + enter to restart the dev server')
168+
})
169+
})
170+
171+
describe('browser launch',()=>{
172+
it('should say so when the browser launcher fails',async()=>{
173+
constrestore=stubDisplay({browser: '/definitely/not/a/launcher'})
174+
175+
constrenderer=awaitrender(async({ waitForOutput })=>{
176+
openBrowser('http://localhost:3000/')
177+
awaitwaitForOutput('Could not open')
178+
})
179+
restore()
180+
181+
expect(screen(renderer)).toContain('Could not open http://localhost:<port>/ in a browser.')
182+
})
183+
184+
it('should say so when the browser launcher exits with an error',async()=>{
185+
constrestore=stubDisplay({browser: 'false'})
186+
187+
constrenderer=awaitrender(async({ waitForOutput })=>{
188+
openBrowser('http://localhost:3000/')
189+
awaitwaitForOutput('Could not open')
190+
})
191+
restore()
192+
193+
expect(screen(renderer)).toContain('Could not open http://localhost:<port>/ in a browser.')
194+
})
195+
196+
it('should say so when there is no browser to open',async()=>{
197+
constrestore=stubDisplay()
198+
const{ press }=awaitwithShortcuts()
199+
200+
constrenderer=awaitrender(()=>press('o'))
201+
restore()
202+
203+
expect(screen(renderer)).toContain('No browser is available in this environment. Open http://localhost:<port>/ manually.')
204+
})
205+
})
206+
})

‎packages/nuxt-cli/test/unit/utils/banner.spec.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1+
importtype{Nuxt}from'@nuxt/schema'
2+
13
import{describe,expect,it,vi}from'vitest'
24

5+
import{render,screen}from'../../utils/terminal'
6+
7+
// Colours have to be forced before `picocolors` is imported by the module under test.
8+
process.env.FORCE_COLOR='3'
9+
10+
constVERSIONS: Record<string,string>={
11+
'webpack': '5.99.0',
12+
'@rspack/core': '1.3.0',
13+
'nuxt': '4.4.6',
14+
'nitropack': '2.13.4',
15+
'vue': '3.5.39',
16+
}
17+
318
vi.mock('../../../src/utils/versions',()=>({
419
getPkgJSON: vi.fn((_cwd: string,pkg: string,options?: {via?: string[]})=>{
520
if(pkg==='vite'&&options?.via?.includes('@nuxt/vite-builder')){
@@ -8,17 +23,17 @@ vi.mock('../../../src/utils/versions', () => ({
823
returnnull
924
}),
1025
getPkgVersion: vi.fn((_cwd: string,pkg: string,options?: {via?: string[]})=>{
11-
if(pkg==='webpack'&&options?.via?.includes('@nuxt/webpack-builder')){
12-
return'5.99.0'
26+
if(pkg==='webpack'&&!options?.via?.includes('@nuxt/webpack-builder')){
27+
return''
1328
}
14-
if(pkg==='@rspack/core'&&options?.via?.includes('@nuxt/rspack-builder')){
15-
return'1.3.0'
29+
if(pkg==='@rspack/core'&&!options?.via?.includes('@nuxt/rspack-builder')){
30+
return''
1631
}
17-
return''
32+
returnVERSIONS[pkg]||''
1833
}),
1934
}))
2035

21-
const{ getBuilder }=awaitimport('../../../src/utils/banner')
36+
const{ getBuilder, showBanner}=awaitimport('../../../src/utils/banner')
2237

2338
describe('getBuilder',()=>{
2439
it('resolves vite version via nuxt -> @nuxt/vite-builder',()=>{
@@ -33,3 +48,15 @@ describe('getBuilder', () => {
3348
expect(getBuilder('/any','rspack')).toEqual({name: 'Rspack',version: '1.3.0'})
3449
})
3550
})
51+
52+
describe('showBanner',()=>{
53+
it('should print every version on one line',async()=>{
54+
constrenderer=awaitrender(()=>
55+
showBanner({_version: '4.4.6',options: {rootDir: '/any',builder: 'vite'}}asunknownasNuxt))
56+
57+
expect(screen(renderer)).toMatchInlineSnapshot(`
58+
"│
59+
● Nuxt 4.4.6 (with Nitro 2.13.4, Vite 7.3.1 and Vue 3.5.39)"
60+
`)
61+
})
62+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
importtype{Renderer}from'ansivision'
2+
3+
importprocessfrom'node:process'
4+
5+
import{RenderStream}from'ansivision'
6+
import{vi}from'vitest'
7+
8+
exportinterfaceRenderContext{
9+
/** Wait for `matcher` to show up in the output, for asynchronous logs. */
10+
waitForOutput: (matcher: string|RegExp)=>Promise<void>
11+
}
12+
13+
/**
14+
* Replay everything written to the terminal through a virtual one, so tests can
15+
* assert on what a user would see rather than on raw escape sequences.
16+
*
17+
* Both sinks feed the same stream so escape sequences stay ordered relative to
18+
* the logs; `console.log` cannot be left to `process.stdout` because vitest
19+
* replaces the global console.
20+
*/
21+
exportasyncfunctionrender(run: (context: RenderContext)=>unknown): Promise<Renderer>{
22+
conststream=newRenderStream()
23+
letoutput=''
24+
constcapture=(chunk: string)=>{
25+
output+=chunk
26+
stream.write(chunk)
27+
}
28+
29+
constlog=vi.spyOn(console,'log').mockImplementation((...args: unknown[])=>capture(`${args.join(' ')}\n`))
30+
constwrite=vi.spyOn(process.stdout,'write').mockImplementation((chunk: unknown)=>{
31+
capture(chunkasstring)
32+
returntrue
33+
})
34+
35+
try{
36+
awaitrun({
37+
waitForOutput: matcher=>vi.waitFor(()=>{
38+
if(!output.match(matcher)){
39+
thrownewError(`Timed out waiting for ${matcher} in terminal output`)
40+
}
41+
}),
42+
})
43+
}
44+
finally{
45+
log.mockRestore()
46+
write.mockRestore()
47+
}
48+
49+
// The renderer only catches up once the stream has drained.
50+
awaitnewPromise<void>(resolve=>stream.end(resolve))
51+
returnstream.renderer
52+
}
53+
54+
/**
55+
* The visible screen. `ansivision` starts a new frame whenever the screen is
56+
* cleared, so the last frame is what is on screen now.
57+
*/
58+
exportfunctionscreen(renderer: Renderer): string{
59+
return(renderer.frames.at(-1)??'')
60+
.split('\n')
61+
.map(line=>line.trimEnd())
62+
.join('\n')
63+
.replaceAll(/^\n+|\n+$/g,'')
64+
}
65+
66+
/** Foreground colour of the first character of `text` on screen. */
67+
exportfunctioncolourOf(renderer: Renderer,text: string): unknown{
68+
constlines=(renderer.frames.at(-1)??'').split('\n')
69+
constrow=lines.findIndex(line=>line.includes(text))
70+
returnrenderer.getStyleAtPosition(row,lines[row]!.indexOf(text)).foreground
71+
}

‎pnpm-lock.yaml‎

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)