1import { by, device, element, waitFor } from 'detox';
2import jestExpect from 'expect';
3import path from 'path';
4import { setTimeout } from 'timers/promises';
5
6import Server from './utils/server';
7import Update from './utils/update';
8
9const projectRoot = process.env.PROJECT_ROOT || process.cwd();
10const platform = device.getPlatform();
11const protocolVersion = 1;
12const TIMEOUT_BIAS = process.env.CI ? 10 : 1;
13
14const checkNumAssetsAsync = async () => {
15  await element(by.id('readAssetFiles')).tap();
16  await setTimeout(20 * TIMEOUT_BIAS);
17  await waitFor(element(by.id('activity')))
18    .not.toBeVisible()
19    .withTimeout(2000);
20  const attributes: any = await element(by.id('numAssetFiles')).getAttributes();
21  return parseInt(attributes?.text || -1, 10);
22};
23
24const clearNumAssetsAsync = async () => {
25  await element(by.id('clearAssetFiles')).tap();
26  await setTimeout(20 * TIMEOUT_BIAS);
27  await waitFor(element(by.id('activity')))
28    .not.toBeVisible()
29    .withTimeout(2000);
30};
31
32const testElementValueAsync = async (testID: string) => {
33  const attributes: any = await element(by.id(testID)).getAttributes();
34  return attributes?.text || '';
35};
36
37const pressTestButtonAsync = async (testID: string) => await element(by.id(testID)).tap();
38
39const readLogEntriesAsync = async () => {
40  await element(by.id('readLogEntries')).tap();
41  await setTimeout(20 * TIMEOUT_BIAS);
42  await waitFor(element(by.id('activity')))
43    .not.toBeVisible()
44    .withTimeout(2000);
45  const attributes: any = await element(by.id('logEntries')).getAttributes();
46  try {
47    return JSON.parse(attributes?.text) || [];
48  } catch (e) {
49    console.warn(`Error in parsing logs: ${e}`);
50    return [];
51  }
52};
53
54const clearLogEntriesAsync = async () => {
55  await element(by.id('clearLogEntries')).tap();
56  await setTimeout(20 * TIMEOUT_BIAS);
57  await waitFor(element(by.id('activity')))
58    .not.toBeVisible()
59    .withTimeout(2000);
60};
61
62const waitForAppToBecomeVisible = async () => {
63  await waitFor(element(by.id('updateString')))
64    .toBeVisible()
65    .withTimeout(2000);
66};
67
68describe('Basic tests', () => {
69  afterEach(async () => {
70    await device.uninstallApp();
71    Server.stop();
72  });
73
74  it('starts app, stops, and starts again', async () => {
75    console.warn(`Platform = ${platform}`);
76    jest.setTimeout(300000 * TIMEOUT_BIAS);
77    Server.start(Update.serverPort, protocolVersion);
78    await device.installApp();
79    await device.launchApp({
80      newInstance: true,
81    });
82    await waitForAppToBecomeVisible();
83
84    const message = await testElementValueAsync('updateString');
85    jestExpect(message).toBe('test');
86    await device.terminateApp();
87    await device.launchApp();
88    await waitForAppToBecomeVisible();
89
90    const message2 = await testElementValueAsync('updateString');
91    jestExpect(message2).toBe('test');
92
93    await device.terminateApp();
94  });
95
96  it('initial request includes correct update-id headers', async () => {
97    jest.setTimeout(300000 * TIMEOUT_BIAS);
98    Server.start(Update.serverPort);
99    await device.installApp();
100    await device.launchApp({
101      newInstance: true,
102    });
103    const request = await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
104    jestExpect(request.headers['expo-embedded-update-id'] || null).toBeDefined();
105    jestExpect(request.headers['expo-current-update-id']).toBeDefined();
106    // before any updates, the current update ID and embedded update ID should be the same
107    jestExpect(request.headers['expo-current-update-id']).toEqual(
108      request.headers['expo-embedded-update-id']
109    );
110  });
111
112  it('downloads and runs update, and updates current-update-id header', async () => {
113    jest.setTimeout(300000 * TIMEOUT_BIAS);
114    const bundleFilename = 'bundle1.js';
115    const newNotifyString = 'test-update-1';
116    const hash = await Update.copyBundleToStaticFolder(
117      projectRoot,
118      bundleFilename,
119      newNotifyString,
120      platform
121    );
122    const manifest = Update.getUpdateManifestForBundleFilename(
123      new Date(),
124      hash,
125      'test-update-1-key',
126      bundleFilename,
127      [],
128      projectRoot
129    );
130
131    Server.start(Update.serverPort, protocolVersion);
132    await Server.serveSignedManifest(manifest, projectRoot);
133    await device.installApp();
134    await device.launchApp({
135      newInstance: true,
136    });
137    const firstRequest = await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
138    await waitForAppToBecomeVisible();
139    const message = await testElementValueAsync('updateString');
140    jestExpect(message).toBe('test');
141
142    // give the app time to load the new update in the background
143    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(1);
144
145    // restart the app so it will launch the new update
146    await device.terminateApp();
147    await device.launchApp();
148    const secondRequest = await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
149    await waitForAppToBecomeVisible();
150    const updatedMessage = await testElementValueAsync('updateString');
151    jestExpect(updatedMessage).toBe(newNotifyString);
152
153    jestExpect(secondRequest.headers['expo-embedded-update-id']).toBeDefined();
154    jestExpect(secondRequest.headers['expo-embedded-update-id']).toEqual(
155      firstRequest.headers['expo-embedded-update-id']
156    );
157    jestExpect(secondRequest.headers['expo-current-update-id']).toBeDefined();
158    jestExpect(secondRequest.headers['expo-current-update-id']).toEqual(manifest.id);
159  });
160
161  it('does not run update with incorrect hash', async () => {
162    jest.setTimeout(300000 * TIMEOUT_BIAS);
163    const bundleFilename = 'bundle-invalid-hash.js';
164    const newNotifyString = 'test-update-invalid-hash';
165    await Update.copyBundleToStaticFolder(projectRoot, bundleFilename, newNotifyString, platform);
166    const hash = 'invalid-hash';
167    const manifest = Update.getUpdateManifestForBundleFilename(
168      new Date(),
169      hash,
170      'test-update-1-key',
171      bundleFilename,
172      [],
173      projectRoot
174    );
175
176    Server.start(Update.serverPort, protocolVersion);
177    await Server.serveSignedManifest(manifest, projectRoot);
178    await device.installApp();
179    await device.launchApp({
180      newInstance: true,
181    });
182    await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
183    await waitForAppToBecomeVisible();
184    const message = await testElementValueAsync('updateString');
185    jestExpect(message).toBe('test');
186
187    // give the app time to load the new update in the background
188    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(1);
189
190    // restart the app to verify the new update isn't used
191    await device.terminateApp();
192    await device.launchApp();
193    await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
194    const updatedMessage = await testElementValueAsync('updateString');
195    jestExpect(updatedMessage).toBe('test');
196  });
197
198  it('update with bad asset hash yields expected log entry', async () => {
199    jest.setTimeout(300000 * TIMEOUT_BIAS);
200    const bundleFilename = 'bundle2.js';
201    const newNotifyString = 'test-update-2';
202    const hash = await Update.copyBundleToStaticFolder(
203      projectRoot,
204      bundleFilename,
205      newNotifyString,
206      platform
207    );
208    const assets = await Promise.all(
209      [
210        'lubo-minar-j2RgHfqKhCM-unsplash.jpg',
211        'niklas-liniger-zuPiCN7xekM-unsplash.jpg',
212        'patrick-untersee-XJjsuuDwWas-unsplash.jpg',
213      ].map(async (sourceFilename, index) => {
214        const destinationFilename = `asset${index}.jpg`;
215        const hash = await Update.copyAssetToStaticFolder(
216          path.join(__dirname, 'assets', sourceFilename),
217          destinationFilename
218        );
219        return {
220          hash:
221            index === 0 ? hash.substring(1, 2) + hash.substring(0, 1) + hash.substring(2) : hash,
222          key: `asset${index}`,
223          contentType: 'image/jpg',
224          fileExtension: '.jpg',
225          url: `http://${Update.serverHost}:${Update.serverPort}/static/${destinationFilename}`,
226        };
227      })
228    );
229    const manifest = Update.getUpdateManifestForBundleFilename(
230      new Date(),
231      hash,
232      'test-update-2-key',
233      bundleFilename,
234      assets,
235      projectRoot
236    );
237
238    Server.start(Update.serverPort, protocolVersion);
239    await Server.serveSignedManifest(manifest, projectRoot);
240    await device.installApp();
241    await device.launchApp({
242      newInstance: true,
243    });
244    // give the app time to load the new update in the background
245    await waitForAppToBecomeVisible();
246    const message = await testElementValueAsync('updateString');
247    jestExpect(message).toBe('test');
248
249    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(4);
250
251    // restart the app so it will launch the new update
252    await device.terminateApp();
253    await device.launchApp();
254    await waitForAppToBecomeVisible();
255    await setTimeout(2000 * TIMEOUT_BIAS);
256    const updatedMessage = await testElementValueAsync('updateString');
257    // Because of the mismatch, the new update will not load, so updatedMessage will still be 'test'
258    jestExpect(updatedMessage).toBe('test');
259
260    // Check readLogEntriesAsync
261    const logEntries: any[] = await readLogEntriesAsync();
262    console.warn(
263      'Total number of log entries = ' +
264        logEntries.length +
265        '\n' +
266        JSON.stringify(logEntries, null, 2)
267    );
268
269    // Should have at least one message
270    jestExpect(logEntries.length > 0).toBe(true);
271    // Check for message that hash is mismatched, with expected error code
272    jestExpect(logEntries.map((entry) => entry.code)).toEqual(
273      jestExpect.arrayContaining(['AssetsFailedToLoad'])
274    );
275  });
276
277  it('downloads and runs update with multiple assets', async () => {
278    jest.setTimeout(300000 * TIMEOUT_BIAS);
279    const bundleFilename = 'bundle2.js';
280    const newNotifyString = 'test-update-2';
281    const hash = await Update.copyBundleToStaticFolder(
282      projectRoot,
283      bundleFilename,
284      newNotifyString,
285      platform
286    );
287    const assets = await Promise.all(
288      [
289        'lubo-minar-j2RgHfqKhCM-unsplash.jpg',
290        'niklas-liniger-zuPiCN7xekM-unsplash.jpg',
291        'patrick-untersee-XJjsuuDwWas-unsplash.jpg',
292      ].map(async (sourceFilename, index) => {
293        const destinationFilename: string = `asset${index}.jpg`;
294        const hash = await Update.copyAssetToStaticFolder(
295          path.join(__dirname, 'assets', sourceFilename),
296          destinationFilename
297        );
298        return {
299          hash,
300          key: `asset${index}`,
301          contentType: 'image/jpg',
302          fileExtension: '.jpg',
303          url: `http://${Update.serverHost}:${Update.serverPort}/static/${destinationFilename}`,
304        };
305      })
306    );
307    const manifest = Update.getUpdateManifestForBundleFilename(
308      new Date(),
309      hash,
310      'test-update-2-key',
311      bundleFilename,
312      assets,
313      projectRoot
314    );
315
316    Server.start(Update.serverPort, protocolVersion);
317    await Server.serveSignedManifest(manifest, projectRoot);
318    await device.installApp();
319    await device.launchApp({
320      newInstance: true,
321    });
322    await waitForAppToBecomeVisible();
323    const message = await testElementValueAsync('updateString');
324    jestExpect(message).toBe('test');
325
326    // give the app time to load the new update in the background
327    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(4);
328
329    // restart the app so it will launch the new update
330    await device.terminateApp();
331    await device.launchApp();
332    const updatedMessage = await testElementValueAsync('updateString');
333
334    jestExpect(updatedMessage).toBe(newNotifyString);
335  });
336
337  // important for usage accuracy
338  it('does not download any assets for an older update', async () => {
339    jest.setTimeout(300000 * TIMEOUT_BIAS);
340    const bundleFilename = 'bundle-old.js';
341    const hash = await Update.copyBundleToStaticFolder(
342      projectRoot,
343      bundleFilename,
344      'test-update-older',
345      platform
346    );
347    const manifest = Update.getUpdateManifestForBundleFilename(
348      new Date(Date.now() - 1000 * 60 * 60 * 24),
349      hash,
350      'test-update-old-key',
351      bundleFilename,
352      [],
353      projectRoot
354    );
355
356    Server.start(Update.serverPort, protocolVersion);
357    await Server.serveSignedManifest(manifest, projectRoot);
358    await device.installApp();
359    await device.launchApp({
360      newInstance: true,
361    });
362    await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
363    await waitForAppToBecomeVisible();
364    const firstMessage = await testElementValueAsync('updateString');
365    jestExpect(firstMessage).toBe('test');
366
367    // give the app time to load the new update in the background (i.e. to make sure it doesn't)
368    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(0);
369
370    // restart the app and make sure it's still running the initial update
371    await device.terminateApp();
372    await device.launchApp();
373    const secondMessage = await testElementValueAsync('updateString');
374    jestExpect(secondMessage).toBe('test');
375  });
376
377  it('supports rollbacks', async () => {
378    jest.setTimeout(300000 * TIMEOUT_BIAS);
379    const bundleFilename = 'bundle1.js';
380    const newNotifyString = 'test-update-3';
381    const hash = await Update.copyBundleToStaticFolder(
382      projectRoot,
383      bundleFilename,
384      newNotifyString,
385      platform
386    );
387    const manifest = Update.getUpdateManifestForBundleFilename(
388      new Date(),
389      hash,
390      'test-update-3-key',
391      bundleFilename,
392      [],
393      projectRoot
394    );
395
396    Server.start(Update.serverPort, protocolVersion);
397    await Server.serveSignedManifest(manifest, projectRoot);
398    await device.installApp();
399    await device.launchApp({
400      newInstance: true,
401    });
402    const firstRequest = await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
403    await waitForAppToBecomeVisible();
404    const message = await testElementValueAsync('updateString');
405    jestExpect(message).toBe('test');
406
407    // give the app time to load the new update in the background
408    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(1);
409
410    // restart the app so it will launch the new update
411    await device.terminateApp();
412    await device.launchApp();
413    const secondRequest = await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
414    await waitForAppToBecomeVisible();
415    const updatedMessage = await testElementValueAsync('updateString');
416    jestExpect(updatedMessage).toBe(newNotifyString);
417
418    // serve a rollback now
419    const rollbackDirective = Update.getRollbackDirective(new Date());
420    await Server.serveSignedDirective(rollbackDirective, projectRoot);
421
422    // restart the app so it will fetch the rollback
423    await device.terminateApp();
424    await device.launchApp();
425    const thirdRequest = await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
426
427    // Restart the app so it will launch the rollback
428    await device.terminateApp();
429    await device.launchApp();
430    const fourthRequest = await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
431    await waitForAppToBecomeVisible();
432    const rolledBackMessage = await testElementValueAsync('updateString');
433    jestExpect(rolledBackMessage).toBe('test');
434    jestExpect(secondRequest.headers['expo-embedded-update-id']).toBeDefined();
435    jestExpect(secondRequest.headers['expo-embedded-update-id']).toEqual(
436      firstRequest.headers['expo-embedded-update-id']
437    );
438    jestExpect(thirdRequest.headers['expo-embedded-update-id']).toEqual(
439      firstRequest.headers['expo-embedded-update-id']
440    );
441    jestExpect(fourthRequest.headers['expo-embedded-update-id']).toEqual(
442      firstRequest.headers['expo-embedded-update-id']
443    );
444
445    jestExpect(firstRequest.headers['expo-current-update-id']).toEqual(
446      firstRequest.headers['expo-embedded-update-id']
447    );
448    jestExpect(secondRequest.headers['expo-current-update-id']).toEqual(manifest.id);
449    jestExpect(thirdRequest.headers['expo-current-update-id']).toEqual(manifest.id);
450    jestExpect(fourthRequest.headers['expo-current-update-id']).toEqual(
451      firstRequest.headers['expo-embedded-update-id']
452    );
453  });
454});
455
456describe('JS API tests', () => {
457  afterEach(async () => {
458    await device.uninstallApp();
459    Server.stop();
460  });
461
462  it('downloads and runs update with JS API', async () => {
463    jest.setTimeout(300000 * TIMEOUT_BIAS);
464    const bundleFilename = 'bundle1.js';
465    const newNotifyString = 'test-update-1';
466    const hash = await Update.copyBundleToStaticFolder(
467      projectRoot,
468      bundleFilename,
469      newNotifyString,
470      platform
471    );
472    const manifest = Update.getUpdateManifestForBundleFilename(
473      new Date(),
474      hash,
475      'test-update-1-key',
476      bundleFilename,
477      [],
478      projectRoot
479    );
480    await device.installApp();
481    await device.launchApp({
482      newInstance: true,
483    });
484    await waitForAppToBecomeVisible();
485    const message = await testElementValueAsync('updateString');
486    jestExpect(message).toEqual('test');
487    const isEmbedded = await testElementValueAsync('isEmbeddedLaunch');
488    jestExpect(isEmbedded).toEqual('true');
489    const checkAutomatically = await testElementValueAsync('checkAutomatically');
490    jestExpect(checkAutomatically).toEqual('ON_LOAD');
491
492    // Test extra params
493    await pressTestButtonAsync('setExtraParams');
494    const extraParamsString = await testElementValueAsync('extraParamsString');
495    console.warn(`extraParamsString = ${extraParamsString}`);
496    jestExpect(extraParamsString).toContain('testparam');
497    jestExpect(extraParamsString).toContain('testvalue');
498    jestExpect(extraParamsString).not.toContain('testsetnull');
499
500    Server.start(Update.serverPort, protocolVersion);
501    await Server.serveSignedManifest(manifest, projectRoot);
502    await pressTestButtonAsync('checkForUpdate');
503    const availableUpdateID = await testElementValueAsync('availableUpdateID');
504    jestExpect(availableUpdateID).toEqual(manifest.id);
505    await pressTestButtonAsync('downloadUpdate');
506    await setTimeout(2000);
507    Server.stop();
508    await device.terminateApp();
509    await device.launchApp();
510    await waitForAppToBecomeVisible();
511    const runningUpdateID = await testElementValueAsync('updateID');
512    jestExpect(runningUpdateID).toEqual(manifest.id);
513    const isEmbeddedAfterUpdate = await testElementValueAsync('isEmbeddedLaunch');
514    jestExpect(isEmbeddedAfterUpdate).toEqual('false');
515  });
516
517  it('Receives state machine change events', async () => {
518    jest.setTimeout(300000 * TIMEOUT_BIAS);
519    const bundleFilename = 'bundle1.js';
520    const newNotifyString = 'test-update-1';
521    const hash = await Update.copyBundleToStaticFolder(
522      projectRoot,
523      bundleFilename,
524      newNotifyString,
525      platform
526    );
527    const manifest = Update.getUpdateManifestForBundleFilename(
528      new Date(),
529      hash,
530      'test-update-1-key',
531      bundleFilename,
532      [],
533      projectRoot
534    );
535
536    // Launch app
537    await device.installApp();
538    await device.launchApp({
539      newInstance: true,
540    });
541    await waitForAppToBecomeVisible();
542
543    // Check state
544    const isUpdatePending = await testElementValueAsync('state.isUpdatePending');
545    const isUpdateAvailable = await testElementValueAsync('state.isUpdateAvailable');
546    const latestManifestId = await testElementValueAsync('state.latestManifest.id');
547    const downloadedManifestId = await testElementValueAsync('state.downloadedManifest.id');
548    const isRollback = await testElementValueAsync('state.isRollback');
549
550    console.warn(`isUpdatePending = ${isUpdatePending}`);
551    console.warn(`isUpdateAvailable = ${isUpdateAvailable}`);
552    console.warn(`isRollback = ${isRollback}`);
553    console.warn(`latestManifestId = ${latestManifestId}`);
554    console.warn(`downloadedManifestId = ${downloadedManifestId}`);
555
556    const updatesExpoClientEmbeddedString = await testElementValueAsync('updates.expoClient');
557    const constantsExpoConfigEmbeddedString = await testElementValueAsync('constants.expoConfig');
558    console.warn(`updatesExpoClientEmbedded = ${updatesExpoClientEmbeddedString}`);
559    console.warn(`constantsExpoConfigEmbedded = ${constantsExpoConfigEmbeddedString}`);
560
561    // Now serve a manifest
562    Server.start(Update.serverPort, protocolVersion);
563    await Server.serveSignedManifest(manifest, projectRoot);
564
565    // Check for update, and expect isUpdateAvailable to be true
566    await pressTestButtonAsync('checkForUpdate');
567    await pressTestButtonAsync('checkForUpdate');
568    await pressTestButtonAsync('checkForUpdate');
569    await pressTestButtonAsync('checkForUpdate');
570
571    const isUpdatePending2 = await testElementValueAsync('state.isUpdatePending');
572    const isUpdateAvailable2 = await testElementValueAsync('state.isUpdateAvailable');
573    const latestManifestId2 = await testElementValueAsync('state.latestManifest.id');
574    const downloadedManifestId2 = await testElementValueAsync('state.downloadedManifest.id');
575    const isRollback2 = await testElementValueAsync('state.isRollback');
576
577    console.warn(`isUpdatePending2 = ${isUpdatePending2}`);
578    console.warn(`isUpdateAvailable2 = ${isUpdateAvailable2}`);
579    console.warn(`isRollback2 = ${isRollback2}`);
580    console.warn(`latestManifestId2 = ${latestManifestId2}`);
581    console.warn(`downloadedManifestId2 = ${downloadedManifestId2}`);
582
583    // Download update and expect isUpdatePending to be true
584    await pressTestButtonAsync('downloadUpdate');
585    await pressTestButtonAsync('downloadUpdate');
586    await pressTestButtonAsync('downloadUpdate');
587    await pressTestButtonAsync('downloadUpdate');
588
589    const isUpdatePending3 = await testElementValueAsync('state.isUpdatePending');
590    const isUpdateAvailable3 = await testElementValueAsync('state.isUpdateAvailable');
591    const latestManifestId3 = await testElementValueAsync('state.latestManifest.id');
592    const downloadedManifestId3 = await testElementValueAsync('state.downloadedManifest.id');
593    const isRollback3 = await testElementValueAsync('state.isRollback');
594    await waitFor(element(by.id('activity')))
595      .not.toBeVisible()
596      .withTimeout(2000);
597
598    console.warn(`isUpdatePending3 = ${isUpdatePending3}`);
599    console.warn(`isUpdateAvailable3 = ${isUpdateAvailable3}`);
600    console.warn(`isRollback3 = ${isRollback3}`);
601    console.warn(`latestManifestId3 = ${latestManifestId3}`);
602    console.warn(`downloadedManifestId3 = ${downloadedManifestId3}`);
603
604    // Test native context reader
605    await pressTestButtonAsync('readNativeStateContext');
606    await waitFor(element(by.id('activity')))
607      .not.toBeVisible()
608      .withTimeout(2000);
609    const nativeStateContextString = await testElementValueAsync('nativeStateContextString');
610    const nativeStateContext = JSON.parse(nativeStateContextString);
611    console.warn(`nativeStateContext = ${JSON.stringify(nativeStateContext, null, 2)}`);
612
613    // Terminate and relaunch app, we should be running the update, and back to the default state
614    await device.terminateApp();
615    await device.launchApp();
616    await waitForAppToBecomeVisible();
617
618    const isUpdatePending4 = await testElementValueAsync('state.isUpdatePending');
619    const isUpdateAvailable4 = await testElementValueAsync('state.isUpdateAvailable');
620    const latestManifestId4 = await testElementValueAsync('state.latestManifest.id');
621    const downloadedManifestId4 = await testElementValueAsync('state.downloadedManifest.id');
622    const isRollback4 = await testElementValueAsync('state.isRollback');
623    const rollbackCommitTime4 = await testElementValueAsync('state.rollbackCommitTime');
624
625    console.warn(`isUpdatePending4 = ${isUpdatePending4}`);
626    console.warn(`isUpdateAvailable4 = ${isUpdateAvailable4}`);
627    console.warn(`isRollback4 = ${isRollback4}`);
628    console.warn(`latestManifestId4 = ${latestManifestId4}`);
629    console.warn(`downloadedManifestId4 = ${downloadedManifestId4}`);
630    console.warn(`rollbackCommitTime4 = ${rollbackCommitTime4}`);
631
632    const updatesExpoClientUpdateString = await testElementValueAsync('updates.expoClient');
633    const constantsExpoConfigUpdateString = await testElementValueAsync('constants.expoConfig');
634    console.warn(`updatesExpoClientUpdate = ${updatesExpoClientUpdateString}`);
635    console.warn(`constantsExpoConfigUpdate = ${constantsExpoConfigUpdateString}`);
636
637    // Now serve a rollback
638    const rollbackDirective = Update.getRollbackDirective(new Date());
639    await Server.serveSignedDirective(rollbackDirective, projectRoot);
640
641    // Check for update, and expect isRollback to be true
642    await pressTestButtonAsync('checkForUpdate');
643
644    const isUpdatePending5 = await testElementValueAsync('state.isUpdatePending');
645    const isUpdateAvailable5 = await testElementValueAsync('state.isUpdateAvailable');
646    const latestManifestId5 = await testElementValueAsync('state.latestManifest.id');
647    const downloadedManifestId5 = await testElementValueAsync('state.downloadedManifest.id');
648    const isRollback5 = await testElementValueAsync('state.isRollback');
649    const rollbackCommitTime5 = await testElementValueAsync('state.rollbackCommitTime');
650
651    console.warn(`isUpdatePending5 = ${isUpdatePending5}`);
652    console.warn(`isUpdateAvailable5 = ${isUpdateAvailable5}`);
653    console.warn(`isRollback5 = ${isRollback5}`);
654    console.warn(`latestManifestId5 = ${latestManifestId5}`);
655    console.warn(`downloadedManifestId5 = ${downloadedManifestId5}`);
656    console.warn(`rollbackCommitTime5 = ${rollbackCommitTime5}`);
657
658    // Terminate and relaunch app, we should be running the original bundle again, and back to the default state
659    await device.terminateApp();
660    await device.launchApp();
661    await waitForAppToBecomeVisible();
662
663    const isUpdatePending6 = await testElementValueAsync('state.isUpdatePending');
664    const isUpdateAvailable6 = await testElementValueAsync('state.isUpdateAvailable');
665    const latestManifestId6 = await testElementValueAsync('state.latestManifest.id');
666    const downloadedManifestId6 = await testElementValueAsync('state.downloadedManifest.id');
667    const isRollback6 = await testElementValueAsync('state.isRollback');
668    const rollbackCommitTime6 = await testElementValueAsync('state.rollbackCommitTime');
669
670    console.warn(`isUpdatePending6 = ${isUpdatePending6}`);
671    console.warn(`isUpdateAvailable6 = ${isUpdateAvailable6}`);
672    console.warn(`isRollback6 = ${isRollback6}`);
673    console.warn(`latestManifestId6 = ${latestManifestId6}`);
674    console.warn(`downloadedManifestId6 = ${downloadedManifestId6}`);
675    console.warn(`rollbackCommitTime6 = ${rollbackCommitTime6}`);
676
677    const updatesExpoConfigRollbackString = await testElementValueAsync('updates.expoClient');
678    const constantsExpoConfigRollbackString = await testElementValueAsync('constants.expoConfig');
679    console.warn(`updatesExpoConfigRollback = ${updatesExpoConfigRollbackString}`);
680    console.warn(`constantsExpoConfigRollback = ${constantsExpoConfigRollbackString}`);
681
682    // Unpack expo config values and check them
683    const updatesExpoConfigEmbedded = JSON.parse(updatesExpoClientEmbeddedString);
684    jestExpect(updatesExpoConfigEmbedded).not.toBeNull();
685
686    // Verify correct behavior
687    // On launch
688    jestExpect(isUpdateAvailable).toEqual('false');
689    jestExpect(isUpdatePending).toEqual('false');
690    jestExpect(isRollback).toEqual('false');
691    jestExpect(latestManifestId).toEqual('');
692    jestExpect(downloadedManifestId).toEqual('');
693    // After check for update and getting a manifest
694    jestExpect(isUpdateAvailable2).toEqual('true');
695    jestExpect(isUpdatePending2).toEqual('false');
696    jestExpect(isRollback2).toEqual('false');
697    jestExpect(latestManifestId2).toEqual(manifest.id);
698    jestExpect(downloadedManifestId2).toEqual('');
699    // After downloading the update
700    jestExpect(isUpdateAvailable3).toEqual('true');
701    jestExpect(isUpdatePending3).toEqual('true');
702    jestExpect(isRollback3).toEqual('false');
703    jestExpect(latestManifestId3).toEqual(manifest.id);
704    jestExpect(downloadedManifestId3).toEqual(manifest.id);
705    // native state context values
706    jestExpect(nativeStateContext.latestManifest?.id).toEqual(manifest.id);
707    jestExpect(nativeStateContext.isUpdateAvailable).toBe(true);
708    jestExpect(nativeStateContext.isUpdatePending).toBe(true);
709    // After restarting
710    jestExpect(isUpdateAvailable4).toEqual('false');
711    jestExpect(isUpdatePending4).toEqual('false');
712    jestExpect(isRollback4).toEqual('false');
713    jestExpect(latestManifestId4).toEqual('');
714    jestExpect(downloadedManifestId4).toEqual('');
715    jestExpect(rollbackCommitTime4).toEqual('');
716    // After check for update and getting a rollback
717    jestExpect(isUpdateAvailable5).toEqual('true');
718    jestExpect(isUpdatePending5).toEqual('false');
719    jestExpect(isRollback5).toEqual('true');
720    jestExpect(latestManifestId5).toEqual('');
721    jestExpect(downloadedManifestId5).toEqual('');
722    jestExpect(rollbackCommitTime5).not.toEqual('');
723  });
724
725  it('Receives expected events when update available on start', async () => {
726    jest.setTimeout(300000 * TIMEOUT_BIAS);
727    const bundleFilename = 'bundle1.js';
728    const newNotifyString = 'test-update-1';
729    const hash = await Update.copyBundleToStaticFolder(
730      projectRoot,
731      bundleFilename,
732      newNotifyString,
733      platform
734    );
735    const manifest = Update.getUpdateManifestForBundleFilename(
736      new Date(),
737      hash,
738      'test-update-1-key',
739      bundleFilename,
740      [],
741      projectRoot
742    );
743    // Launch app
744    await device.installApp();
745    await device.launchApp({
746      newInstance: true,
747    });
748    await waitForAppToBecomeVisible();
749
750    const lastUpdateEventType = await testElementValueAsync('lastUpdateEventType');
751    // Server is not running, so error received
752    console.warn(`lastUpdateEventType = ${lastUpdateEventType}`);
753
754    // Error should be surfaced in checkError
755    const checkErrorMessage = await testElementValueAsync('state.checkError');
756    console.warn(`checkErrorMessage = ${checkErrorMessage}`);
757
758    // Start server with no update available directive,
759    // then restart app, we should get "No update available" event
760    let lastUpdateEventType2 = '';
761    let checkErrorMessage2 = '';
762    if (protocolVersion === 1) {
763      Server.start(Update.serverPort, protocolVersion);
764      const directive = Update.getNoUpdateAvailableDirective();
765      await Server.serveSignedDirective(directive, projectRoot);
766      await device.terminateApp();
767      await device.launchApp();
768      await waitForAppToBecomeVisible();
769      await readLogEntriesAsync();
770
771      lastUpdateEventType2 = await testElementValueAsync('lastUpdateEventType');
772      checkErrorMessage2 = await testElementValueAsync('state.checkError');
773      console.warn(`lastUpdateEventType2 = ${lastUpdateEventType2}`);
774      console.warn(`checkErrorMessage2 = ${checkErrorMessage2}`);
775      Server.stop();
776    }
777
778    // Relaunch app after server has an update,
779    // we should get the 'update available' event
780    Server.start(Update.serverPort, protocolVersion);
781    await Server.serveSignedManifest(manifest, projectRoot);
782    await device.terminateApp();
783    await device.launchApp();
784    await waitForAppToBecomeVisible();
785
786    const lastUpdateEventType3 = await testElementValueAsync('lastUpdateEventType');
787    const checkErrorMessage3 = await testElementValueAsync('state.checkError');
788    console.warn(`lastUpdateEventType3 = ${lastUpdateEventType3}`);
789    console.warn(`checkErrorMessage3 = ${checkErrorMessage3}`);
790
791    // Test passes if all the event types seen are the expected ones
792    // This test not working on Android in 0.72 in the CI environment, so disable it for now.
793    if (platform === 'ios') {
794      jestExpect(lastUpdateEventType).toEqual('error');
795      jestExpect(lastUpdateEventType2).toEqual('noUpdateAvailable');
796      jestExpect(lastUpdateEventType3).toEqual('updateAvailable');
797      jestExpect(checkErrorMessage).toEqual('Could not connect to the server.');
798      jestExpect(checkErrorMessage2).toEqual('');
799      jestExpect(checkErrorMessage3).toEqual('');
800    }
801  });
802});
803
804// The tests in this suite install an app with multiple assets, then clear all the assets from
805// .expo-internal storage (but not SQLite). This simulates scenarios such as: a bug in our code that
806// deletes assets unintentionally; OS deleting files from app storage if it runs out of memory; etc.
807//
808// Recovery code for this situation exists in the DatabaseLauncher, these are the main tests that
809// ensure that logic doesn't regress.
810//
811// These tests all make use of the additional UpdatesE2ETestModule, which provides methods for
812// clearing and reading the .expo-internal folder.
813describe('Asset deletion recovery tests', () => {
814  afterEach(async () => {
815    await device.uninstallApp();
816    Server.stop();
817  });
818
819  it('embedded assets deleted from internal storage should be re-copied', async () => {
820    // Simplest scenario; only one update (embedded) is loaded, then assets are cleared from
821    // internal storage. The app is then relaunched with the same embedded update.
822    // DatabaseLauncher should copy all the missing assets and run the update as normal.
823    jest.setTimeout(300000 * TIMEOUT_BIAS);
824    Server.start(Update.serverPort, protocolVersion);
825
826    // Install the app and immediately send it a message to clear internal storage. Verify storage
827    // has been cleared properly.
828    await device.installApp();
829    await device.launchApp({
830      newInstance: true,
831    });
832    await waitForAppToBecomeVisible();
833
834    // Check that we are running the embedded update
835    const isEmbedded = await testElementValueAsync('isEmbeddedLaunch');
836    jestExpect(isEmbedded).toEqual('true');
837
838    // Check that asset files are present
839    let numAssets = await checkNumAssetsAsync();
840    jestExpect(numAssets).toBeGreaterThan(2);
841
842    // Get current update ID
843    const updateID = await testElementValueAsync('updateID');
844
845    // Clear assets and check that number of assets is now 0
846    await clearNumAssetsAsync();
847    numAssets = await checkNumAssetsAsync();
848    jestExpect(numAssets).toBe(0);
849
850    // Stop and then restart app.
851    await device.terminateApp();
852    await device.launchApp();
853    await waitForAppToBecomeVisible();
854
855    // Check that assets are restored from DB
856    numAssets = await checkNumAssetsAsync();
857    jestExpect(numAssets).toBeGreaterThan(2);
858
859    // Check that update ID is the same
860    const updateID2 = await testElementValueAsync('updateID');
861    jestExpect(updateID2).toEqual(updateID);
862
863    // Check for log messages
864    const logEntries = await readLogEntriesAsync();
865    console.warn(
866      'Total number of log entries = ' +
867        logEntries.length +
868        '\n' +
869        JSON.stringify(logEntries, null, 2)
870    );
871    jestExpect(logEntries.length).toBeGreaterThan(0);
872  });
873
874  it('embedded assets deleted from internal storage should be re-copied from a new embedded update', async () => {
875    // This test ensures that when trying to launch a NEW update that includes some OLD assets we
876    // already have (according to SQLite), even if those assets are actually missing from disk
877    // (but included in the embedded update) DatabaseLauncher can recover.
878    //
879    // To create this scenario, we load a single (embedded) update, then clear assets from
880    // internal storage. Then we install a NEW build with a NEW embedded update but that includes
881    // some of the same assets. When we launch this new build, DatabaseLauncher should still copy
882    // the missing assets and run the update as normal.
883    jest.setTimeout(300000 * TIMEOUT_BIAS);
884    Server.start(Update.serverPort, protocolVersion);
885
886    // Install the app and immediately send it a message to clear internal storage. Verify storage
887    // has been cleared properly.
888    await device.installApp();
889    await device.launchApp({
890      newInstance: true,
891    });
892    await waitForAppToBecomeVisible();
893
894    // Save the number of assets in storage
895    const numAssetsSaved = await checkNumAssetsAsync();
896    jestExpect(numAssetsSaved).toBeGreaterThan(0);
897
898    // Clear assets and check that number of assets is now 0
899    await clearNumAssetsAsync();
900    let numAssets = await checkNumAssetsAsync();
901    jestExpect(numAssets).toBe(0);
902
903    // Stop the app and install a newer build on top of it. The newer build has a different
904    // embedded update (different updateId) but still includes some of the same assets. Now SQLite
905    // thinks we already have these assets, but we actually just deleted them from internal
906    // storage.
907    await device.terminateApp();
908    await device.installApp();
909
910    // Start the new build, and immediately send it a message to read internal storage.
911    await device.launchApp({
912      newInstance: true,
913    });
914    await waitForAppToBecomeVisible();
915
916    // Verify all the assets that were deleted have been re-copied back into internal storage, and
917    // that we are running a DIFFERENT update than before -- otherwise this test is no different
918    // from the previous one.
919    numAssets = await checkNumAssetsAsync();
920    jestExpect(numAssets).toEqual(numAssetsSaved);
921
922    // TODO: develop a way to modify the embedded update used by the build in a Detox test environment,
923    // so that we can actually do this test with a real modified update. Until then, disable the line below
924    // to allow the test to pass.
925    //jestExpect(readAssetsMessage.updateId).not.toEqual(clearAssetsMessage.updateId);
926  });
927
928  it('assets in a downloaded update deleted from internal storage should be re-copied or re-downloaded', async () => {
929    // This test ensures we can (or at least try to) recover missing assets that originated from a
930    // downloaded update, as opposed to assets originally copied from an embedded update (which
931    // the previous 2 tests concern).
932    //
933    // To create this scenario, we launch an app, download an update with multiple assets
934    // (including at least one -- the bundle -- not part of the embedded update), make sure the
935    // update runs, then clear assets from internal storage. When we relaunch the app,
936    // DatabaseLauncher should re-download the missing assets and run the update as normal.
937    jest.setTimeout(300000 * TIMEOUT_BIAS);
938
939    // Prepare to host update manifest and assets from the test runner
940    const bundleFilename = 'bundle-assets.js';
941    const newNotifyString = 'test-assets-1';
942    const bundleHash = await Update.copyBundleToStaticFolder(
943      projectRoot,
944      bundleFilename,
945      newNotifyString,
946      platform
947    );
948
949    const bundledAssets = Update.findAssets(projectRoot, platform);
950    const assets = await Promise.all(
951      bundledAssets.map(async (asset: { path: string; ext: string }) => {
952        const filename = path.basename(asset.path);
953        const mimeType = asset.ext === 'ttf' ? 'font/ttf' : 'image/png';
954        const key = filename.replace('asset_', '').replace(/\.[^/.]+$/, '');
955        const hash = await Update.copyAssetToStaticFolder(asset.path, filename);
956        return {
957          hash,
958          key,
959          contentType: mimeType,
960          fileExtension: asset.ext,
961          url: `http://${Update.serverHost}:${Update.serverPort}/static/${filename}`,
962        };
963      })
964    );
965    const manifest = Update.getUpdateManifestForBundleFilename(
966      new Date(),
967      bundleHash,
968      'test-assets-bundle',
969      bundleFilename,
970      assets,
971      projectRoot
972    );
973
974    // Install the app and launch it so that it downloads the new update we're hosting
975    Server.start(Update.serverPort, protocolVersion);
976    await Server.serveSignedManifest(manifest, projectRoot);
977    await device.installApp();
978    await device.launchApp({ newInstance: true });
979    await Server.waitForUpdateRequest(10000 * TIMEOUT_BIAS);
980
981    // give the app time to load the new update in the background
982    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(1); // only the bundle should be new
983
984    // Stop and restart the app so it will launch the new update. Immediately send it a message to
985    // clear internal storage while also verifying the new update is running.
986    await device.terminateApp();
987    await device.launchApp({ newInstance: true });
988    await waitForAppToBecomeVisible();
989    const updateString = await testElementValueAsync('updateString');
990    jestExpect(updateString).toEqual(newNotifyString);
991    await clearNumAssetsAsync();
992
993    // Verify that the assets were cleared correctly.
994    let numAssets = await checkNumAssetsAsync();
995    jestExpect(numAssets).toBe(0);
996    let updateID = await testElementValueAsync('updateID');
997    jestExpect(updateID).toEqual(manifest.id);
998
999    // Stop and restart the app and immediately send it a message to read internal storage. Verify
1000    // that the new update is running (again).
1001    await device.terminateApp();
1002    await device.launchApp({ newInstance: true });
1003    await waitForAppToBecomeVisible();
1004
1005    // Verify all the assets -- including the JS bundle from the update (which wasn't in the
1006    // embedded update) -- have been restored. Additionally verify from the server side that the
1007    // updated bundle was re-downloaded.
1008    numAssets = await checkNumAssetsAsync();
1009    jestExpect(numAssets).toBe(manifest.assets.length + 1);
1010    updateID = await testElementValueAsync('updateID');
1011    jestExpect(updateID).toEqual(manifest.id);
1012    jestExpect(Server.consumeRequestedStaticFiles().length).toBe(1); // should have re-downloaded only the JS bundle; the rest should have been copied from the app binary
1013  });
1014});
1015