1import execa from 'execa'; 2import klawSync from 'klaw-sync'; 3import path from 'path'; 4 5import { bin, ensurePortFreeAsync, getRouterE2ERoot } from '../utils'; 6import { runExportSideEffects } from './export-side-effects'; 7 8runExportSideEffects(); 9 10describe('server-output', () => { 11 const projectRoot = getRouterE2ERoot(); 12 const outputDir = path.join(projectRoot, 'dist-server'); 13 14 beforeAll( 15 async () => { 16 await execa('node', [bin, 'export', '-p', 'web', '--output-dir', 'dist-server'], { 17 cwd: projectRoot, 18 env: { 19 NODE_ENV: 'production', 20 EXPO_USE_STATIC: 'server', 21 E2E_ROUTER_SRC: 'server', 22 E2E_ROUTER_ASYNC: 'development', 23 }, 24 }); 25 }, 26 // Could take 45s depending on how fast the bundler resolves 27 560 * 1000 28 ); 29 30 describe('requests', () => { 31 beforeAll(async () => { 32 await ensurePortFreeAsync(3000); 33 // Start a server instance that we can test against then kill it. 34 server = execa('node', [nodeScript], { 35 cwd: projectRoot, 36 37 stderr: 'inherit', 38 39 env: { 40 NODE_ENV: 'production', 41 TEST_SECRET_KEY: 'test-secret-key', 42 }, 43 }); 44 // Wait for the server to start 45 await new Promise((resolve) => { 46 const listener = server!.stdout?.on('data', (data) => { 47 if (data.toString().includes('server listening')) { 48 console.log('Express server ready'); 49 resolve(null); 50 listener?.removeAllListeners(); 51 } 52 }); 53 }); 54 }, 120 * 1000); 55 const nodeScript = path.join(projectRoot, '__e2e__/server/express.js'); 56 let server: execa.ExecaChildProcess<string> | undefined; 57 58 afterAll(async () => { 59 server?.kill(); 60 }); 61 62 ['POST', 'GET', 'PUT', 'DELETE'].map(async (method) => { 63 it(`can make requests to ${method} routes`, async () => { 64 // Request missing route 65 expect( 66 await fetch('http://localhost:3000/methods', { 67 method: method, 68 }).then((res) => res.json()) 69 ).toEqual({ 70 method: method.toLowerCase(), 71 }); 72 }); 73 }); 74 75 it(`can serve up index html`, async () => { 76 expect(await fetch('http://localhost:3000').then((res) => res.text())).toMatch( 77 /<div id="root">/ 78 ); 79 }); 80 81 it(`can serve up group routes`, async () => { 82 // Can access the same route from different paths 83 expect(await fetch('http://localhost:3000/beta').then((res) => res.text())).toMatch( 84 /<div data-testid="alpha-beta-text">/ 85 ); 86 expect(await fetch('http://localhost:3000/(alpha)/beta').then((res) => res.text())).toMatch( 87 /<div data-testid="alpha-beta-text">/ 88 ); 89 }); 90 it(`can serve up dynamic html routes`, async () => { 91 expect(await fetch('http://localhost:3000/blog/123').then((res) => res.text())).toMatch( 92 /\[post\]/ 93 ); 94 }); 95 it(`can hit the 404 route`, async () => { 96 expect( 97 await fetch('http://localhost:3000/clearly-missing').then((res) => res.text()) 98 ).toMatch(/<div id="root">/); 99 }); 100 101 it( 102 'can use environment variables', 103 async () => { 104 expect(await fetch('http://localhost:3000/api/env-vars').then((res) => res.json())).toEqual( 105 { 106 // This is defined when we start the production server in `beforeAll`. 107 var: 'test-secret-key', 108 } 109 ); 110 }, 111 5 * 1000 112 ); 113 it( 114 'serves the empty route as 405', 115 async () => { 116 await expect(fetch('http://localhost:3000/api/empty').then((r) => r.status)).resolves.toBe( 117 405 118 ); 119 }, 120 5 * 1000 121 ); 122 it( 123 'serves not-found routes as 404', 124 async () => { 125 await expect(fetch('http://localhost:3000/missing').then((r) => r.status)).resolves.toBe( 126 404 127 ); 128 }, 129 5 * 1000 130 ); 131 it( 132 'automatically handles JS errors thrown inside of route handlers as 500', 133 async () => { 134 const res = await fetch('http://localhost:3000/api/problematic'); 135 expect(res.status).toBe(500); 136 expect(res.statusText).toBe('Internal Server Error'); 137 }, 138 5 * 1000 139 ); 140 it( 141 'can POST json to a route', 142 async () => { 143 const res = await fetch('http://localhost:3000/api/json', { 144 method: 'POST', 145 headers: { 146 'Content-Type': 'application/json', 147 }, 148 body: JSON.stringify({ hello: 'world' }), 149 }).then((r) => r.json()); 150 expect(res).toEqual({ hello: 'world' }); 151 }, 152 5 * 1000 153 ); 154 it( 155 'handles pinging routes with unsupported methods with 405 "Method Not Allowed"', 156 async () => { 157 const res = await fetch('http://localhost:3000/api/env-vars', { method: 'POST' }); 158 expect(res.status).toBe(405); 159 expect(res.statusText).toBe('Method Not Allowed'); 160 }, 161 5 * 1000 162 ); 163 it( 164 'supports accessing dynamic parameters using same convention as client-side Expo Router', 165 async () => { 166 await expect(fetch('http://localhost:3000/api/abc').then((r) => r.json())).resolves.toEqual( 167 { 168 hello: 'abc', 169 } 170 ); 171 }, 172 5 * 1000 173 ); 174 it( 175 'supports accessing deep dynamic parameters using different convention to client-side Expo Router', 176 async () => { 177 await expect( 178 fetch('http://localhost:3000/api/a/1/2/3').then((r) => r.json()) 179 ).resolves.toEqual({ 180 results: '1/2/3', 181 }); 182 }, 183 5 * 1000 184 ); 185 it( 186 'supports using Node.js externals to read local files', 187 async () => { 188 await expect( 189 fetch('http://localhost:3000/api/externals').then((r) => r.text()) 190 ).resolves.toEqual('a/b/c'); 191 }, 192 5 * 1000 193 ); 194 }); 195 196 it( 197 'has expected files', 198 async () => { 199 // Request HTML 200 201 // List output files with sizes for snapshotting. 202 // This is to make sure that any changes to the output are intentional. 203 // Posix path formatting is used to make paths the same across OSes. 204 const files = klawSync(outputDir) 205 .map((entry) => { 206 if (entry.path.includes('node_modules') || !entry.stats.isFile()) { 207 return null; 208 } 209 return path.posix.relative(outputDir, entry.path); 210 }) 211 .filter(Boolean); 212 213 // The wrapper should not be included as a route. 214 expect(files).not.toContain('+html.html'); 215 expect(files).not.toContain('_layout.html'); 216 217 // Has routes.json 218 expect(files).toContain('_expo/routes.json'); 219 220 // Has functions 221 expect(files).toContain('_expo/functions/methods+api.js'); 222 expect(files).toContain('_expo/functions/api/[dynamic]+api.js'); 223 expect(files).toContain('_expo/functions/api/externals+api.js'); 224 225 // TODO: We shouldn't export this 226 expect(files).toContain('_expo/functions/api/empty+api.js'); 227 228 // Has single variation of group file 229 expect(files).toContain('(alpha)/beta.html'); 230 expect(files).not.toContain('beta.html'); 231 232 // Injected by framework 233 expect(files).toContain('_sitemap.html'); 234 expect(files).toContain('[...404].html'); 235 236 // Normal routes 237 expect(files).toContain('index.html'); 238 expect(files).toContain('blog/[post].html'); 239 }, 240 5 * 1000 241 ); 242}); 243