105863844SEvan Bacon/* eslint-env jest */ 205863844SEvan Baconimport execa from 'execa'; 305863844SEvan Baconimport fs from 'fs-extra'; 405863844SEvan Baconimport klawSync from 'klaw-sync'; 505863844SEvan Baconimport path from 'path'; 605863844SEvan Bacon 705863844SEvan Baconimport { runExportSideEffects } from './export-side-effects'; 805863844SEvan Baconimport { bin, ensurePortFreeAsync, getPageHtml, getRouterE2ERoot } from '../utils'; 905863844SEvan Bacon 1005863844SEvan BaconrunExportSideEffects(); 1105863844SEvan Bacon 1205863844SEvan Bacondescribe('exports static', () => { 1305863844SEvan Bacon const projectRoot = getRouterE2ERoot(); 1405863844SEvan Bacon const outputName = 'dist-static-rendering'; 1505863844SEvan Bacon const outputDir = path.join(projectRoot, outputName); 1605863844SEvan Bacon 1705863844SEvan Bacon beforeAll( 1805863844SEvan Bacon async () => { 1905863844SEvan Bacon await execa( 2005863844SEvan Bacon 'node', 2105863844SEvan Bacon [bin, 'export', '-p', 'web', '--dump-sourcemap', '--output-dir', outputName], 2205863844SEvan Bacon { 2305863844SEvan Bacon cwd: projectRoot, 2405863844SEvan Bacon env: { 2505863844SEvan Bacon NODE_ENV: 'production', 26*46f023faSEvan Bacon EXPO_USE_STATIC: 'static', 2705863844SEvan Bacon E2E_ROUTER_SRC: 'static-rendering', 2805863844SEvan Bacon E2E_ROUTER_ASYNC: 'development', 2905863844SEvan Bacon }, 3005863844SEvan Bacon } 3105863844SEvan Bacon ); 3205863844SEvan Bacon }, 3305863844SEvan Bacon // Could take 45s depending on how fast the bundler resolves 3405863844SEvan Bacon 560 * 1000 3505863844SEvan Bacon ); 3605863844SEvan Bacon 3705863844SEvan Bacon xdescribe('server', () => { 3805863844SEvan Bacon let server: execa.ExecaChildProcess<string> | undefined; 3905863844SEvan Bacon const serverUrl = 'http://localhost:3000'; 4005863844SEvan Bacon 4105863844SEvan Bacon beforeAll( 4205863844SEvan Bacon async () => { 4305863844SEvan Bacon await ensurePortFreeAsync(3000); 4405863844SEvan Bacon // Start a server instance that we can test against then kill it. 4505863844SEvan Bacon server = execa('npx', ['serve', outputName, '-l', '3000'], { 4605863844SEvan Bacon cwd: projectRoot, 4705863844SEvan Bacon 4805863844SEvan Bacon stderr: 'inherit', 4905863844SEvan Bacon 5005863844SEvan Bacon env: { 5105863844SEvan Bacon NODE_ENV: 'production', 5205863844SEvan Bacon TEST_SECRET_KEY: 'test-secret-key', 5305863844SEvan Bacon }, 5405863844SEvan Bacon }); 5505863844SEvan Bacon // Wait for the server to start 5605863844SEvan Bacon await new Promise((resolve) => { 5705863844SEvan Bacon const listener = server!.stdout?.on('data', (data) => { 5805863844SEvan Bacon if (data.toString().includes('Accepting connections at')) { 5905863844SEvan Bacon resolve(null); 6005863844SEvan Bacon listener?.removeAllListeners(); 6105863844SEvan Bacon } 6205863844SEvan Bacon }); 6305863844SEvan Bacon }); 6405863844SEvan Bacon }, 6505863844SEvan Bacon // 5 seconds to drop a port and start a server. 6605863844SEvan Bacon 5 * 1000 6705863844SEvan Bacon ); 6805863844SEvan Bacon 6905863844SEvan Bacon afterAll(async () => { 7005863844SEvan Bacon if (server) { 7105863844SEvan Bacon server.kill(); 7205863844SEvan Bacon await server; 7305863844SEvan Bacon } 7405863844SEvan Bacon }); 7505863844SEvan Bacon 7605863844SEvan Bacon it(`can serve up index html`, async () => { 7705863844SEvan Bacon expect(await fetch(serverUrl).then((res) => res.text())).toMatch(/<div id="root">/); 7805863844SEvan Bacon }); 7905863844SEvan Bacon it(`gets a 404`, async () => { 8005863844SEvan Bacon expect(await fetch(serverUrl + '/missing-route').then((res) => res.status)).toBe(404); 8105863844SEvan Bacon }); 8205863844SEvan Bacon }); 8305863844SEvan Bacon 8405863844SEvan Bacon it('has expected files', async () => { 8505863844SEvan Bacon // List output files with sizes for snapshotting. 8605863844SEvan Bacon // This is to make sure that any changes to the output are intentional. 8705863844SEvan Bacon // Posix path formatting is used to make paths the same across OSes. 8805863844SEvan Bacon const files = klawSync(outputDir) 8905863844SEvan Bacon .map((entry) => { 9005863844SEvan Bacon if (entry.path.includes('node_modules') || !entry.stats.isFile()) { 9105863844SEvan Bacon return null; 9205863844SEvan Bacon } 9305863844SEvan Bacon return path.posix.relative(outputDir, entry.path); 9405863844SEvan Bacon }) 9505863844SEvan Bacon .filter(Boolean); 9605863844SEvan Bacon 9705863844SEvan Bacon // The wrapper should not be included as a route. 9805863844SEvan Bacon expect(files).not.toContain('+html.html'); 9905863844SEvan Bacon expect(files).not.toContain('_layout.html'); 10005863844SEvan Bacon 10105863844SEvan Bacon // Injected by framework 10205863844SEvan Bacon expect(files).toContain('_sitemap.html'); 10305863844SEvan Bacon expect(files).toContain('[...404].html'); 10405863844SEvan Bacon 10505863844SEvan Bacon // Normal routes 10605863844SEvan Bacon expect(files).toContain('about.html'); 10705863844SEvan Bacon expect(files).toContain('index.html'); 10805863844SEvan Bacon expect(files).toContain('styled.html'); 10905863844SEvan Bacon 11005863844SEvan Bacon // generateStaticParams values 11105863844SEvan Bacon expect(files).toContain('[post].html'); 11205863844SEvan Bacon expect(files).toContain('welcome-to-the-universe.html'); 11305863844SEvan Bacon expect(files).toContain('other.html'); 11405863844SEvan Bacon }); 11505863844SEvan Bacon 11605863844SEvan Bacon it('has source maps', async () => { 11705863844SEvan Bacon // List output files with sizes for snapshotting. 11805863844SEvan Bacon // This is to make sure that any changes to the output are intentional. 11905863844SEvan Bacon // Posix path formatting is used to make paths the same across OSes. 12005863844SEvan Bacon const files = klawSync(outputDir) 12105863844SEvan Bacon .map((entry) => { 12205863844SEvan Bacon if (entry.path.includes('node_modules') || !entry.stats.isFile()) { 12305863844SEvan Bacon return null; 12405863844SEvan Bacon } 12505863844SEvan Bacon return path.posix.relative(outputDir, entry.path); 12605863844SEvan Bacon }) 12705863844SEvan Bacon .filter(Boolean); 12805863844SEvan Bacon 12905863844SEvan Bacon const mapFiles = files.filter((file) => file?.endsWith('.map')); 13005863844SEvan Bacon expect(mapFiles).toEqual([expect.stringMatching(/_expo\/static\/js\/web\/index-.*\.map/)]); 13105863844SEvan Bacon 13205863844SEvan Bacon for (const file of mapFiles) { 13305863844SEvan Bacon // Ensure the bundle does not contain a source map reference 13405863844SEvan Bacon const sourceMap = JSON.parse(fs.readFileSync(path.join(outputDir, file!), 'utf8')); 13505863844SEvan Bacon expect(sourceMap.version).toBe(3); 13605863844SEvan Bacon expect(sourceMap.sources).toEqual( 13705863844SEvan Bacon expect.arrayContaining([ 13805863844SEvan Bacon '__prelude__', 13905863844SEvan Bacon // NOTE: No `/Users/evanbacon/`... 14005863844SEvan Bacon '/node_modules/metro-runtime/src/polyfills/require.js', 14105863844SEvan Bacon 14205863844SEvan Bacon // NOTE: relative to the server root for optimal source map support 14305863844SEvan Bacon '/apps/router-e2e/__e2e__/static-rendering/app/[post].tsx', 14405863844SEvan Bacon ]) 14505863844SEvan Bacon ); 14605863844SEvan Bacon } 14705863844SEvan Bacon 14805863844SEvan Bacon const jsFiles = files.filter((file) => file?.endsWith('.js')); 14905863844SEvan Bacon 15005863844SEvan Bacon for (const file of jsFiles) { 15105863844SEvan Bacon // Ensure the bundle does not contain a source map reference 15205863844SEvan Bacon const jsBundle = fs.readFileSync(path.join(outputDir, file!), 'utf8'); 15305863844SEvan Bacon expect(jsBundle).toMatch( 15405863844SEvan Bacon /^\/\/\# sourceMappingURL=\/_expo\/static\/js\/web\/index-.*\.map$/gm 15505863844SEvan Bacon ); 15605863844SEvan Bacon expect(jsBundle).toMatch(/^\/\/\# sourceURL=\/_expo\/static\/js\/web\/index-.*\.js$/gm); 15705863844SEvan Bacon const mapFile = jsBundle.match( 15805863844SEvan Bacon /^\/\/\# sourceMappingURL=(\/_expo\/static\/js\/web\/index-.*\.map)$/m 15905863844SEvan Bacon )?.[1]; 16005863844SEvan Bacon 16105863844SEvan Bacon expect(fs.existsSync(path.join(outputDir, mapFile!))).toBe(true); 16205863844SEvan Bacon } 16305863844SEvan Bacon }); 16405863844SEvan Bacon 16505863844SEvan Bacon it('can use environment variables', async () => { 16605863844SEvan Bacon const indexHtml = await getPageHtml(outputDir, 'index.html'); 16705863844SEvan Bacon 16805863844SEvan Bacon const queryMeta = (name: string) => 16905863844SEvan Bacon indexHtml.querySelector(`html > head > meta[name="${name}"]`)?.attributes.content; 17005863844SEvan Bacon 17105863844SEvan Bacon // Injected in app/+html.js 17205863844SEvan Bacon expect(queryMeta('expo-e2e-public-env-var')).toEqual('foobar'); 17305863844SEvan Bacon // non-public env vars are injected during SSG 17405863844SEvan Bacon expect(queryMeta('expo-e2e-private-env-var')).toEqual('not-public-value'); 17505863844SEvan Bacon 17605863844SEvan Bacon // Injected in app/_layout.js 17705863844SEvan Bacon expect(queryMeta('expo-e2e-public-env-var-client')).toEqual('foobar'); 17805863844SEvan Bacon // non-public env vars are injected during SSG 17905863844SEvan Bacon expect(queryMeta('expo-e2e-private-env-var-client')).toEqual('not-public-value'); 18005863844SEvan Bacon 18105863844SEvan Bacon indexHtml.querySelectorAll('script').forEach((script) => { 18205863844SEvan Bacon const jsBundle = fs.readFileSync(path.join(outputDir, script.attributes.src), 'utf8'); 18305863844SEvan Bacon 18405863844SEvan Bacon // Ensure the bundle is valid 18505863844SEvan Bacon expect(jsBundle).toMatch('__BUNDLE_START_TIME__'); 18605863844SEvan Bacon // Ensure the non-public env var is not included in the bundle 18705863844SEvan Bacon expect(jsBundle).not.toMatch('not-public-value'); 18805863844SEvan Bacon }); 18905863844SEvan Bacon }); 19005863844SEvan Bacon 19105863844SEvan Bacon it('static styles are injected', async () => { 19205863844SEvan Bacon const indexHtml = await getPageHtml(outputDir, 'index.html'); 19305863844SEvan Bacon expect(indexHtml.querySelectorAll('html > head > style')?.length).toBe( 19405863844SEvan Bacon // React Native and Expo resets 19505863844SEvan Bacon 3 19605863844SEvan Bacon ); 19705863844SEvan Bacon // The Expo style reset 19805863844SEvan Bacon expect(indexHtml.querySelector('html > head > style#expo-reset')?.innerHTML).toEqual( 19905863844SEvan Bacon expect.stringContaining('#root,body{display:flex}') 20005863844SEvan Bacon ); 20105863844SEvan Bacon 20205863844SEvan Bacon expect( 20305863844SEvan Bacon indexHtml.querySelector('html > head > style#react-native-stylesheet')?.innerHTML 20405863844SEvan Bacon ).toEqual(expect.stringContaining('[stylesheet-group="0"]{}')); 20505863844SEvan Bacon }); 20605863844SEvan Bacon 20705863844SEvan Bacon it('statically extracts CSS', async () => { 20805863844SEvan Bacon // Unfortunately, the CSS is injected in every page for now since we don't have bundle splitting. 20905863844SEvan Bacon const indexHtml = await getPageHtml(outputDir, 'index.html'); 21005863844SEvan Bacon 21105863844SEvan Bacon const links = indexHtml.querySelectorAll('html > head > link').filter((link) => { 21205863844SEvan Bacon // Fonts are tested elsewhere 21305863844SEvan Bacon return link.attributes.as !== 'font'; 21405863844SEvan Bacon }); 21505863844SEvan Bacon expect(links.length).toBe( 21605863844SEvan Bacon // Global CSS, CSS Module 21705863844SEvan Bacon 4 21805863844SEvan Bacon ); 21905863844SEvan Bacon 22005863844SEvan Bacon links.forEach((link) => { 22105863844SEvan Bacon // Linked to the expected static location 22205863844SEvan Bacon expect(link.attributes.href).toMatch(/^\/_expo\/static\/css\/.*\.css$/); 22305863844SEvan Bacon }); 22405863844SEvan Bacon 22505863844SEvan Bacon expect(links[0].toString()).toMatch( 22605863844SEvan Bacon /<link rel="preload" href="\/_expo\/static\/css\/global-[\d\w]+\.css" as="style">/ 22705863844SEvan Bacon ); 22805863844SEvan Bacon expect(links[1].toString()).toMatch( 22905863844SEvan Bacon /<link rel="stylesheet" href="\/_expo\/static\/css\/global-[\d\w]+\.css">/ 23005863844SEvan Bacon ); 23105863844SEvan Bacon // CSS Module 23205863844SEvan Bacon expect(links[2].toString()).toMatch( 23305863844SEvan Bacon /<link rel="preload" href="\/_expo\/static\/css\/test\.module-[\d\w]+\.css" as="style">/ 23405863844SEvan Bacon ); 23505863844SEvan Bacon expect(links[3].toString()).toMatch( 23605863844SEvan Bacon /<link rel="stylesheet" href="\/_expo\/static\/css\/test\.module-[\d\w]+\.css">/ 23705863844SEvan Bacon ); 23805863844SEvan Bacon 23905863844SEvan Bacon expect( 24005863844SEvan Bacon fs.readFileSync(path.join(outputDir, links[0].attributes.href), 'utf-8') 24105863844SEvan Bacon ).toMatchInlineSnapshot(`"div{background:#0ff}"`); 24205863844SEvan Bacon 24305863844SEvan Bacon // CSS Module 24405863844SEvan Bacon expect( 24505863844SEvan Bacon fs.readFileSync(path.join(outputDir, links[2].attributes.href), 'utf-8') 24605863844SEvan Bacon ).toMatchInlineSnapshot(`".HPV33q_text{color:#1e90ff}"`); 24705863844SEvan Bacon 24805863844SEvan Bacon const styledHtml = await getPageHtml(outputDir, 'styled.html'); 24905863844SEvan Bacon 25005863844SEvan Bacon // Ensure the atomic CSS class is used 25105863844SEvan Bacon expect( 25205863844SEvan Bacon styledHtml.querySelector('html > body div[data-testid="styled-text"]')?.attributes.class 25305863844SEvan Bacon ).toMatch('HPV33q_text'); 25405863844SEvan Bacon }); 25505863844SEvan Bacon 25605863844SEvan Bacon it('statically extracts fonts', async () => { 25705863844SEvan Bacon // <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="" /> 25805863844SEvan Bacon // Unfortunately, the CSS is injected in every page for now since we don't have bundle splitting. 25905863844SEvan Bacon const indexHtml = await getPageHtml(outputDir, 'index.html'); 26005863844SEvan Bacon 26105863844SEvan Bacon const links = indexHtml.querySelectorAll('html > head > link[as="font"]'); 26205863844SEvan Bacon expect(links.length).toBe(1); 26305863844SEvan Bacon expect(links[0].attributes.href).toBe( 26405863844SEvan Bacon '/assets/__e2e__/static-rendering/sweet.ttf?platform=web&hash=7c9263d3cffcda46ff7a4d9c00472c07' 26505863844SEvan Bacon ); 26605863844SEvan Bacon 26705863844SEvan Bacon expect(links[0].toString()).toMatch( 26805863844SEvan Bacon /<link rel="preload" href="\/assets\/__e2e__\/static-rendering\/sweet\.ttf\?platform=web&hash=[\d\w]+" as="font" crossorigin="" >/ 26905863844SEvan Bacon ); 27005863844SEvan Bacon 27105863844SEvan Bacon expect( 27205863844SEvan Bacon fs.readFileSync(path.join(outputDir, links[0].attributes.href.replace(/\?.*$/, '')), 'utf-8') 27305863844SEvan Bacon ).toBeDefined(); 27405863844SEvan Bacon 27505863844SEvan Bacon // Ensure the font is used 27605863844SEvan Bacon expect(indexHtml.querySelector('div[data-testid="index-text"]')?.attributes.style).toMatch( 27705863844SEvan Bacon 'font-family:sweet' 27805863844SEvan Bacon ); 27905863844SEvan Bacon 28005863844SEvan Bacon // Fonts have proper splitting due to how they're loaded during static rendering, we should test 28105863844SEvan Bacon // that certain fonts only show on the about page. 28205863844SEvan Bacon const aboutHtml = await getPageHtml(outputDir, 'about.html'); 28305863844SEvan Bacon 28405863844SEvan Bacon const aboutLinks = aboutHtml.querySelectorAll('html > head > link[as="font"]'); 28505863844SEvan Bacon expect(aboutLinks.length).toBe(2); 28605863844SEvan Bacon expect(aboutLinks[1].attributes.href).toMatch( 28705863844SEvan Bacon /react-native-vector-icons\/Fonts\/EvilIcons\.ttf/ 28805863844SEvan Bacon ); 28905863844SEvan Bacon }); 29005863844SEvan Bacon 29105863844SEvan Bacon it('supports usePathname in +html files', async () => { 29205863844SEvan Bacon const page = await fs.readFile(path.join(outputDir, 'index.html'), 'utf8'); 29305863844SEvan Bacon 29405863844SEvan Bacon expect(page).toContain('<meta name="custom-value" content="value"/>'); 29505863844SEvan Bacon 29605863844SEvan Bacon // Root element 29705863844SEvan Bacon expect(page).toContain('<div id="root">'); 29805863844SEvan Bacon 29905863844SEvan Bacon const sanitized = page.replace( 30005863844SEvan Bacon /<script src="\/_expo\/static\/js\/web\/.*" defer>/g, 30105863844SEvan Bacon '<script src="/_expo/static/js/web/[mock].js" defer>' 30205863844SEvan Bacon ); 30305863844SEvan Bacon expect(sanitized).toMatchSnapshot(); 30405863844SEvan Bacon 30505863844SEvan Bacon expect( 30605863844SEvan Bacon (await getPageHtml(outputDir, 'about.html')).querySelector( 30705863844SEvan Bacon 'html > head > meta[name="expo-e2e-pathname"]' 30805863844SEvan Bacon )?.attributes.content 30905863844SEvan Bacon ).toBe('/about'); 31005863844SEvan Bacon 31105863844SEvan Bacon expect( 31205863844SEvan Bacon (await getPageHtml(outputDir, 'index.html')).querySelector( 31305863844SEvan Bacon 'html > head > meta[name="expo-e2e-pathname"]' 31405863844SEvan Bacon )?.attributes.content 31505863844SEvan Bacon ).toBe('/'); 31605863844SEvan Bacon 31705863844SEvan Bacon expect( 31805863844SEvan Bacon (await getPageHtml(outputDir, 'welcome-to-the-universe.html')).querySelector( 31905863844SEvan Bacon 'html > head > meta[name="expo-e2e-pathname"]' 32005863844SEvan Bacon )?.attributes.content 32105863844SEvan Bacon ).toBe('/welcome-to-the-universe'); 32205863844SEvan Bacon }); 32305863844SEvan Bacon 32405863844SEvan Bacon it('supports nested static head values', async () => { 32505863844SEvan Bacon // <title>About | Website</title> 32605863844SEvan Bacon // <meta name="description" content="About page" /> 32705863844SEvan Bacon const about = await getPageHtml(outputDir, 'about.html'); 32805863844SEvan Bacon 32905863844SEvan Bacon expect(about.querySelector('html > body div[data-testid="content"]')?.innerText).toBe('About'); 33005863844SEvan Bacon expect(about.querySelector('html > head > title')?.innerText).toBe('About | Website'); 33105863844SEvan Bacon expect(about.querySelector('html > head > meta[name="description"]')?.attributes.content).toBe( 33205863844SEvan Bacon 'About page' 33305863844SEvan Bacon ); 33405863844SEvan Bacon expect( 33505863844SEvan Bacon // Nested from app/_layout.js 33605863844SEvan Bacon about.querySelector('html > head > meta[name="expo-nested-layout"]')?.attributes.content 33705863844SEvan Bacon ).toBe('TEST_VALUE'); 33805863844SEvan Bacon 33905863844SEvan Bacon expect( 34005863844SEvan Bacon // Other routes have the nested layout value 34105863844SEvan Bacon (await getPageHtml(outputDir, 'welcome-to-the-universe.html')).querySelector( 34205863844SEvan Bacon 'html > head > meta[name="expo-nested-layout"]' 34305863844SEvan Bacon )?.attributes.content 34405863844SEvan Bacon ).toBe('TEST_VALUE'); 34505863844SEvan Bacon }); 34605863844SEvan Bacon}); 347