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        --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.
89        -h, --help                      Usage info
90    "
91  `);
92});
93
94for (const args of [
95  ['--lan', '--tunnel'],
96  ['--offline', '--localhost'],
97  ['--host', 'localhost', '--tunnel'],
98  ['-m', 'localhost', '--lan', '--offline'],
99]) {
100  it(`asserts invalid URL arguments on \`expo start ${args.join(' ')}\``, async () => {
101    await expect(execa('node', [bin, 'start', ...args], { cwd: projectRoot })).rejects.toThrowError(
102      /Specify at most one of/
103    );
104  });
105}
106
107describe('server', () => {
108  // Kill port
109  const kill = () => ensurePortFreeAsync(8081);
110
111  beforeEach(async () => {
112    await kill();
113  });
114
115  afterAll(async () => {
116    await kill();
117  });
118  it(
119    'runs `npx expo start`',
120    async () => {
121      const projectRoot = await setupTestProjectAsync('basic-start', 'with-blank');
122      await fs.remove(path.join(projectRoot, '.expo'));
123
124      const promise = execa('node', [bin, 'start'], { cwd: projectRoot });
125
126      console.log('Starting server');
127
128      await new Promise<void>((resolve, reject) => {
129        promise.on('close', (code: number) => {
130          reject(
131            code === 0
132              ? 'Server closed too early. Run `kill -9 $(lsof -ti:8081)` to kill the orphaned process.'
133              : code
134          );
135        });
136
137        promise.stdout?.on('data', (data) => {
138          const stdout = data.toString();
139          console.log('output:', stdout);
140          if (stdout.includes('Logs for your project')) {
141            resolve();
142          }
143        });
144      });
145
146      console.log('Fetching manifest');
147      const response = await fetch('http://localhost:8081/', {
148        headers: {
149          'expo-platform': 'ios',
150          Accept: 'multipart/mixed',
151        },
152      });
153
154      const multipartParts = await parseMultipartMixedResponseAsync(
155        response.headers.get('content-type') as string,
156        await response.buffer()
157      );
158      const manifestPart = nullthrows(
159        multipartParts.find((part) => isMultipartPartWithName(part, 'manifest'))
160      );
161
162      const manifest = JSON.parse(manifestPart.body);
163
164      // Required for Expo Go
165      expect(manifest.extra.expoGo.packagerOpts).toStrictEqual({
166        dev: true,
167      });
168      expect(manifest.extra.expoGo.developer).toStrictEqual({
169        projectRoot: expect.anything(),
170        tool: 'expo-cli',
171      });
172
173      // URLs
174      expect(manifest.launchAsset.url).toBe(
175        'http://127.0.0.1:8081/node_modules/expo/AppEntry.bundle?platform=ios&dev=true&hot=false'
176      );
177      expect(manifest.extra.expoGo.debuggerHost).toBe('127.0.0.1:8081');
178      expect(manifest.extra.expoGo.mainModuleName).toBe('node_modules/expo/AppEntry');
179      expect(manifest.extra.expoClient.hostUri).toBe('127.0.0.1:8081');
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