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