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    it(`can serve up dynamic html routes`, async () => {
81      expect(await fetch('http://localhost:3000/blog/123').then((res) => res.text())).toMatch(
82        /\[post\]/
83      );
84    });
85    it(`can hit the 404 route`, async () => {
86      expect(
87        await fetch('http://localhost:3000/clearly-missing').then((res) => res.text())
88      ).toMatch(/<div id="root">/);
89    });
90
91    it(
92      'can use environment variables',
93      async () => {
94        expect(await fetch('http://localhost:3000/api/env-vars').then((res) => res.json())).toEqual(
95          {
96            // This is defined when we start the production server in `beforeAll`.
97            var: 'test-secret-key',
98          }
99        );
100      },
101      5 * 1000
102    );
103    it(
104      'serves the empty route as 405',
105      async () => {
106        await expect(fetch('http://localhost:3000/api/empty').then((r) => r.status)).resolves.toBe(
107          405
108        );
109      },
110      5 * 1000
111    );
112    it(
113      'serves not-found routes as 404',
114      async () => {
115        await expect(fetch('http://localhost:3000/missing').then((r) => r.status)).resolves.toBe(
116          404
117        );
118      },
119      5 * 1000
120    );
121    it(
122      'automatically handles JS errors thrown inside of route handlers as 500',
123      async () => {
124        const res = await fetch('http://localhost:3000/api/problematic');
125        expect(res.status).toBe(500);
126        expect(res.statusText).toBe('Internal Server Error');
127      },
128      5 * 1000
129    );
130    it(
131      'can POST json to a route',
132      async () => {
133        const res = await fetch('http://localhost:3000/api/json', {
134          method: 'POST',
135          headers: {
136            'Content-Type': 'application/json',
137          },
138          body: JSON.stringify({ hello: 'world' }),
139        }).then((r) => r.json());
140        expect(res).toEqual({ hello: 'world' });
141      },
142      5 * 1000
143    );
144    it(
145      'handles pinging routes with unsupported methods with 405 "Method Not Allowed"',
146      async () => {
147        const res = await fetch('http://localhost:3000/api/env-vars', { method: 'POST' });
148        expect(res.status).toBe(405);
149        expect(res.statusText).toBe('Method Not Allowed');
150      },
151      5 * 1000
152    );
153    it(
154      'supports accessing dynamic parameters using same convention as client-side Expo Router',
155      async () => {
156        await expect(fetch('http://localhost:3000/api/abc').then((r) => r.json())).resolves.toEqual(
157          {
158            hello: 'abc',
159          }
160        );
161      },
162      5 * 1000
163    );
164    it(
165      'supports accessing deep dynamic parameters using different convention to client-side Expo Router',
166      async () => {
167        await expect(
168          fetch('http://localhost:3000/api/a/1/2/3').then((r) => r.json())
169        ).resolves.toEqual({
170          results: '1/2/3',
171        });
172      },
173      5 * 1000
174    );
175    it(
176      'supports using Node.js externals to read local files',
177      async () => {
178        await expect(
179          fetch('http://localhost:3000/api/externals').then((r) => r.text())
180        ).resolves.toEqual('a/b/c');
181      },
182      5 * 1000
183    );
184  });
185
186  it(
187    'has expected files',
188    async () => {
189      // Request HTML
190
191      // List output files with sizes for snapshotting.
192      // This is to make sure that any changes to the output are intentional.
193      // Posix path formatting is used to make paths the same across OSes.
194      const files = klawSync(outputDir)
195        .map((entry) => {
196          if (entry.path.includes('node_modules') || !entry.stats.isFile()) {
197            return null;
198          }
199          return path.posix.relative(outputDir, entry.path);
200        })
201        .filter(Boolean);
202
203      // The wrapper should not be included as a route.
204      expect(files).not.toContain('+html.html');
205      expect(files).not.toContain('_layout.html');
206
207      // Has routes.json
208      expect(files).toContain('_expo/routes.json');
209
210      // Has functions
211      expect(files).toContain('_expo/functions/methods+api.js');
212      expect(files).toContain('_expo/functions/api/[dynamic]+api.js');
213      expect(files).toContain('_expo/functions/api/externals+api.js');
214
215      // TODO: We shouldn't export this
216      expect(files).toContain('_expo/functions/api/empty+api.js');
217
218      // Injected by framework
219      expect(files).toContain('_sitemap.html');
220      expect(files).toContain('[...404].html');
221
222      // Normal routes
223      expect(files).toContain('index.html');
224      expect(files).toContain('blog/[post].html');
225    },
226    5 * 1000
227  );
228});
229