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+
import type { Renderer } from 'ansivision'
2+
import type { Listener } from '../../src/dev/listen'
3+
import type { 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, screen as visibleScreen } 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 } = await import('../../src/dev/listen')
18+
const { setupShortcuts } = await import('../../src/dev/shortcuts')
19+
20+
/** SGR foreground colour indexes, as reported by `ansivision`. */
21+
const GREEN = 2
22+
const MAGENTA = 5
23+
const CYAN = 6
24+
25+
/** The visible screen, with ports and QR codes replaced by stable markers. */
26+
function screen(renderer: Renderer): string {
27+
return visibleScreen(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+
function stubDisplay({ browser }: { browser?: string } = {}): () => void {
34+
const platform = process.platform
35+
const env = { ...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+
const listeners: Listener[] = []
52+
53+
afterEach(async () => {
54+
await Promise.all(listeners.splice(0).map(listener => listener.close()))
55+
vi.restoreAllMocks()
56+
})
57+
58+
async function start(options: Parameters<typeof listen>[1] = {}) {
59+
const listener = await listen((_req, res) => res.end('ok'), { port: 0, qr: false, ...options })
60+
listeners.push(listener)
61+
return listener
62+
}
63+
64+
async function withShortcuts(context: Partial<ShortcutContext> = {}) {
65+
const stdin = new PassThrough() as unknown as typeof process.stdin
66+
Object.assign(stdin, { isTTY: true })
67+
vi.spyOn(process, 'stdin', 'get').mockReturnValue(stdin)
68+
69+
const listener = await start({ 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+
await new Promise(resolve => setImmediate(resolve))
82+
},
83+
}
84+
}
85+
86+
describe('startup', () => {
87+
it('should print the url block', async () => {
88+
const renderer = await render(() => 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+
const renderer = await render(() => 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+
const renderer = await render(() => 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+
const renderer = await render(() => 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+
const renderer = await render(() => printQRCode('http://192.168.1.20:3000/', { showURL: true }))
123+
const lines = 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 } = await withShortcuts()
132+
133+
const renderer = await render(async () => {
134+
// eslint-disable-next-line no-console
135+
console.log('noise from a previous build')
136+
await press('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 } = await withShortcuts()
148+
149+
const renderer = await render(() => 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 } = await withShortcuts({ restart: () => {} })
164+
165+
const renderer = await render(() => 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+
const restore = stubDisplay({ browser: '/definitely/not/a/launcher' })
174+
175+
const renderer = await render(async ({ waitForOutput }) => {
176+
openBrowser('http://localhost:3000/')
177+
await waitForOutput('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+
const restore = stubDisplay({ browser: 'false' })
186+
187+
const renderer = await render(async ({ waitForOutput }) => {
188+
openBrowser('http://localhost:3000/')
189+
await waitForOutput('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+
const restore = stubDisplay()
198+
const { press } = await withShortcuts()
199+
200+
const renderer = await render(() => 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+
import type { 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+
const VERSIONS: 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
return null
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+
return VERSIONS[pkg] || ''
1833
}),
1934
}))
2035

21-
const { getBuilder } = await import('../../../src/utils/banner')
36+
const { getBuilder, showBanner } = await import('../../../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+
const renderer = await render(() =>
55+
showBanner({ _version: '4.4.6', options: { rootDir: '/any', builder: 'vite' } } as unknown as Nuxt))
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+
import type { Renderer } from 'ansivision'
2+
3+
import process from 'node:process'
4+
5+
import { RenderStream } from 'ansivision'
6+
import { vi } from 'vitest'
7+
8+
export interface RenderContext {
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+
export async function render(run: (context: RenderContext) => unknown): Promise<Renderer> {
22+
const stream = new RenderStream()
23+
let output = ''
24+
const capture = (chunk: string) => {
25+
output += chunk
26+
stream.write(chunk)
27+
}
28+
29+
const log = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => capture(`${args.join(' ')}\n`))
30+
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
31+
capture(chunk as string)
32+
return true
33+
})
34+
35+
try {
36+
await run({
37+
waitForOutput: matcher => vi.waitFor(() => {
38+
if (!output.match(matcher)) {
39+
throw new Error(`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+
await new Promise<void>(resolve => stream.end(resolve))
51+
return stream.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+
export function screen(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+
export function colourOf(renderer: Renderer, text: string): unknown {
68+
const lines = (renderer.frames.at(-1) ?? '').split('\n')
69+
const row = lines.findIndex(line => line.includes(text))
70+
return renderer.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)