1/* eslint-env jest */ 2import execa from 'execa'; 3import fs from 'fs-extra'; 4import klawSync from 'klaw-sync'; 5import path from 'path'; 6 7import { runExportSideEffects } from './export-side-effects'; 8import { bin, ensurePortFreeAsync, getPageHtml, getRouterE2ERoot } from '../utils'; 9 10runExportSideEffects(); 11 12describe('exports static', () => { 13 const projectRoot = getRouterE2ERoot(); 14 const outputName = 'dist-static-rendering'; 15 const outputDir = path.join(projectRoot, outputName); 16 17 beforeAll( 18 async () => { 19 await execa( 20 'node', 21 [bin, 'export', '-p', 'web', '--dump-sourcemap', '--output-dir', outputName], 22 { 23 cwd: projectRoot, 24 env: { 25 NODE_ENV: 'production', 26 EXPO_USE_STATIC: 'static', 27 E2E_ROUTER_SRC: 'static-rendering', 28 E2E_ROUTER_ASYNC: 'development', 29 }, 30 } 31 ); 32 }, 33 // Could take 45s depending on how fast the bundler resolves 34 560 * 1000 35 ); 36 37 xdescribe('server', () => { 38 let server: execa.ExecaChildProcess<string> | undefined; 39 const serverUrl = 'http://localhost:3000'; 40 41 beforeAll( 42 async () => { 43 await ensurePortFreeAsync(3000); 44 // Start a server instance that we can test against then kill it. 45 server = execa('npx', ['serve', outputName, '-l', '3000'], { 46 cwd: projectRoot, 47 48 stderr: 'inherit', 49 50 env: { 51 NODE_ENV: 'production', 52 TEST_SECRET_KEY: 'test-secret-key', 53 }, 54 }); 55 // Wait for the server to start 56 await new Promise((resolve) => { 57 const listener = server!.stdout?.on('data', (data) => { 58 if (data.toString().includes('Accepting connections at')) { 59 resolve(null); 60 listener?.removeAllListeners(); 61 } 62 }); 63 }); 64 }, 65 // 5 seconds to drop a port and start a server. 66 5 * 1000 67 ); 68 69 afterAll(async () => { 70 if (server) { 71 server.kill(); 72 await server; 73 } 74 }); 75 76 it(`can serve up index html`, async () => { 77 expect(await fetch(serverUrl).then((res) => res.text())).toMatch(/<div id="root">/); 78 }); 79 it(`gets a 404`, async () => { 80 expect(await fetch(serverUrl + '/missing-route').then((res) => res.status)).toBe(404); 81 }); 82 }); 83 84 it('has expected files', async () => { 85 // List output files with sizes for snapshotting. 86 // This is to make sure that any changes to the output are intentional. 87 // Posix path formatting is used to make paths the same across OSes. 88 const files = klawSync(outputDir) 89 .map((entry) => { 90 if (entry.path.includes('node_modules') || !entry.stats.isFile()) { 91 return null; 92 } 93 return path.posix.relative(outputDir, entry.path); 94 }) 95 .filter(Boolean); 96 97 // The wrapper should not be included as a route. 98 expect(files).not.toContain('+html.html'); 99 expect(files).not.toContain('_layout.html'); 100 101 // Injected by framework 102 expect(files).toContain('_sitemap.html'); 103 expect(files).toContain('[...404].html'); 104 105 // Normal routes 106 expect(files).toContain('about.html'); 107 expect(files).toContain('index.html'); 108 expect(files).toContain('styled.html'); 109 110 // generateStaticParams values 111 expect(files).toContain('[post].html'); 112 expect(files).toContain('welcome-to-the-universe.html'); 113 expect(files).toContain('other.html'); 114 }); 115 116 it('has source maps', async () => { 117 // List output files with sizes for snapshotting. 118 // This is to make sure that any changes to the output are intentional. 119 // Posix path formatting is used to make paths the same across OSes. 120 const files = klawSync(outputDir) 121 .map((entry) => { 122 if (entry.path.includes('node_modules') || !entry.stats.isFile()) { 123 return null; 124 } 125 return path.posix.relative(outputDir, entry.path); 126 }) 127 .filter(Boolean); 128 129 const mapFiles = files.filter((file) => file?.endsWith('.map')); 130 expect(mapFiles).toEqual([expect.stringMatching(/_expo\/static\/js\/web\/index-.*\.map/)]); 131 132 for (const file of mapFiles) { 133 // Ensure the bundle does not contain a source map reference 134 const sourceMap = JSON.parse(fs.readFileSync(path.join(outputDir, file!), 'utf8')); 135 expect(sourceMap.version).toBe(3); 136 expect(sourceMap.sources).toEqual( 137 expect.arrayContaining([ 138 '__prelude__', 139 // NOTE: No `/Users/evanbacon/`... 140 '/node_modules/metro-runtime/src/polyfills/require.js', 141 142 // NOTE: relative to the server root for optimal source map support 143 '/apps/router-e2e/__e2e__/static-rendering/app/[post].tsx', 144 ]) 145 ); 146 } 147 148 const jsFiles = files.filter((file) => file?.endsWith('.js')); 149 150 for (const file of jsFiles) { 151 // Ensure the bundle does not contain a source map reference 152 const jsBundle = fs.readFileSync(path.join(outputDir, file!), 'utf8'); 153 expect(jsBundle).toMatch( 154 /^\/\/\# sourceMappingURL=\/_expo\/static\/js\/web\/index-.*\.map$/gm 155 ); 156 expect(jsBundle).toMatch(/^\/\/\# sourceURL=\/_expo\/static\/js\/web\/index-.*\.js$/gm); 157 const mapFile = jsBundle.match( 158 /^\/\/\# sourceMappingURL=(\/_expo\/static\/js\/web\/index-.*\.map)$/m 159 )?.[1]; 160 161 expect(fs.existsSync(path.join(outputDir, mapFile!))).toBe(true); 162 } 163 }); 164 165 it('can use environment variables', async () => { 166 const indexHtml = await getPageHtml(outputDir, 'index.html'); 167 168 const queryMeta = (name: string) => 169 indexHtml.querySelector(`html > head > meta[name="${name}"]`)?.attributes.content; 170 171 // Injected in app/+html.js 172 expect(queryMeta('expo-e2e-public-env-var')).toEqual('foobar'); 173 // non-public env vars are injected during SSG 174 expect(queryMeta('expo-e2e-private-env-var')).toEqual('not-public-value'); 175 176 // Injected in app/_layout.js 177 expect(queryMeta('expo-e2e-public-env-var-client')).toEqual('foobar'); 178 // non-public env vars are injected during SSG 179 expect(queryMeta('expo-e2e-private-env-var-client')).toEqual('not-public-value'); 180 181 indexHtml.querySelectorAll('script').forEach((script) => { 182 const jsBundle = fs.readFileSync(path.join(outputDir, script.attributes.src), 'utf8'); 183 184 // Ensure the bundle is valid 185 expect(jsBundle).toMatch('__BUNDLE_START_TIME__'); 186 // Ensure the non-public env var is not included in the bundle 187 expect(jsBundle).not.toMatch('not-public-value'); 188 }); 189 }); 190 191 it('static styles are injected', async () => { 192 const indexHtml = await getPageHtml(outputDir, 'index.html'); 193 expect(indexHtml.querySelectorAll('html > head > style')?.length).toBe( 194 // React Native and Expo resets 195 3 196 ); 197 // The Expo style reset 198 expect(indexHtml.querySelector('html > head > style#expo-reset')?.innerHTML).toEqual( 199 expect.stringContaining('#root,body{display:flex}') 200 ); 201 202 expect( 203 indexHtml.querySelector('html > head > style#react-native-stylesheet')?.innerHTML 204 ).toEqual(expect.stringContaining('[stylesheet-group="0"]{}')); 205 }); 206 207 it('statically extracts CSS', async () => { 208 // Unfortunately, the CSS is injected in every page for now since we don't have bundle splitting. 209 const indexHtml = await getPageHtml(outputDir, 'index.html'); 210 211 const links = indexHtml.querySelectorAll('html > head > link').filter((link) => { 212 // Fonts are tested elsewhere 213 return link.attributes.as !== 'font'; 214 }); 215 expect(links.length).toBe( 216 // Global CSS, CSS Module 217 4 218 ); 219 220 links.forEach((link) => { 221 // Linked to the expected static location 222 expect(link.attributes.href).toMatch(/^\/_expo\/static\/css\/.*\.css$/); 223 }); 224 225 expect(links[0].toString()).toMatch( 226 /<link rel="preload" href="\/_expo\/static\/css\/global-[\d\w]+\.css" as="style">/ 227 ); 228 expect(links[1].toString()).toMatch( 229 /<link rel="stylesheet" href="\/_expo\/static\/css\/global-[\d\w]+\.css">/ 230 ); 231 // CSS Module 232 expect(links[2].toString()).toMatch( 233 /<link rel="preload" href="\/_expo\/static\/css\/test\.module-[\d\w]+\.css" as="style">/ 234 ); 235 expect(links[3].toString()).toMatch( 236 /<link rel="stylesheet" href="\/_expo\/static\/css\/test\.module-[\d\w]+\.css">/ 237 ); 238 239 expect( 240 fs.readFileSync(path.join(outputDir, links[0].attributes.href), 'utf-8') 241 ).toMatchInlineSnapshot(`"div{background:#0ff}"`); 242 243 // CSS Module 244 expect( 245 fs.readFileSync(path.join(outputDir, links[2].attributes.href), 'utf-8') 246 ).toMatchInlineSnapshot(`".HPV33q_text{color:#1e90ff}"`); 247 248 const styledHtml = await getPageHtml(outputDir, 'styled.html'); 249 250 // Ensure the atomic CSS class is used 251 expect( 252 styledHtml.querySelector('html > body div[data-testid="styled-text"]')?.attributes.class 253 ).toMatch('HPV33q_text'); 254 }); 255 256 it('statically extracts fonts', async () => { 257 // <style id="expo-generated-fonts" type="text/css">@font-face{font-family:sweet;src:url(/assets/__e2e__/static-rendering/sweet.ttf?platform=web&hash=7c9263d3cffcda46ff7a4d9c00472c07);font-display:auto}</style><link rel="preload" href="/assets/__e2e__/static-rendering/sweet.ttf?platform=web&hash=7c9263d3cffcda46ff7a4d9c00472c07" as="font" crossorigin="" /> 258 // Unfortunately, the CSS is injected in every page for now since we don't have bundle splitting. 259 const indexHtml = await getPageHtml(outputDir, 'index.html'); 260 261 const links = indexHtml.querySelectorAll('html > head > link[as="font"]'); 262 expect(links.length).toBe(1); 263 expect(links[0].attributes.href).toBe( 264 '/assets/__e2e__/static-rendering/sweet.ttf?platform=web&hash=7c9263d3cffcda46ff7a4d9c00472c07' 265 ); 266 267 expect(links[0].toString()).toMatch( 268 /<link rel="preload" href="\/assets\/__e2e__\/static-rendering\/sweet\.ttf\?platform=web&hash=[\d\w]+" as="font" crossorigin="" >/ 269 ); 270 271 expect( 272 fs.readFileSync(path.join(outputDir, links[0].attributes.href.replace(/\?.*$/, '')), 'utf-8') 273 ).toBeDefined(); 274 275 // Ensure the font is used 276 expect(indexHtml.querySelector('div[data-testid="index-text"]')?.attributes.style).toMatch( 277 'font-family:sweet' 278 ); 279 280 // Fonts have proper splitting due to how they're loaded during static rendering, we should test 281 // that certain fonts only show on the about page. 282 const aboutHtml = await getPageHtml(outputDir, 'about.html'); 283 284 const aboutLinks = aboutHtml.querySelectorAll('html > head > link[as="font"]'); 285 expect(aboutLinks.length).toBe(2); 286 expect(aboutLinks[1].attributes.href).toMatch( 287 /react-native-vector-icons\/Fonts\/EvilIcons\.ttf/ 288 ); 289 }); 290 291 it('supports usePathname in +html files', async () => { 292 const page = await fs.readFile(path.join(outputDir, 'index.html'), 'utf8'); 293 294 expect(page).toContain('<meta name="custom-value" content="value"/>'); 295 296 // Root element 297 expect(page).toContain('<div id="root">'); 298 299 const sanitized = page.replace( 300 /<script src="\/_expo\/static\/js\/web\/.*" defer>/g, 301 '<script src="/_expo/static/js/web/[mock].js" defer>' 302 ); 303 expect(sanitized).toMatchSnapshot(); 304 305 expect( 306 (await getPageHtml(outputDir, 'about.html')).querySelector( 307 'html > head > meta[name="expo-e2e-pathname"]' 308 )?.attributes.content 309 ).toBe('/about'); 310 311 expect( 312 (await getPageHtml(outputDir, 'index.html')).querySelector( 313 'html > head > meta[name="expo-e2e-pathname"]' 314 )?.attributes.content 315 ).toBe('/'); 316 317 expect( 318 (await getPageHtml(outputDir, 'welcome-to-the-universe.html')).querySelector( 319 'html > head > meta[name="expo-e2e-pathname"]' 320 )?.attributes.content 321 ).toBe('/welcome-to-the-universe'); 322 }); 323 324 it('supports nested static head values', async () => { 325 // <title>About | Website</title> 326 // <meta name="description" content="About page" /> 327 const about = await getPageHtml(outputDir, 'about.html'); 328 329 expect(about.querySelector('html > body div[data-testid="content"]')?.innerText).toBe('About'); 330 expect(about.querySelector('html > head > title')?.innerText).toBe('About | Website'); 331 expect(about.querySelector('html > head > meta[name="description"]')?.attributes.content).toBe( 332 'About page' 333 ); 334 expect( 335 // Nested from app/_layout.js 336 about.querySelector('html > head > meta[name="expo-nested-layout"]')?.attributes.content 337 ).toBe('TEST_VALUE'); 338 339 expect( 340 // Other routes have the nested layout value 341 (await getPageHtml(outputDir, 'welcome-to-the-universe.html')).querySelector( 342 'html > head > meta[name="expo-nested-layout"]' 343 )?.attributes.content 344 ).toBe('TEST_VALUE'); 345 }); 346}); 347