|
| 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 | +}) |
0 commit comments