1const spawnAsync = require('@expo/spawn-async'); 2const fs = require('fs/promises'); 3const glob = require('glob'); 4const path = require('path'); 5 6const dirName = __dirname; /* eslint-disable-line */ 7 8const expoDependencyNames = [ 9 'expo', 10 '@expo/cli', 11 '@expo/config-plugins', 12 '@expo/config-types', 13 '@expo/prebuild-config', 14 'expo-application', 15 'expo-constants', 16 'expo-eas-client', 17 'expo-file-system', 18 'expo-font', 19 'expo-json-utils', 20 'expo-keep-awake', 21 'expo-manifests', 22 'expo-modules-autolinking', 23 'expo-modules-core', 24 'expo-splash-screen', 25 'expo-status-bar', 26 'expo-structured-headers', 27 'expo-updates', 28 'expo-updates-interface', 29]; 30 31const expoResolutions = {}; 32const expoVersions = {}; 33 34/** 35 * Executes `npm pack` on one of the Expo packages used in updates E2E 36 * Adds a dateTime stamp to the version to ensure that it is unique and that 37 * only this version will be used when yarn installs dependencies in the test app. 38 */ 39async function packExpoDependency(repoRoot, projectRoot, destPath, dependencyName) { 40 // Pack up the named Expo package into the destination folder 41 const dependencyComponents = dependencyName.split('/'); 42 let dependencyPath; 43 if (dependencyComponents[0] === '@expo') { 44 dependencyPath = path.resolve( 45 repoRoot, 46 'packages', 47 dependencyComponents[0], 48 dependencyComponents[1] 49 ); 50 } else { 51 dependencyPath = path.resolve(repoRoot, 'packages', dependencyComponents[0]); 52 } 53 54 // Save a copy of package.json 55 const packageJsonPath = path.resolve(dependencyPath, 'package.json'); 56 const packageJsonCopyPath = `${packageJsonPath}-original`; 57 await fs.copyFile(packageJsonPath, packageJsonCopyPath); 58 // Extract the version from package.json 59 const packageJson = require(packageJsonPath); 60 const originalVersion = packageJson.version; 61 // Add string to the version to ensure that yarn uses the tarball and not the published version 62 const e2eVersion = `${originalVersion}-${new Date().getTime()}`; 63 await fs.writeFile( 64 packageJsonPath, 65 JSON.stringify( 66 { 67 ...packageJson, 68 version: e2eVersion, 69 }, 70 null, 71 2 72 ) 73 ); 74 75 await spawnAsync('npm', ['pack', '--pack-destination', destPath], { 76 cwd: dependencyPath, 77 stdio: 'ignore', 78 }); 79 80 // Ensure the file was created as expected 81 const dependencyTarballName = 82 dependencyComponents[0] === '@expo' 83 ? `expo-${dependencyComponents[1]}` 84 : `${dependencyComponents[0]}`; 85 const dependencyTarballPath = glob.sync(path.join(destPath, `${dependencyTarballName}-*.tgz`))[0]; 86 87 if (!dependencyTarballPath) { 88 throw new Error(`Failed to locate packed ${dependencyName} in ${destPath}`); 89 } 90 91 // Restore the original package JSON 92 await fs.copyFile(packageJsonCopyPath, packageJsonPath); 93 await fs.rm(packageJsonCopyPath); 94 95 // Return the dependency in the form needed by package.json, as a relative path 96 const dependency = `.${path.sep}${path.relative(projectRoot, dependencyTarballPath)}`; 97 return { 98 dependency, 99 e2eVersion, 100 }; 101} 102 103async function copyCommonFixturesToProject(projectRoot, { appJsFileName, repoRoot, isTV = false }) { 104 // copy App.tsx from test fixtures 105 const appJsSourcePath = path.resolve(dirName, '..', 'fixtures', appJsFileName); 106 const appJsDestinationPath = path.resolve(projectRoot, 'App.tsx'); 107 let appJsFileContents = await fs.readFile(appJsSourcePath, 'utf-8'); 108 appJsFileContents = appJsFileContents 109 .replace('UPDATES_HOST', process.env.UPDATES_HOST) 110 .replace('UPDATES_PORT', process.env.UPDATES_PORT); 111 await fs.writeFile(appJsDestinationPath, appJsFileContents, 'utf-8'); 112 113 // pack up project files 114 const projectFilesSourcePath = path.join(dirName, '..', 'fixtures', 'project_files'); 115 const projectFilesTarballPath = path.join(projectRoot, 'project_files.tgz'); 116 await spawnAsync( 117 'tar', 118 [ 119 'zcf', 120 projectFilesTarballPath, 121 'tsconfig.json', 122 '.detoxrc.json', 123 'detox.config.js', 124 'eas.json', 125 'eas-hooks', 126 'e2e', 127 'scripts', 128 ], 129 { 130 cwd: projectFilesSourcePath, 131 stdio: 'inherit', 132 } 133 ); 134 135 // unpack project files in project directory 136 await spawnAsync('tar', ['zxf', projectFilesTarballPath], { 137 cwd: projectRoot, 138 stdio: 'inherit', 139 }); 140 141 // remove project files archive 142 await fs.rm(projectFilesTarballPath); 143 144 // copy .prettierrc 145 await fs.copyFile(path.resolve(repoRoot, '.prettierrc'), path.join(projectRoot, '.prettierrc')); 146 147 // Modify specific files for TV 148 if (isTV) { 149 // Modify .detoxrc.json for TV 150 const detoxRCPath = path.resolve(projectRoot, '.detoxrc.json'); 151 let detoxRCText = await fs.readFile(detoxRCPath, { encoding: 'utf-8' }); 152 detoxRCText = detoxRCText.replace(/iphonesim/g, 'appletvsim').replace('iPhone 14', 'Apple TV'); 153 await fs.rm(detoxRCPath); 154 await fs.writeFile(detoxRCPath, detoxRCText, { encoding: 'utf-8' }); 155 156 // Add TV environment variable to EAS build config 157 const easJsonPath = path.resolve(projectRoot, 'eas.json'); 158 let easJson = require(easJsonPath); 159 easJson = { 160 ...easJson, 161 build: { 162 ...easJson.build, 163 updates_testing: { 164 ...easJson.build.updates_testing, 165 env: { 166 ...easJson.build.updates_testing.env, 167 TEST_TV_BUILD: '1', 168 }, 169 }, 170 }, 171 }; 172 await fs.rm(easJsonPath); 173 await fs.writeFile(easJsonPath, JSON.stringify(easJson, null, 2), { encoding: 'utf-8' }); 174 } 175} 176 177/** 178 * Adds all the dependencies and other properties needed for the E2E test app 179 */ 180async function preparePackageJson(projectRoot, repoRoot, configureE2E) { 181 // Create the project subfolder to hold NPM tarballs built from the current state of the repo 182 const dependenciesPath = path.join(projectRoot, 'dependencies'); 183 await fs.mkdir(dependenciesPath); 184 185 for (const dependencyName of expoDependencyNames) { 186 console.log(`Packing ${dependencyName}...`); 187 const result = await packExpoDependency( 188 repoRoot, 189 projectRoot, 190 dependenciesPath, 191 dependencyName 192 ); 193 expoResolutions[dependencyName] = result.dependency; 194 expoVersions[dependencyName] = result.dependency; 195 } 196 console.log('Done packing dependencies.'); 197 198 // Additional scripts and dependencies for Detox testing 199 const extraScripts = configureE2E 200 ? { 201 'detox:android:debug:build': 'detox build -c android.debug', 202 'detox:android:debug:test': 'detox test -c android.debug', 203 'detox:android:release:build': 'detox build -c android.release', 204 'detox:android:release:test': 'detox test -c android.release', 205 'detox:ios:debug:build': 'detox build -c ios.debug', 206 'detox:ios:debug:test': 'detox test -c ios.debug', 207 'detox:ios:release:build': 'detox build -c ios.release', 208 'detox:ios:release:test': 'detox test -c ios.release', 209 'eas-build-pre-install': './eas-hooks/eas-build-pre-install.sh', 210 'eas-build-on-success': './eas-hooks/eas-build-on-success.sh', 211 'generate-test-update-bundles': 'node scripts/generate-test-update-bundles.js', 212 } 213 : {}; 214 215 const extraDevDependencies = configureE2E 216 ? { 217 '@config-plugins/detox': '^5.0.1', 218 '@types/express': '^4.17.17', 219 '@types/jest': '^29.4.0', 220 '@types/react': '~18.0.14', 221 '@types/react-native': '~0.70.6', 222 detox: '^20.4.0', 223 express: '^4.18.2', 224 'form-data': '^4.0.0', 225 jest: '^29.3.1', 226 'jest-circus': '^29.3.1', 227 prettier: '^2.8.1', 228 'ts-jest': '^29.0.5', 229 typescript: '^4.6.3', 230 } 231 : {}; 232 233 // Remove the default Expo dependencies from create-expo-app 234 let packageJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8')); 235 for (const dependencyName of expoDependencyNames) { 236 if (packageJson.dependencies[dependencyName]) { 237 delete packageJson.dependencies[dependencyName]; 238 } 239 } 240 // Add dependencies and resolutions to package.json 241 packageJson = { 242 ...packageJson, 243 scripts: { 244 ...packageJson.scripts, 245 ...extraScripts, 246 }, 247 dependencies: { 248 ...expoResolutions, 249 ...packageJson.dependencies, 250 }, 251 devDependencies: { 252 ...extraDevDependencies, 253 ...packageJson.devDependencies, 254 }, 255 resolutions: { 256 ...expoResolutions, 257 ...packageJson.resolutions, 258 }, 259 }; 260 261 const packageJsonString = JSON.stringify(packageJson, null, 2); 262 await fs.writeFile(path.join(projectRoot, 'package.json'), packageJsonString, 'utf-8'); 263} 264 265/** 266 * Adds Detox modules to both iOS and Android expo-updates code. 267 * Returns a function that cleans up these changes to the repo once E2E setup is complete 268 */ 269async function prepareLocalUpdatesModule(repoRoot) { 270 // copy UpdatesE2ETest exported module into the local package 271 const iosE2ETestModuleSwiftPath = path.join( 272 repoRoot, 273 'packages', 274 'expo-updates', 275 'ios', 276 'EXUpdates', 277 'E2ETestModule.swift' 278 ); 279 const androidE2ETestModuleKTPath = path.join( 280 repoRoot, 281 'packages', 282 'expo-updates', 283 'android', 284 'src', 285 'main', 286 'java', 287 'expo', 288 'modules', 289 'updates', 290 'UpdatesE2ETestModule.kt' 291 ); 292 await fs.copyFile( 293 path.resolve(dirName, '..', 'fixtures', 'E2ETestModule.swift'), 294 iosE2ETestModuleSwiftPath 295 ); 296 await fs.copyFile( 297 path.resolve(dirName, '..', 'fixtures', 'UpdatesE2ETestModule.kt'), 298 androidE2ETestModuleKTPath 299 ); 300 301 // export module from UpdatesPackage on Android 302 const updatesPackageFilePath = path.join( 303 repoRoot, 304 'packages', 305 'expo-updates', 306 'android', 307 'src', 308 'main', 309 'java', 310 'expo', 311 'modules', 312 'updates', 313 'UpdatesPackage.kt' 314 ); 315 const originalUpdatesPackageFileContents = await fs.readFile(updatesPackageFilePath, 'utf8'); 316 let updatesPackageFileContents = originalUpdatesPackageFileContents; 317 if (!updatesPackageFileContents) { 318 throw new Error('Failed to read UpdatesPackage.kt; was the file renamed or moved?'); 319 } 320 updatesPackageFileContents = updatesPackageFileContents.replace( 321 'UpdatesModule(context) as ExportedModule', 322 'UpdatesModule(context) as ExportedModule, UpdatesE2ETestModule(context)' 323 ); 324 // make sure the insertion worked 325 if (!updatesPackageFileContents.includes('UpdatesE2ETestModule(context)')) { 326 throw new Error('Failed to modify UpdatesPackage.kt to insert UpdatesE2ETestModule'); 327 } 328 await fs.writeFile(updatesPackageFilePath, updatesPackageFileContents, 'utf8'); 329 330 // Add E2ETestModule to expo-module.config.json 331 const expoModuleConfigFilePath = path.join( 332 repoRoot, 333 'packages', 334 'expo-updates', 335 'expo-module.config.json' 336 ); 337 const originalExpoModuleConfigJsonString = await fs.readFile(expoModuleConfigFilePath, 'utf-8'); 338 const originalExpoModuleConfig = JSON.parse(originalExpoModuleConfigJsonString); 339 const expoModuleConfig = { 340 ...originalExpoModuleConfig, 341 ios: { 342 ...originalExpoModuleConfig.ios, 343 modules: ['UpdatesModule', 'E2ETestModule'], 344 }, 345 }; 346 await fs.writeFile(expoModuleConfigFilePath, JSON.stringify(expoModuleConfig, null, 2), 'utf-8'); 347 348 // Return cleanup function 349 return async () => { 350 await fs.writeFile(updatesPackageFilePath, originalUpdatesPackageFileContents, 'utf8'); 351 await fs.writeFile(expoModuleConfigFilePath, originalExpoModuleConfigJsonString, 'utf-8'); 352 await fs.rm(iosE2ETestModuleSwiftPath, { force: true }); 353 await fs.rm(androidE2ETestModuleKTPath, { force: true }); 354 }; 355} 356 357/** 358 * Modifies app.json in the E2E test app to add the properties we need 359 */ 360function transformAppJsonForE2E(appJson, projectName, runtimeVersion) { 361 return { 362 ...appJson, 363 expo: { 364 ...appJson.expo, 365 name: projectName, 366 owner: 'expo-ci', 367 runtimeVersion, 368 plugins: ['expo-updates', '@config-plugins/detox'], 369 android: { ...appJson.expo.android, package: 'dev.expo.updatese2e' }, 370 ios: { ...appJson.expo.ios, bundleIdentifier: 'dev.expo.updatese2e' }, 371 updates: { 372 ...appJson.updates, 373 url: `http://${process.env.UPDATES_HOST}:${process.env.UPDATES_PORT}/update`, 374 }, 375 extra: { 376 eas: { 377 projectId: '55685a57-9cf3-442d-9ba8-65c7b39849ef', 378 }, 379 }, 380 }, 381 }; 382} 383 384async function configureUpdatesSigningAsync(projectRoot) { 385 // generate and configure code signing 386 await spawnAsync( 387 'yarn', 388 [ 389 'expo-updates', 390 'codesigning:generate', 391 '--key-output-directory', 392 'keys', 393 '--certificate-output-directory', 394 'certs', 395 '--certificate-validity-duration-years', 396 '1', 397 '--certificate-common-name', 398 'E2E Test App', 399 ], 400 { cwd: projectRoot, stdio: 'inherit' } 401 ); 402 await spawnAsync( 403 'yarn', 404 [ 405 'expo-updates', 406 'codesigning:configure', 407 '--certificate-input-directory', 408 'certs', 409 '--key-input-directory', 410 'keys', 411 ], 412 { cwd: projectRoot, stdio: 'inherit' } 413 ); 414 // Archive the keys so that they are not filtered out when uploading to EAS 415 await spawnAsync('tar', ['cf', 'keys.tar', 'keys'], { cwd: projectRoot, stdio: 'inherit' }); 416} 417 418async function initAsync( 419 projectRoot, 420 { 421 repoRoot, 422 runtimeVersion, 423 localCliBin, 424 configureE2E = true, 425 transformAppJson = transformAppJsonForE2E, 426 isTV = false, 427 } 428) { 429 console.log('Creating expo app'); 430 const workingDir = path.dirname(projectRoot); 431 const projectName = path.basename(projectRoot); 432 433 // pack typescript template 434 const templateName = isTV ? 'expo-template-tv' : 'expo-template-blank-typescript'; 435 const localTSTemplatePath = path.join(repoRoot, 'templates', templateName); 436 await spawnAsync('npm', ['pack', '--pack-destination', repoRoot], { 437 cwd: localTSTemplatePath, 438 stdio: 'ignore', 439 }); 440 441 const localTSTemplatePathName = glob.sync(path.join(repoRoot, `${templateName}-*.tgz`))[0]; 442 443 if (!localTSTemplatePathName) { 444 throw new Error(`Failed to locate packed template in ${repoRoot}`); 445 } 446 447 // initialize project (do not do NPM install, we do that later) 448 await spawnAsync( 449 'yarn', 450 [ 451 'create', 452 'expo-app', 453 projectName, 454 '--yes', 455 '--no-install', 456 '--template', 457 localTSTemplatePathName, 458 ], 459 { 460 cwd: workingDir, 461 stdio: 'inherit', 462 } 463 ); 464 465 // We are done with template tarball 466 await fs.rm(localTSTemplatePathName); 467 468 let cleanupLocalUpdatesModule; 469 if (configureE2E) { 470 cleanupLocalUpdatesModule = await prepareLocalUpdatesModule(repoRoot); 471 } 472 473 await preparePackageJson(projectRoot, repoRoot, configureE2E); 474 475 // Now we do NPM install 476 await spawnAsync('yarn', [], { 477 cwd: projectRoot, 478 stdio: 'inherit', 479 }); 480 481 // configure app.json 482 let appJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'app.json'), 'utf-8')); 483 appJson = transformAppJson(appJson, projectName, runtimeVersion); 484 await fs.writeFile(path.join(projectRoot, 'app.json'), JSON.stringify(appJson, null, 2), 'utf-8'); 485 486 if (configureE2E) { 487 await configureUpdatesSigningAsync(projectRoot); 488 } 489 490 // pack local template and prebuild, but do not reinstall NPM 491 const prebuildTemplateName = isTV ? 'expo-template-tv' : 'expo-template-bare-minimum'; 492 493 const localTemplatePath = path.join(repoRoot, 'templates', prebuildTemplateName); 494 await spawnAsync('npm', ['pack', '--pack-destination', projectRoot], { 495 cwd: localTemplatePath, 496 stdio: 'ignore', 497 }); 498 499 const localTemplatePathName = glob.sync( 500 path.join(projectRoot, `${prebuildTemplateName}-*.tgz`) 501 )[0]; 502 503 if (!localTemplatePathName) { 504 throw new Error(`Failed to locate packed template in ${projectRoot}`); 505 } 506 507 await spawnAsync(localCliBin, ['prebuild', '--no-install', '--template', localTemplatePathName], { 508 env: { 509 ...process.env, 510 EX_UPDATES_NATIVE_DEBUG: '1', 511 EXPO_DEBUG: '1', 512 CI: '1', 513 }, 514 cwd: projectRoot, 515 stdio: 'ignore', 516 }); 517 518 // We are done with template tarball 519 await fs.rm(localTemplatePathName); 520 521 // Restore expo dependencies after prebuild 522 const packageJsonPath = path.resolve(projectRoot, 'package.json'); 523 let packageJsonString = await fs.readFile(packageJsonPath, 'utf-8'); 524 const packageJson = JSON.parse(packageJsonString); 525 packageJson.dependencies.expo = packageJson.resolutions.expo; 526 packageJson.dependencies['expo-splash-screen'] = packageJson.resolutions['expo-splash-screen']; 527 packageJsonString = JSON.stringify(packageJson, null, 2); 528 await fs.rm(packageJsonPath); 529 await fs.writeFile(packageJsonPath, packageJsonString, 'utf-8'); 530 await spawnAsync('yarn', [], { 531 cwd: projectRoot, 532 stdio: 'inherit', 533 }); 534 535 // enable proguard on Android 536 await fs.appendFile( 537 path.join(projectRoot, 'android', 'gradle.properties'), 538 '\nandroid.enableProguardInReleaseBuilds=true\nandroid.kotlinVersion=1.8.20', 539 'utf-8' 540 ); 541 542 // Append additional Proguard rule for Detox 20 543 await fs.appendFile( 544 path.join(projectRoot, 'android', 'app', 'proguard-rules.pro'), 545 '\n-keep class org.apache.commons.** { *; }\n', 546 'utf-8' 547 ); 548 549 // Cleanup local updates module if needed 550 if (cleanupLocalUpdatesModule) { 551 await cleanupLocalUpdatesModule(); 552 } 553 554 return projectRoot; 555} 556 557async function setupE2EAppAsync(projectRoot, { localCliBin, repoRoot, isTV = false }) { 558 await copyCommonFixturesToProject(projectRoot, { appJsFileName: 'App.tsx', repoRoot, isTV }); 559 560 // copy png assets and install extra package 561 await fs.copyFile( 562 path.resolve(dirName, '..', 'fixtures', 'test.png'), 563 path.join(projectRoot, 'test.png') 564 ); 565 await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], { 566 cwd: projectRoot, 567 stdio: 'inherit', 568 }); 569 570 // Copy Detox test file to e2e/tests directory 571 await fs.copyFile( 572 path.resolve(dirName, '..', 'fixtures', 'Updates.e2e.ts'), 573 path.join(projectRoot, 'e2e', 'tests', 'Updates.e2e.ts') 574 ); 575} 576 577async function setupManualTestAppAsync(projectRoot) { 578 // Copy API test app to project 579 await fs.rm(path.join(projectRoot, 'App.tsx')); 580 await fs.copyFile( 581 path.resolve(dirName, '..', 'fixtures', 'App-apitest.tsx'), 582 path.join(projectRoot, 'App.tsx') 583 ); 584 // Copy tsconfig.json to project 585 await fs.rm(path.join(projectRoot, 'tsconfig.json')); 586 await fs.copyFile( 587 path.resolve(dirName, '..', 'fixtures', 'project_files', 'tsconfig.json'), 588 path.join(projectRoot, 'tsconfig.json') 589 ); 590} 591 592module.exports = { 593 initAsync, 594 setupE2EAppAsync, 595 setupManualTestAppAsync, 596}; 597