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