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