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