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
104async function getRunningServer() {
105  const devServer = new MockBundlerDevServer('/', getPlatformBundlers({}));
106  await devServer.startAsync({ location: {} });
107  return devServer;
108}
109
110describe('broadcastMessage', () => {
111  it(`sends a message`, async () => {
112    const devServer = await getRunningServer();
113    devServer.broadcastMessage('reload', { foo: true });
114    expect(devServer.getInstance()!.messageSocket.broadcast).toBeCalledWith('reload', {
115      foo: true,
116    });
117  });
118});
119
120describe('openPlatformAsync', () => {
121  it(`opens a project in the browser`, async () => {
122    const devServer = await getRunningServer();
123    const { url } = await devServer.openPlatformAsync('desktop');
124    expect(url).toBe('http://localhost:3000');
125    expect(openBrowserAsync).toBeCalledWith('http://localhost:3000');
126  });
127
128  for (const platform of ['ios', 'android']) {
129    for (const isDevClient of [false, true]) {
130      const runtime = platform === 'ios' ? 'simulator' : 'emulator';
131      it(`opens an ${platform} project in a ${runtime} (dev client: ${isDevClient})`, async () => {
132        const devServer = await getRunningServer();
133        devServer.isDevClient = isDevClient;
134        const { url } = await devServer.openPlatformAsync(runtime);
135
136        expect(
137          (await devServer['getPlatformManagerAsync'](runtime)).openAsync
138        ).toHaveBeenNthCalledWith(1, { runtime: isDevClient ? 'custom' : 'expo' }, {});
139
140        expect(url).toBe(platform === 'ios' ? 'mock-apple-url' : 'mock-android-url');
141      });
142    }
143  }
144});
145
146describe('stopAsync', () => {
147  it(`stops a running dev server`, async () => {
148    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
149    const instance = await server.startAsync({
150      location: {
151        hostType: 'tunnel',
152      },
153    });
154    const ngrok = server.getNgrok();
155    const devSession = server.getNgrok();
156
157    // Ensure services were started.
158    expect(ngrok?.startAsync).toHaveBeenCalled();
159    expect(devSession?.startAsync).toHaveBeenCalled();
160
161    // Invoke the stop function
162    await server.stopAsync();
163
164    // Ensure services were stopped.
165    expect(instance.server.close).toHaveBeenCalled();
166    expect(ngrok?.stopAsync).toHaveBeenCalled();
167    expect(devSession?.stopAsync).toHaveBeenCalled();
168    expect(server.getInstance()).toBeNull();
169  });
170});
171
172describe('isRedirectPageEnabled', () => {
173  beforeEach(() => {
174    vol.reset();
175    delete process.env.EXPO_NO_REDIRECT_PAGE;
176  });
177
178  function mockDevClientInstalled() {
179    vol.fromJSON(
180      {
181        'node_modules/expo-dev-client/package.json': '',
182      },
183      '/'
184    );
185  }
186
187  it(`is redirect enabled`, async () => {
188    mockDevClientInstalled();
189
190    const server = new MockBundlerDevServer(
191      '/',
192      getPlatformBundlers({}),
193      // is Dev Client
194      false
195    );
196    expect(server['isRedirectPageEnabled']()).toBe(true);
197  });
198
199  it(`redirect can be disabled with env var`, async () => {
200    mockDevClientInstalled();
201
202    process.env.EXPO_NO_REDIRECT_PAGE = '1';
203
204    const server = new MockBundlerDevServer(
205      '/',
206      getPlatformBundlers({}),
207      // is Dev Client
208      false
209    );
210    expect(server['isRedirectPageEnabled']()).toBe(false);
211  });
212
213  it(`redirect is disabled when running in dev client mode`, async () => {
214    mockDevClientInstalled();
215
216    const server = new MockBundlerDevServer(
217      '/',
218      getPlatformBundlers({}),
219      // is Dev Client
220      true
221    );
222    expect(server['isRedirectPageEnabled']()).toBe(false);
223  });
224
225  it(`redirect is disabled when expo-dev-client is not installed in the project`, async () => {
226    const server = new MockBundlerDevServer(
227      '/',
228      getPlatformBundlers({}),
229      // is Dev Client
230      false
231    );
232    expect(server['isRedirectPageEnabled']()).toBe(false);
233  });
234});
235
236describe('getRedirectUrl', () => {
237  it(`returns null when the redirect page functionality is disabled`, async () => {
238    const server = new MockBundlerDevServer(
239      '/',
240      getPlatformBundlers({}),
241      // is Dev Client
242      false
243    );
244    server['isRedirectPageEnabled'] = () => false;
245    expect(server['getRedirectUrl']()).toBe(null);
246  });
247
248  it(`gets the redirect page URL`, async () => {
249    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
250    server['isRedirectPageEnabled'] = () => true;
251    await server.startAsync({
252      location: {},
253    });
254
255    const urlCreator = server.getPublicUrlCreator()!;
256    urlCreator.constructLoadingUrl = jest.fn(urlCreator.constructLoadingUrl);
257
258    expect(server.getRedirectUrl('emulator')).toBe(
259      'http://100.100.1.100:3000/_expo/loading?platform=android'
260    );
261    expect(server.getRedirectUrl('simulator')).toBe(
262      'http://100.100.1.100:3000/_expo/loading?platform=ios'
263    );
264    expect(server.getRedirectUrl(null)).toBe('http://100.100.1.100:3000/_expo/loading');
265    expect(urlCreator.constructLoadingUrl).toBeCalledTimes(3);
266  });
267});
268
269describe('getExpoGoUrl', () => {
270  it(`asserts if the dev server has not been started yet`, () => {
271    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
272    expect(() => server['getExpoGoUrl']()).toThrow('Dev server instance not found');
273  });
274
275  it(`gets the native Expo Go URL`, async () => {
276    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
277    await server.startAsync({
278      location: {},
279    });
280
281    expect(await server['getExpoGoUrl']()).toBe('exp://100.100.1.100:3000');
282  });
283});
284
285describe('getNativeRuntimeUrl', () => {
286  it(`gets the native runtime URL`, async () => {
287    const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
288    await server.startAsync({
289      location: {},
290    });
291    expect(server.getNativeRuntimeUrl()).toBe('exp://100.100.1.100:3000');
292    expect(server.getNativeRuntimeUrl({ hostname: 'localhost' })).toBe('exp://127.0.0.1:3000');
293    expect(server.getNativeRuntimeUrl({ scheme: 'foobar' })).toBe('exp://100.100.1.100:3000');
294  });
295  it(`gets the native runtime URL for dev client`, async () => {
296    const server = new MockBundlerDevServer('/', getPlatformBundlers({}), true);
297    await server.startAsync({
298      location: {
299        scheme: 'my-app',
300      },
301    });
302    expect(server.getNativeRuntimeUrl()).toBe(
303      'my-app://expo-development-client/?url=http%3A%2F%2F100.100.1.100%3A3000'
304    );
305    expect(server.getNativeRuntimeUrl({ hostname: 'localhost' })).toBe(
306      'my-app://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A3000'
307    );
308    expect(server.getNativeRuntimeUrl({ scheme: 'foobar' })).toBe(
309      'foobar://expo-development-client/?url=http%3A%2F%2F100.100.1.100%3A3000'
310    );
311  });
312});
313
314describe('getManifestMiddlewareAsync', () => {
315  const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
316  it(`asserts invalid manifest type`, async () => {
317    await expect(
318      server['getManifestMiddlewareAsync']({
319        // @ts-expect-error
320        forceManifestType: 'foobar',
321      })
322    ).rejects.toThrow(/Manifest middleware for type 'foobar' not found/);
323  });
324  it(`asserts server is not running`, async () => {
325    await expect(server['getManifestMiddlewareAsync']()).rejects.toThrow(
326      /Dev server instance not found/
327    );
328  });
329});
330
331describe('_startTunnelAsync', () => {
332  const server = new MockBundlerDevServer('/', getPlatformBundlers({}));
333  it(`returns null when the server isn't running`, async () => {
334    expect(await server._startTunnelAsync()).toEqual(null);
335  });
336});
337