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