1import { asMock } from '../../../__tests__/asMock';
2import { getVersionsAsync, SDKVersion, Versions } from '../../../api/getVersions';
3import { downloadExpoGoAsync } from '../../../utils/downloadExpoGoAsync';
4import { confirmAsync } from '../../../utils/prompts';
5import { ExpoGoInstaller } from '../ExpoGoInstaller';
6
7jest.mock('../../../log');
8jest.mock('../../../utils/prompts');
9jest.mock('../../../utils/downloadExpoGoAsync');
10jest.mock('../../../api/getVersions', () => ({
11  getVersionsAsync: jest.fn(),
12}));
13
14beforeEach(() => {
15  // Reset global memo...
16  ExpoGoInstaller.cache = {};
17});
18
19function createInstaller(platform: 'ios' | 'android') {
20  return new ExpoGoInstaller(platform, 'host.fake.expo', '44.0.0');
21}
22
23function mockVersionsOnce(versions: Partial<Versions>) {
24  asMock(getVersionsAsync).mockResolvedValueOnce(asVersions(versions));
25}
26
27function asVersions(versions: Partial<Versions>): Versions {
28  return versions as Versions;
29}
30
31describe('_getExpectedClientVersionAsync', () => {
32  it(`returns null when version cannot be found`, async () => {
33    mockVersionsOnce({});
34    await expect(createInstaller('android')._getExpectedClientVersionAsync()).resolves.toBe(null);
35  });
36  describe('android', () => {
37    it(`uses platform and most specific version`, async () => {
38      mockVersionsOnce({
39        androidVersion: '2.0.0',
40        sdkVersions: {
41          '44.0.0': {
42            androidClientVersion: '1.0.0',
43          } as SDKVersion,
44        },
45      });
46      await expect(createInstaller('android')._getExpectedClientVersionAsync()).resolves.toBe(
47        '1.0.0'
48      );
49    });
50    it(`uses platform and general version`, async () => {
51      mockVersionsOnce({
52        androidVersion: '2.0.0',
53        sdkVersions: {},
54      });
55      await expect(createInstaller('android')._getExpectedClientVersionAsync()).resolves.toBe(
56        '2.0.0'
57      );
58    });
59  });
60  describe('ios', () => {
61    it(`uses platform and most specific version`, async () => {
62      mockVersionsOnce({
63        iosVersion: '2.0.0',
64        sdkVersions: {
65          '44.0.0': {
66            iosClientVersion: '1.0.0',
67          } as SDKVersion,
68        },
69      });
70
71      await expect(createInstaller('ios')._getExpectedClientVersionAsync()).resolves.toBe('1.0.0');
72    });
73    it(`uses platform and general version`, async () => {
74      mockVersionsOnce({
75        iosVersion: '2.0.0',
76        sdkVersions: {},
77      });
78      await expect(createInstaller('ios')._getExpectedClientVersionAsync()).resolves.toBe('2.0.0');
79    });
80  });
81});
82
83describe('isClientOutdatedAsync', () => {
84  it(`returns true if the app is not installed`, async () => {
85    // platform doesn't matter here...
86    const installer = createInstaller('android');
87    installer._getExpectedClientVersionAsync = jest.fn();
88    const deviceManager = {
89      getAppVersionAsync: jest.fn(async () => null),
90    } as any;
91    await expect(installer.isClientOutdatedAsync(deviceManager)).resolves.toBe(true);
92    expect(installer._getExpectedClientVersionAsync).toBeCalledTimes(0);
93  });
94  it(`returns true if the installed Expo Go app is outdated`, async () => {
95    // platform doesn't matter here...
96    const installer = createInstaller('android');
97    installer._getExpectedClientVersionAsync = jest.fn(async () => '2.0.0');
98    const deviceManager = {
99      getAppVersionAsync: jest.fn(async () => '1.0.0'),
100    } as any;
101    await expect(installer.isClientOutdatedAsync(deviceManager)).resolves.toBe(true);
102    expect(installer._getExpectedClientVersionAsync).toBeCalledTimes(1);
103  });
104  it(`returns false if the installed Expo Go app version is up to date`, async () => {
105    // platform doesn't matter here...
106    const installer = createInstaller('android');
107    installer._getExpectedClientVersionAsync = jest.fn(async () => '2.0.0');
108    const deviceManager = {
109      getAppVersionAsync: jest.fn(async () => '2.0.0'),
110    } as any;
111    await expect(installer.isClientOutdatedAsync(deviceManager)).resolves.toBe(false);
112    expect(installer._getExpectedClientVersionAsync).toBeCalledTimes(1);
113  });
114});
115
116describe('uninstallExpoGoIfOutdatedAsync', () => {
117  function createDeviceManager() {
118    return {
119      name: 'Pixel 3',
120      identifier: '123',
121      uninstallAppAsync: jest.fn(async () => null),
122    } as any;
123  }
124  beforeEach(() => {
125    asMock(confirmAsync).mockReset();
126  });
127  it(`returns true when the user uninstalls the outdated app`, async () => {
128    asMock(confirmAsync).mockResolvedValueOnce(true);
129    const installer = createInstaller('android');
130    installer.isClientOutdatedAsync = jest.fn(async () => true);
131    const deviceManager = createDeviceManager();
132
133    await expect(installer.uninstallExpoGoIfOutdatedAsync(deviceManager)).resolves.toBe(true);
134    expect(confirmAsync).toBeCalled();
135    expect(deviceManager.uninstallAppAsync).toBeCalled();
136  });
137
138  it(`prevents checking the same device twice`, async () => {
139    asMock(confirmAsync).mockResolvedValueOnce(true);
140    const installer = createInstaller('android');
141    installer.isClientOutdatedAsync = jest.fn(async () => true);
142    const deviceManager = createDeviceManager();
143    await expect(installer.uninstallExpoGoIfOutdatedAsync(deviceManager)).resolves.toBe(true);
144    expect(confirmAsync).toBeCalled();
145
146    // Returns false bacuse the device is already checked.
147    await expect(installer.uninstallExpoGoIfOutdatedAsync(deviceManager)).resolves.toBe(false);
148    expect(confirmAsync).toBeCalledTimes(1);
149  });
150
151  it(`returns false when the user uninstalls the outdated app`, async () => {
152    asMock(confirmAsync).mockResolvedValueOnce(false);
153    const installer = createInstaller('android');
154    installer.isClientOutdatedAsync = jest.fn(async () => true);
155    const deviceManager = createDeviceManager();
156    await expect(installer.uninstallExpoGoIfOutdatedAsync(deviceManager)).resolves.toBe(false);
157    expect(confirmAsync).toBeCalled();
158    expect(deviceManager.uninstallAppAsync).not.toBeCalled();
159  });
160
161  it(`does not actually uninstall the outdated app on iOS`, async () => {
162    asMock(confirmAsync).mockResolvedValueOnce(true);
163    const installer = createInstaller('ios');
164    installer.isClientOutdatedAsync = jest.fn(async () => true);
165    const deviceManager = createDeviceManager();
166    await expect(installer.uninstallExpoGoIfOutdatedAsync(deviceManager)).resolves.toBe(true);
167    expect(confirmAsync).toBeCalled();
168    // Not called because we simply install the new app which automatically overwrites the old one.
169    expect(deviceManager.uninstallAppAsync).not.toBeCalled();
170  });
171  it(`does not prompt if the app is not up to date`, async () => {
172    asMock(confirmAsync).mockImplementationOnce(() => {
173      throw new Error('Should not be called');
174    });
175    const installer = createInstaller('ios');
176    installer.isClientOutdatedAsync = jest.fn(async () => false);
177    const deviceManager = createDeviceManager();
178    await expect(installer.uninstallExpoGoIfOutdatedAsync(deviceManager)).resolves.toBe(false);
179    expect(confirmAsync).not.toBeCalled();
180    expect(deviceManager.uninstallAppAsync).not.toBeCalled();
181  });
182
183  it(`returns false when the project is unversioned`, async () => {
184    const installer = new ExpoGoInstaller('android', 'host.fake.expo', 'UNVERSIONED');
185    installer.isClientOutdatedAsync = jest.fn(async () => true);
186    const deviceManager = createDeviceManager();
187
188    await expect(installer.uninstallExpoGoIfOutdatedAsync(deviceManager)).resolves.toBe(false);
189    expect(confirmAsync).not.toBeCalled();
190    expect(deviceManager.uninstallAppAsync).not.toBeCalled();
191  });
192});
193
194describe('ensureAsync', () => {
195  beforeEach(() => {
196    delete process.env.EXPO_OFFLINE;
197  });
198  function createDeviceManager(isAppInstalled: boolean) {
199    return {
200      name: 'Pixel 3',
201      identifier: '123',
202      isAppInstalledAsync: jest.fn(async () => isAppInstalled),
203      installAppAsync: jest.fn(async () => {}),
204    } as any;
205  }
206
207  it(`returns true if the app was updated (after being updated)`, async () => {
208    // true because the app is installed.
209    const deviceManager = createDeviceManager(true);
210
211    const installer = createInstaller('android');
212    // Return true to indicate that the app was uninstalled.
213    installer.uninstallExpoGoIfOutdatedAsync = jest.fn(async () => true);
214
215    await expect(installer.ensureAsync(deviceManager)).resolves.toBe(true);
216
217    expect(downloadExpoGoAsync).toBeCalledTimes(1);
218    expect(deviceManager.installAppAsync).toBeCalled();
219  });
220
221  it(`returns true if the app was installed`, async () => {
222    // false because the app is not installed
223    const deviceManager = createDeviceManager(false);
224
225    const installer = createInstaller('android');
226
227    // App is not installed so we shouldn't check if it's outdated.
228    // This is important because skipping this means we skipped the cache and prompt.
229    installer.uninstallExpoGoIfOutdatedAsync = () => {
230      throw new Error('Should not be called');
231    };
232
233    await expect(installer.ensureAsync(deviceManager)).resolves.toBe(true);
234
235    expect(downloadExpoGoAsync).toBeCalledTimes(1);
236    expect(deviceManager.installAppAsync).toBeCalled();
237  });
238
239  it(`returns false if the app is up to date`, async () => {
240    // true because the app is up to date.
241    const deviceManager = createDeviceManager(true);
242
243    const installer = createInstaller('android');
244
245    // Return false to indicate that the app was not uninstalled
246    installer.uninstallExpoGoIfOutdatedAsync = jest.fn(async () => false);
247
248    await expect(installer.ensureAsync(deviceManager)).resolves.toBe(false);
249
250    // No expensive actions.
251    expect(downloadExpoGoAsync).not.toBeCalled();
252    expect(deviceManager.installAppAsync).not.toBeCalled();
253  });
254
255  it(`returns false when installed and running in offline mode`, async () => {
256    process.env.EXPO_OFFLINE = '1';
257
258    // Reload the Expo Go installer to use the mocked settings
259    const { ExpoGoInstaller } = require('../ExpoGoInstaller');
260
261    const deviceManager = createDeviceManager(true);
262    const installer = new ExpoGoInstaller('android', 'host.fake.expo', '44.0.0');
263
264    // Return false and allow the test to validate this is not called
265    installer.uninstallExpoGoIfOutdatedAsync = jest.fn(async () => false);
266
267    await expect(installer.ensureAsync(deviceManager)).resolves.toBe(false);
268
269    // Ensure it avoids making any API requests
270    expect(installer.uninstallExpoGoIfOutdatedAsync).not.toBeCalled();
271    expect(downloadExpoGoAsync).not.toBeCalled();
272  });
273
274  it(`throws when not installed and running in offline mode`, async () => {
275    process.env.EXPO_OFFLINE = '1';
276
277    // Reload the Expo Go installer to use the mocked settings
278    const { ExpoGoInstaller } = require('../ExpoGoInstaller');
279
280    const deviceManager = createDeviceManager(false);
281    const installer = new ExpoGoInstaller('android', 'host.fake.expo', '44.0.0');
282
283    // Return false and allow the test to validate this is not called
284    installer.uninstallExpoGoIfOutdatedAsync = jest.fn(async () => false);
285
286    await expect(installer.ensureAsync(deviceManager)).rejects.toThrowError(
287      'Expo Go is not installed'
288    );
289  });
290});
291