1/* eslint-env jest */
2import {
3  isMultipartPartWithName,
4  parseMultipartMixedResponseAsync,
5} from '@expo/multipart-body-parser';
6import execa from 'execa';
7import fs from 'fs-extra';
8import fetch from 'node-fetch';
9import nullthrows from 'nullthrows';
10import path from 'path';
11
12import {
13  execute,
14  projectRoot,
15  getLoadedModulesAsync,
16  setupTestProjectAsync,
17  bin,
18  ensurePortFreeAsync,
19} from './utils';
20
21const originalForceColor = process.env.FORCE_COLOR;
22const originalCI = process.env.CI;
23
24beforeAll(async () => {
25  await fs.mkdir(projectRoot, { recursive: true });
26  process.env.FORCE_COLOR = '0';
27  process.env.CI = '1';
28});
29
30afterAll(() => {
31  process.env.FORCE_COLOR = originalForceColor;
32  process.env.CI = originalCI;
33});
34
35it('loads expected modules by default', async () => {
36  const modules = await getLoadedModulesAsync(`require('../../build/src/start').expoStart`);
37  expect(modules).toStrictEqual([
38    '../node_modules/ansi-styles/index.js',
39    '../node_modules/arg/index.js',
40    '../node_modules/chalk/source/index.js',
41    '../node_modules/chalk/source/util.js',
42    '../node_modules/has-flag/index.js',
43    '../node_modules/supports-color/index.js',
44    '@expo/cli/build/src/log.js',
45    '@expo/cli/build/src/start/index.js',
46    '@expo/cli/build/src/utils/args.js',
47    '@expo/cli/build/src/utils/errors.js',
48  ]);
49});
50
51it('runs `npx expo start --help`', async () => {
52  const results = await execute('start', '--help');
53  expect(results.stdout).toMatchInlineSnapshot(`
54    "
55      Info
56        Start a local dev server for the app
57
58      Usage
59        $ npx expo start <dir>
60
61      Options
62        <dir>                                  Directory of the Expo project. Default: Current working directory
63        -a, --android                          Opens your app in Expo Go on a connected Android device
64        -i, --ios                              Opens your app in Expo Go in a currently running iOS simulator on your computer
65        -w, --web                              Opens your app in a web browser
66
67        -c, --clear                            Clear the bundler cache
68        --max-workers <num>                    Maximum number of tasks to allow Metro to spawn
69        --no-dev                               Bundle in production mode
70        --minify                               Minify JavaScript
71
72        -m, --host <mode>                      Dev server hosting type. Default: lan
73                                               lan: Use the local network
74                                               tunnel: Use any network by tunnel through ngrok
75                                               localhost: Connect to the dev server over localhost
76        --tunnel                               Same as --host tunnel
77        --lan                                  Same as --host lan
78        --localhost                            Same as --host localhost
79
80        --offline                              Skip network requests and use anonymous manifest signatures
81        --https                                Start the dev server with https protocol
82        --scheme <scheme>                      Custom URI protocol to use when launching an app
83        -p, --port <port>                      Port to start the dev server on (does not apply to web or tunnel). Default: 19000
84
85        --dev-client                           Experimental: Starts the bundler for use with the expo-development-client
86        --force-manifest-type <manifest-type>  Override auto detection of manifest type
87        --private-key-path <path>              Path to private key for code signing. Default: "private-key.pem" in the same directory as the certificate specified by the expo-updates configuration in app.json.
88        -h, --help                             Usage info
89    "
90  `);
91});
92
93for (const args of [
94  ['--lan', '--tunnel'],
95  ['--offline', '--localhost'],
96  ['--host', 'localhost', '--tunnel'],
97  ['-m', 'localhost', '--lan', '--offline'],
98]) {
99  it(`asserts invalid URL arguments on \`expo start ${args.join(' ')}\``, async () => {
100    await expect(execa('node', [bin, 'start', ...args], { cwd: projectRoot })).rejects.toThrowError(
101      /Specify at most one of/
102    );
103  });
104}
105
106describe('server', () => {
107  // Kill port
108  const kill = () => ensurePortFreeAsync(19000);
109
110  beforeEach(async () => {
111    await kill();
112  });
113
114  afterAll(async () => {
115    await kill();
116  });
117  it(
118    'runs `npx expo start`',
119    async () => {
120      const projectRoot = await setupTestProjectAsync('basic-start', 'with-blank');
121      await fs.remove(path.join(projectRoot, '.expo'));
122
123      const promise = execa('node', [bin, 'start'], { cwd: projectRoot });
124
125      console.log('Starting server');
126
127      await new Promise<void>((resolve, reject) => {
128        promise.on('close', (code: number) => {
129          reject(
130            code === 0
131              ? 'Server closed too early. Run `kill -9 $(lsof -ti:19000)` to kill the orphaned process.'
132              : code
133          );
134        });
135
136        promise.stdout?.on('data', (data) => {
137          const stdout = data.toString();
138          console.log('output:', stdout);
139          if (stdout.includes('Logs for your project')) {
140            resolve();
141          }
142        });
143      });
144
145      console.log('Fetching manifest');
146      const response = await fetch('http://localhost:19000/', {
147        headers: {
148          'expo-platform': 'ios',
149          Accept: 'multipart/mixed',
150        },
151      });
152
153      const multipartParts = await parseMultipartMixedResponseAsync(
154        response.headers.get('content-type') as string,
155        await response.buffer()
156      );
157      const manifestPart = nullthrows(
158        multipartParts.find((part) => isMultipartPartWithName(part, 'manifest'))
159      );
160
161      const manifest = JSON.parse(manifestPart.body);
162
163      // Required for Expo Go
164      expect(manifest.extra.expoGo.packagerOpts).toStrictEqual({
165        dev: true,
166      });
167      expect(manifest.extra.expoGo.developer).toStrictEqual({
168        projectRoot: expect.anything(),
169        tool: 'expo-cli',
170      });
171
172      // URLs
173      expect(manifest.launchAsset.url).toBe(
174        'http://127.0.0.1:19000/node_modules/expo/AppEntry.bundle?platform=ios&dev=true&hot=false'
175      );
176      expect(manifest.extra.expoGo.debuggerHost).toBe('127.0.0.1:19000');
177      expect(manifest.extra.expoGo.logUrl).toBe('http://127.0.0.1:19000/logs');
178      expect(manifest.extra.expoGo.mainModuleName).toBe('node_modules/expo/AppEntry');
179      expect(manifest.extra.expoClient.hostUri).toBe('127.0.0.1:19000');
180
181      // Manifest
182      expect(manifest.runtimeVersion).toBe('exposdk:47.0.0');
183      expect(manifest.extra.expoClient.sdkVersion).toBe('47.0.0');
184      expect(manifest.extra.expoClient.slug).toBe('basic-start');
185      expect(manifest.extra.expoClient.name).toBe('basic-start');
186
187      // Custom
188      expect(manifest.extra.expoGo.__flipperHack).toBe('React Native packager is running');
189
190      console.log('Fetching bundle');
191      const bundle = await fetch(manifest.launchAsset.url).then((res) => res.text());
192      console.log('Fetched bundle: ', bundle.length);
193      expect(bundle.length).toBeGreaterThan(1000);
194      console.log('Finished');
195
196      // Kill process.
197      promise.kill('SIGTERM');
198
199      await promise;
200    },
201    // Could take 45s depending on how fast npm installs
202    120 * 1000
203  );
204});
205