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