1import { vol } from 'memfs';
2
3import { openBrowserAsync } from '../../../utils/open';
4import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
5import { UrlCreator } from '../UrlCreator';
6import { getPlatformBundlers } from '../platformBundlers';
7
8jest.mock('../../../utils/open');
9jest.mock(`../../../log`);
10jest.mock('../AsyncNgrok');
11jest.mock('../DevelopmentSession');
12jest.mock('../../platforms/ios/ApplePlatformManager', () => {
13  class ApplePlatformManager {
14    openAsync = jest.fn(async () => ({ url: 'mock-apple-url' }));
15  }
16  return {
17    ApplePlatformManager,
18  };
19});
20jest.mock('../../platforms/android/AndroidPlatformManager', () => {
21  class AndroidPlatformManager {
22    openAsync = jest.fn(async () => ({ url: 'mock-android-url' }));
23  }
24  return {
25    AndroidPlatformManager,
26  };
27});
28
29const originalCwd = process.cwd();
30
31beforeAll(() => {
32  process.chdir('/');
33});
34
35const originalEnv = process.env;
36
37beforeEach(() => {
38  vol.reset();
39  delete process.env.EXPO_NO_REDIRECT_PAGE;
40});
41
42afterAll(() => {
43  process.chdir(originalCwd);
44  process.env = originalEnv;
45});
46
47class MockBundlerDevServer extends BundlerDevServer {
48  get name(): string {
49    return 'fake';
50  }
51
52  protected async startImplementationAsync(
53    options: BundlerStartOptions
54  ): Promise<DevServerInstance> {
55    const port = options.port || 3000;
56    this.urlCreator = new UrlCreator(
57      {
58        scheme: options.https ? 'https' : 'http',
59        ...options.location,
60      },
61      {
62        port,
63        getTunnelUrl: this.getTunnelUrl.bind(this),
64      }
65    );
66
67    const protocol = 'http';
68    const host = 'localhost';
69    this.setInstance({
70      // Server instance
71      server: { close: jest.fn((fn) => fn?.()), addListener() {} },
72      // URL Info
73      location: {
74        url: `${protocol}://${host}:${port}`,
75        port,
76        protocol,
77        host,
78      },
79      middleware: {},
80      // Match the native protocol.
81      messageSocket: {
82        broadcast: jest.fn(),
83      },
84    });
85    await this.postStartAsync(options);
86
87    return this.getInstance()!;
88  }
89
90  getPublicUrlCreator() {
91    return this.urlCreator;
92  }
93  getNgrok() {
94    return this.ngrok;
95  }
96  getDevSession() {
97    return this.devSession;
98  }
99
100  protected getConfigModuleIds(): string[] {
101    return ['./fake.config.js'];
102  }
103}
104
105class MockMetroBundlerDevServer extends MockBundlerDevServer {
106  get name(): string {
107    return 'metro';
108  }
109}
110
111async function getRunningServer() {
112  const devServer = new MockBundlerDevServer('/', getPlatformBundlers({}));
113  await devServer.startAsync({ location: {} });
114  return devServer;
115}
116
117describe('broadcastMessage', () => {
118  it(`sends a message`, async () => {
119    const devServer = await getRunningServer();
120    devServer.broadcastMessage('reload', { foo: true });
121    expect(devServer.getInstance()!.messageSocket.broadcast).toBeCalledWith('reload', {
122      foo: true,
123    });
124  });
125});
126
127describe('openPlatformAsync', () => {
128  it(`opens a project in the browser using tunnel with metro web`, async () => {
129    const devServer = new MockMetroBundlerDevServer('/', getPlatformBundlers({}));
130    await devServer.startAsync({
131      location: {
132        hostType: 'tunnel',
133      },
134    });
135    const { url } = await devServer.openPlatformAsync('desktop');
136    expect(url).toBe('http://exp.tunnel.dev/foobar');
137    expect(openBrowserAsync).toBeCalledWith('http://exp.tunnel.dev/foobar');
138  });
139  it(`opens a project in the browser`, async () => {
140    const devServer = await getRunningServer();
141    const { url } = await devServer.openPlatformAsync('desktop');
142    expect(url).toBe('http://localhost:3000');
143    expect(openBrowserAsync).toBeCalledWith('http://localhost:3000');
144  });
145
146  for (const platform of ['ios', 'android']) {
147    for (const isDevClient of [false, true]) {
148      const runtime = platform === 'ios' ? 'simulator' : 'emulator';
149      it(`opens an ${platform} project in a ${runtime} (dev client: ${isDevClient})`, async () => {
150        const devServer = await getRunningServer();
151        devServer.isDevClient = isDevClient;
152        const { url } = await devServer.openPlatformAsync(runtime);
153
154        expect(
155          (await devServer['getPlatformManagerAsync'](runtime)).openAsync
156        ).toHaveBeenNthCalledWith(1, { runtime: isDevClient ? 'custom' : 'expo' }, {});
157
158        expect(url).toBe(platform === 'ios' ? 'mock-apple-url' : 'mock-android-url');
159      });
160    }
161  }
162});
163
164describe('stopAsync', () => {
165  it(`stops a running dev server`, async () => {
166    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
167    const instance = await server.startAsync({
168      location: {
169        hostType: 'tunnel',
170      },
171    });
172    const ngrok = server.getNgrok();
173    const devSession = server.getNgrok();
174
175    // Ensure services were started.
176    expect(ngrok?.startAsync).toHaveBeenCalled();
177    expect(devSession?.startAsync).toHaveBeenCalled();
178
179    // Invoke the stop function
180    await server.stopAsync();
181
182    // Ensure services were stopped.
183    expect(instance.server.close).toHaveBeenCalled();
184    expect(ngrok?.stopAsync).toHaveBeenCalled();
185    expect(devSession?.stopAsync).toHaveBeenCalled();
186    expect(server.getInstance()).toBeNull();
187  });
188});
189
190describe('isRedirectPageEnabled', () => {
191  beforeEach(() => {
192    vol.reset();
193    delete process.env.EXPO_NO_REDIRECT_PAGE;
194  });
195
196  function mockDevClientInstalled() {
197    vol.fromJSON(
198      {
199        'node_modules/expo-dev-client/package.json': '',
200      },
201      '/'
202    );
203  }
204
205  it(`is redirect enabled`, async () => {
206    mockDevClientInstalled();
207
208    const server = new MockBundlerDevServer(
209      '/',
210      getPlatformBundlers({}),
211      // is Dev Client
212      false
213    );
214    expect(server['isRedirectPageEnabled']()).toBe(true);
215  });
216
217  it(`redirect can be disabled with env var`, async () => {
218    mockDevClientInstalled();
219
220    process.env.EXPO_NO_REDIRECT_PAGE = '1';
221
222    const server = new MockBundlerDevServer(
223      '/',
224      getPlatformBundlers({}),
225      // is Dev Client
226      false
227    );
228    expect(server['isRedirectPageEnabled']()).toBe(false);
229  });
230
231  it(`redirect is disabled when running in dev client mode`, async () => {
232    mockDevClientInstalled();
233
234    const server = new MockBundlerDevServer(
235      '/',
236      getPlatformBundlers({}),
237      // is Dev Client
238      true
239    );
240    expect(server['isRedirectPageEnabled']()).toBe(false);
241  });
242
243  it(`redirect is disabled when expo-dev-client is not installed in the project`, async () => {
244    const server = new MockBundlerDevServer(
245      '/',
246      getPlatformBundlers({}),
247      // is Dev Client
248      false
249    );
250    expect(server['isRedirectPageEnabled']()).toBe(false);
251  });
252});
253
254describe('getRedirectUrl', () => {
255  it(`returns null when the redirect page functionality is disabled`, async () => {
256    const server = new MockBundlerDevServer(
257      '/',
258      getPlatformBundlers({}),
259      // is Dev Client
260      false
261    );
262    server['isRedirectPageEnabled'] = () => false;
263    expect(server['getRedirectUrl']()).toBe(null);
264  });
265
266  it(`gets the redirect page URL`, async () => {
267    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
268    server['isRedirectPageEnabled'] = () => true;
269    await server.startAsync({
270      location: {},
271    });
272
273    const urlCreator = server.getPublicUrlCreator()!;
274    urlCreator.constructLoadingUrl = jest.fn(urlCreator.constructLoadingUrl);
275
276    expect(server.getRedirectUrl('emulator')).toBe(
277      'http://100.100.1.100:3000/_expo/loading?platform=android'
278    );
279    expect(server.getRedirectUrl('simulator')).toBe(
280      'http://100.100.1.100:3000/_expo/loading?platform=ios'
281    );
282    expect(server.getRedirectUrl(null)).toBe('http://100.100.1.100:3000/_expo/loading');
283    expect(urlCreator.constructLoadingUrl).toBeCalledTimes(3);
284  });
285});
286
287describe('getExpoGoUrl', () => {
288  it(`asserts if the dev server has not been started yet`, () => {
289    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
290    expect(() => server['getExpoGoUrl']()).toThrow('Dev server instance not found');
291  });
292
293  it(`gets the native Expo Go URL`, async () => {
294    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
295    await server.startAsync({
296      location: {},
297    });
298
299    expect(await server['getExpoGoUrl']()).toBe('exp://100.100.1.100:3000');
300  });
301});
302
303describe('getNativeRuntimeUrl', () => {
304  it(`gets the native runtime URL`, async () => {
305    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
306    await server.startAsync({
307      location: {},
308    });
309    expect(server.getNativeRuntimeUrl()).toBe('exp://100.100.1.100:3000');
310    expect(server.getNativeRuntimeUrl({ hostname: 'localhost' })).toBe('exp://127.0.0.1:3000');
311    expect(server.getNativeRuntimeUrl({ scheme: 'foobar' })).toBe('exp://100.100.1.100:3000');
312  });
313  it(`gets the native runtime URL for dev client`, async () => {
314    const server = new MockBundlerDevServer('/', getPlatformBundlers({}), true);
315    await server.startAsync({
316      location: {
317        scheme: 'my-app',
318      },
319    });
320    expect(server.getNativeRuntimeUrl()).toBe(
321      'my-app://expo-development-client/?url=http%3A%2F%2F100.100.1.100%3A3000'
322    );
323    expect(server.getNativeRuntimeUrl({ hostname: 'localhost' })).toBe(
324      'my-app://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A3000'
325    );
326    expect(server.getNativeRuntimeUrl({ scheme: 'foobar' })).toBe(
327      'foobar://expo-development-client/?url=http%3A%2F%2F100.100.1.100%3A3000'
328    );
329  });
330});
331
332describe('getManifestMiddlewareAsync', () => {
333  const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
334  it(`asserts server is not running`, async () => {
335    await expect(server['getManifestMiddlewareAsync']()).rejects.toThrow(
336      /Dev server instance not found/
337    );
338  });
339});
340
341describe('_startTunnelAsync', () => {
342  const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
343  it(`returns null when the server isn't running`, async () => {
344    expect(await server._startTunnelAsync()).toEqual(null);
345  });
346});
347
348describe('getJsInspectorBaseUrl', () => {
349  it('should return http based url', async () => {
350    const devServer = new MockMetroBundlerDevServer('/', getPlatformBundlers({}));
351    await devServer.startAsync({ location: {} });
352    expect(devServer.getJsInspectorBaseUrl()).toBe('http://100.100.1.100:3000');
353  });
354
355  it('should return tunnel url', async () => {
356    const devServer = new MockMetroBundlerDevServer('/', getPlatformBundlers({}));
357    await devServer.startAsync({ location: { hostType: 'tunnel' } });
358    expect(devServer.getJsInspectorBaseUrl()).toBe('http://exp.tunnel.dev');
359  });
360
361  it('should throw error for unsupported bundler', async () => {
362    const devServer = new MockBundlerDevServer('/', getPlatformBundlers({}));
363    await devServer.startAsync({ location: {} });
364    expect(() => devServer.getJsInspectorBaseUrl()).toThrow();
365  });
366});
367