1import openBrowserAsync from 'better-opn';
2import { vol } from 'memfs';
3
4import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
5import { UrlCreator } from '../UrlCreator';
6
7jest.mock('../AsyncNgrok');
8jest.mock('../DevelopmentSession');
9jest.mock('../../platforms/ios/ApplePlatformManager', () => {
10  class ApplePlatformManager {
11    openAsync = jest.fn(async () => ({ url: 'mock-apple-url' }));
12  }
13  return {
14    ApplePlatformManager,
15  };
16});
17jest.mock('../../platforms/android/AndroidPlatformManager', () => {
18  class AndroidPlatformManager {
19    openAsync = jest.fn(async () => ({ url: 'mock-android-url' }));
20  }
21  return {
22    AndroidPlatformManager,
23  };
24});
25
26const originalCwd = process.cwd();
27
28beforeAll(() => {
29  process.chdir('/');
30});
31
32beforeEach(() => {
33  vol.reset();
34  delete process.env.EXPO_ENABLE_INTERSTITIAL_PAGE;
35});
36
37afterAll(() => {
38  process.chdir(originalCwd);
39  delete process.env.EXPO_ENABLE_INTERSTITIAL_PAGE;
40});
41
42class MockBundlerDevServer extends BundlerDevServer {
43  get name(): string {
44    return 'fake';
45  }
46
47  public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
48    const port = options.port || 3000;
49    this.urlCreator = new UrlCreator(
50      {
51        scheme: options.https ? 'https' : 'http',
52        ...options.location,
53      },
54      {
55        port,
56        getTunnelUrl: this.getTunnelUrl.bind(this),
57      }
58    );
59
60    const protocol = 'http';
61    const host = 'localhost';
62    this.setInstance({
63      // Server instance
64      server: { close: jest.fn((fn) => fn()) },
65      // URL Info
66      location: {
67        url: `${protocol}://${host}:${port}`,
68        port,
69        protocol,
70        host,
71      },
72      middleware: {},
73      // Match the native protocol.
74      messageSocket: {
75        broadcast: jest.fn(),
76      },
77    });
78    await this.postStartAsync(options);
79
80    return this.getInstance();
81  }
82
83  getPublicUrlCreator() {
84    return this.urlCreator;
85  }
86  getNgrok() {
87    return this.ngrok;
88  }
89  getDevSession() {
90    return this.devSession;
91  }
92
93  protected getConfigModuleIds(): string[] {
94    return ['./fake.config.js'];
95  }
96
97  public getExpoGoUrl(platform: 'simulator' | 'emulator') {
98    return super.getExpoGoUrl(platform);
99  }
100}
101
102async function getRunningServer() {
103  const devServer = new MockBundlerDevServer('/');
104  await devServer.startAsync({ location: {} });
105  return devServer;
106}
107
108describe('broadcastMessage', () => {
109  it(`sends a message`, async () => {
110    const devServer = await getRunningServer();
111    devServer.broadcastMessage('reload', { foo: true });
112    expect(devServer.getInstance().messageSocket.broadcast).toBeCalledWith('reload', { foo: true });
113  });
114});
115
116describe('openPlatformAsync', () => {
117  it(`opens a project in the browser`, async () => {
118    const devServer = await getRunningServer();
119    const { url } = await devServer.openPlatformAsync('desktop');
120    expect(url).toBe('http://localhost:3000');
121    expect(openBrowserAsync).toBeCalledWith('http://localhost:3000');
122  });
123
124  for (const platform of ['ios', 'android']) {
125    for (const isDevClient of [false, true]) {
126      const runtime = platform === 'ios' ? 'simulator' : 'emulator';
127      it(`opens an ${platform} project in a ${runtime} (dev client: ${isDevClient})`, async () => {
128        const devServer = await getRunningServer();
129        devServer.isDevClient = isDevClient;
130        const { url } = await devServer.openPlatformAsync(runtime);
131
132        expect(
133          (await devServer['getPlatformManagerAsync'](runtime)).openAsync
134        ).toHaveBeenNthCalledWith(1, { runtime: isDevClient ? 'custom' : 'expo' }, {});
135
136        expect(url).toBe(platform === 'ios' ? 'mock-apple-url' : 'mock-android-url');
137      });
138    }
139  }
140});
141
142describe('stopAsync', () => {
143  it(`stops a running dev server`, async () => {
144    const server = new MockBundlerDevServer('/');
145    const instance = await server.startAsync({
146      location: {
147        hostType: 'tunnel',
148      },
149    });
150    const ngrok = server.getNgrok();
151    const devSession = server.getNgrok();
152
153    // Ensure services were started.
154    expect(ngrok.startAsync).toHaveBeenCalled();
155    expect(devSession.startAsync).toHaveBeenCalled();
156
157    // Invoke the stop function
158    await server.stopAsync();
159
160    // Ensure services were stopped.
161    expect(instance.server.close).toHaveBeenCalled();
162    expect(ngrok.stopAsync).toHaveBeenCalled();
163    expect(devSession.stopAsync).toHaveBeenCalled();
164    expect(server.getInstance()).toBeNull();
165  });
166});
167
168describe('getExpoGoUrl', () => {
169  it(`gets the interstitial page URL`, async () => {
170    process.env.EXPO_ENABLE_INTERSTITIAL_PAGE = '1';
171    vol.fromJSON(
172      {
173        'node_modules/expo-dev-launcher/package.json': '',
174      },
175      '/'
176    );
177
178    const server = new MockBundlerDevServer('/');
179    await server.startAsync({
180      location: {},
181    });
182
183    const urlCreator = server.getPublicUrlCreator();
184    urlCreator.constructLoadingUrl = jest.fn(urlCreator.constructLoadingUrl);
185
186    expect(server.getExpoGoUrl('emulator')).toBe(
187      'http://100.100.1.100:3000/_expo/loading?platform=android'
188    );
189    expect(server.getExpoGoUrl('simulator')).toBe(
190      'http://127.0.0.1:3000/_expo/loading?platform=ios'
191    );
192    expect(urlCreator.constructLoadingUrl).toBeCalledTimes(2);
193  });
194  it(`gets the native Expo Go URL`, async () => {
195    const server = new MockBundlerDevServer('/');
196    await server.startAsync({
197      location: {},
198    });
199
200    expect(server.getExpoGoUrl('emulator')).toBe('exp://100.100.1.100:3000');
201    expect(server.getExpoGoUrl('simulator')).toBe('exp://100.100.1.100:3000');
202  });
203});
204
205describe('getNativeRuntimeUrl', () => {
206  it(`gets the native runtime URL`, async () => {
207    const server = new MockBundlerDevServer('/');
208    await server.startAsync({
209      location: {},
210    });
211    expect(server.getNativeRuntimeUrl()).toBe('exp://100.100.1.100:3000');
212    expect(server.getNativeRuntimeUrl({ hostname: 'localhost' })).toBe('exp://127.0.0.1:3000');
213    expect(server.getNativeRuntimeUrl({ scheme: 'foobar' })).toBe('exp://100.100.1.100:3000');
214  });
215  it(`gets the native runtime URL for dev client`, async () => {
216    const server = new MockBundlerDevServer('/', true);
217    await server.startAsync({
218      location: {
219        scheme: 'my-app',
220      },
221    });
222    expect(server.getNativeRuntimeUrl()).toBe(
223      'my-app://expo-development-client/?url=http%3A%2F%2F100.100.1.100%3A3000'
224    );
225    expect(server.getNativeRuntimeUrl({ hostname: 'localhost' })).toBe(
226      'my-app://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A3000'
227    );
228    expect(server.getNativeRuntimeUrl({ scheme: 'foobar' })).toBe(
229      'foobar://expo-development-client/?url=http%3A%2F%2F100.100.1.100%3A3000'
230    );
231  });
232});
233
234describe('getManifestMiddlewareAsync', () => {
235  const server = new MockBundlerDevServer('/');
236  it(`asserts invalid manifest type`, async () => {
237    await expect(
238      server['getManifestMiddlewareAsync']({
239        // @ts-expect-error
240        forceManifestType: 'foobar',
241      })
242    ).rejects.toThrow(/Manifest middleware for type 'foobar' not found/);
243  });
244  it(`asserts server is not running`, async () => {
245    await expect(server['getManifestMiddlewareAsync']()).rejects.toThrow(
246      /Dev server is not running/
247    );
248  });
249});
250
251describe('_startTunnelAsync', () => {
252  const server = new MockBundlerDevServer('/');
253  it(`returns null when the server isn't running`, async () => {
254    expect(await server._startTunnelAsync()).toEqual(null);
255  });
256});
257