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                   Open on a connected Android device
64        -i, --ios                       Open in an iOS simulator
65        -w, --web                       Open in a web browser
66
67        -d, --dev-client                Launch in a custom native app
68        -g, --go                        Launch in Expo Go
69
70        -c, --clear                     Clear the bundler cache
71        --max-workers <number>          Maximum number of tasks to allow Metro to spawn
72        --no-dev                        Bundle in production mode
73        --minify                        Minify JavaScript
74
75        -m, --host <string>             Dev server hosting type. Default: lan
76                                        lan: Use the local network
77                                        tunnel: Use any network by tunnel through ngrok
78                                        localhost: Connect to the dev server over localhost
79        --tunnel                        Same as --host tunnel
80        --lan                           Same as --host lan
81        --localhost                     Same as --host localhost
82
83        --offline                       Skip network requests and use anonymous manifest signatures
84        --https                         Start the dev server with https protocol
85        --scheme <scheme>               Custom URI protocol to use when launching an app
86        -p, --port <number>             Port to start the dev server on (does not apply to web or tunnel). Default: 8081
87
88        --force-manifest-type <string>  Override auto detection of manifest type. Options: expo-updates, classic
89        --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.
90        -h, --help                      Usage info
91    "
92  `);
93});
94
95for (const args of [
96  ['--lan', '--tunnel'],
97  ['--offline', '--localhost'],
98  ['--host', 'localhost', '--tunnel'],
99  ['-m', 'localhost', '--lan', '--offline'],
100]) {
101  it(`asserts invalid URL arguments on \`expo start ${args.join(' ')}\``, async () => {
102    await expect(execa('node', [bin, 'start', ...args], { cwd: projectRoot })).rejects.toThrowError(
103      /Specify at most one of/
104    );
105  });
106}
107
108describe('server', () => {
109  // Kill port
110  const kill = () => ensurePortFreeAsync(8081);
111
112  beforeEach(async () => {
113    await kill();
114  });
115
116  afterAll(async () => {
117    await kill();
118  });
119  it(
120    'runs `npx expo start`',
121    async () => {
122      const projectRoot = await setupTestProjectAsync('basic-start', 'with-blank');
123      await fs.remove(path.join(projectRoot, '.expo'));
124
125      const promise = execa('node', [bin, 'start'], { cwd: projectRoot });
126
127      console.log('Starting server');
128
129      await new Promise<void>((resolve, reject) => {
130        promise.on('close', (code: number) => {
131          reject(
132            code === 0
133              ? 'Server closed too early. Run `kill -9 $(lsof -ti:8081)` to kill the orphaned process.'
134              : code
135          );
136        });
137
138        promise.stdout?.on('data', (data) => {
139          const stdout = data.toString();
140          console.log('output:', stdout);
141          if (stdout.includes('Logs for your project')) {
142            resolve();
143          }
144        });
145      });
146
147      console.log('Fetching manifest');
148      const response = await fetch('http://localhost:8081/', {
149        headers: {
150          'expo-platform': 'ios',
151          Accept: 'multipart/mixed',
152        },
153      });
154
155      const multipartParts = await parseMultipartMixedResponseAsync(
156        response.headers.get('content-type') as string,
157        await response.buffer()
158      );
159      const manifestPart = nullthrows(
160        multipartParts.find((part) => isMultipartPartWithName(part, 'manifest'))
161      );
162
163      const manifest = JSON.parse(manifestPart.body);
164
165      // Required for Expo Go
166      expect(manifest.extra.expoGo.packagerOpts).toStrictEqual({
167        dev: true,
168      });
169      expect(manifest.extra.expoGo.developer).toStrictEqual({
170        projectRoot: expect.anything(),
171        tool: 'expo-cli',
172      });
173
174      // URLs
175      expect(manifest.launchAsset.url).toBe(
176        'http://127.0.0.1:8081/node_modules/expo/AppEntry.bundle?platform=ios&dev=true&hot=false'
177      );
178      expect(manifest.extra.expoGo.debuggerHost).toBe('127.0.0.1:8081');
179      expect(manifest.extra.expoGo.mainModuleName).toBe('node_modules/expo/AppEntry');
180      expect(manifest.extra.expoClient.hostUri).toBe('127.0.0.1:8081');
181
182      // Manifest
183      expect(manifest.runtimeVersion).toBe('exposdk:47.0.0');
184      expect(manifest.extra.expoClient.sdkVersion).toBe('47.0.0');
185      expect(manifest.extra.expoClient.slug).toBe('basic-start');
186      expect(manifest.extra.expoClient.name).toBe('basic-start');
187
188      // Custom
189      expect(manifest.extra.expoGo.__flipperHack).toBe('React Native packager is running');
190
191      console.log('Fetching bundle');
192      const bundle = await fetch(manifest.launchAsset.url).then((res) => res.text());
193      console.log('Fetched bundle: ', bundle.length);
194      expect(bundle.length).toBeGreaterThan(1000);
195      console.log('Finished');
196
197      // Kill process.
198      promise.kill('SIGTERM');
199
200      await promise;
201    },
202    // Could take 45s depending on how fast npm installs
203    120 * 1000
204  );
205});
206