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