1import spawnAsync from '@expo/spawn-async'; 2import chalk from 'chalk'; 3import path from 'path'; 4 5import * as Directories from '../Directories'; 6import * as Packages from '../Packages'; 7import { filterAsync } from '../Utils'; 8 9const ANDROID_DIR = Directories.getAndroidDir(); 10 11const BARE_EXPO_DIR = path.join(Directories.getAppsDir(), 'bare-expo', 'android'); 12 13const excludedInTests = [ 14 'expo-module-template', 15 'expo-module-template-local', 16 'expo-notifications', 17 'expo-in-app-purchases', 18 'expo-splash-screen', 19 'expo-modules-test-core', 20 'expo-dev-client', 21]; 22 23const packagesNeedToBeTestedUsingBareExpo = [ 24 'expo-dev-launcher', 25 'expo-dev-menu-interface', 26 'expo-dev-menu', 27 'expo-modules-core', 28]; 29 30type TestType = 'local' | 'instrumented'; 31 32function consoleErrorOutput(output: string, label: string, colorifyLine: (string) => string): void { 33 const lines = output.trim().split(/\r\n?|\n/g); 34 console.error(lines.map((line) => `${chalk.gray(label)} ${colorifyLine(line)}`).join('\n')); 35} 36 37export async function androidNativeUnitTests({ 38 type, 39 packages, 40}: { 41 type: TestType; 42 packages?: string; 43}) { 44 if (!type) { 45 throw new Error( 46 'Must specify which type of unit test to run with `--type local` or `--type instrumented`.' 47 ); 48 } 49 if (type !== 'local' && type !== 'instrumented') { 50 throw new Error('Invalid type specified. Must use `--type local` or `--type instrumented`.'); 51 } 52 53 const allPackages = await Packages.getListOfPackagesAsync(); 54 const packageNamesFilter = packages ? packages.split(',') : []; 55 56 const androidPackages = await filterAsync(allPackages, async (pkg) => { 57 if (packageNamesFilter.length > 0 && !packageNamesFilter.includes(pkg.packageName)) { 58 return false; 59 } 60 61 let includesTests; 62 if (pkg.isSupportedOnPlatform('android') && !excludedInTests.includes(pkg.packageSlug)) { 63 if (type === 'instrumented') { 64 includesTests = await pkg.hasNativeInstrumentationTestsAsync('android'); 65 } else { 66 includesTests = await pkg.hasNativeTestsAsync('android'); 67 } 68 } 69 70 if (!includesTests && packageNamesFilter.includes(pkg.packageName)) { 71 throw new Error( 72 `The package ${pkg.packageName} does not include Android ${type} unit tests.` 73 ); 74 } 75 76 return includesTests; 77 }); 78 79 console.log(chalk.green('Packages to test: ')); 80 androidPackages.forEach((pkg) => { 81 console.log(chalk.yellow(pkg.packageSlug)); 82 }); 83 84 const partition = <T>(arr: T[], condition: (T) => boolean) => { 85 const trues = arr.filter((el) => condition(el)); 86 const falses = arr.filter((el) => !condition(el)); 87 return [trues, falses]; 88 }; 89 90 const [androidPackagesTestedUsingBareProject, androidPackagesTestedUsingExpoProject] = partition( 91 androidPackages, 92 (element) => packagesNeedToBeTestedUsingBareExpo.includes(element.packageName) 93 ); 94 95 if (type === 'instrumented') { 96 const testCommand = 'connectedAndroidTest'; 97 const uninstallTestCommand = 'uninstallDebugAndroidTest'; 98 99 // TODO: remove this once avd cache saved to storage 100 await runGradlew(androidPackagesTestedUsingExpoProject, uninstallTestCommand, ANDROID_DIR); 101 await runGradlew(androidPackagesTestedUsingBareProject, uninstallTestCommand, BARE_EXPO_DIR); 102 103 // We should build and test expo-modules-core first 104 // that to make the `isExpoModulesCoreTests` in _expo-modules-core/android/build.gradle_ working. 105 // Otherwise, the `./gradlew :expo-modules-core:connectedAndroidTest :expo-eas-client:connectedAndroidTest` 106 // will have duplicated fbjni.so when building expo-eas-client. 107 const isExpoModulesCore = (pkg: Packages.Package) => pkg.packageName === 'expo-modules-core'; 108 const isNotExpoModulesCore = (pkg: Packages.Package) => pkg.packageName !== 'expo-modules-core'; 109 await runGradlew(androidPackages.filter(isExpoModulesCore), testCommand, BARE_EXPO_DIR); 110 111 await runGradlew( 112 androidPackagesTestedUsingExpoProject.filter(isNotExpoModulesCore), 113 testCommand, 114 ANDROID_DIR 115 ); 116 await runGradlew( 117 androidPackagesTestedUsingBareProject.filter(isNotExpoModulesCore), 118 testCommand, 119 BARE_EXPO_DIR 120 ); 121 122 // Cleanup installed test app 123 await runGradlew(androidPackagesTestedUsingExpoProject, uninstallTestCommand, ANDROID_DIR); 124 await runGradlew(androidPackagesTestedUsingBareProject, uninstallTestCommand, BARE_EXPO_DIR); 125 } else { 126 const testCommand = 'testDebugUnitTest'; 127 await runGradlew(androidPackagesTestedUsingExpoProject, testCommand, ANDROID_DIR); 128 await runGradlew(androidPackagesTestedUsingBareProject, testCommand, BARE_EXPO_DIR); 129 } 130 131 console.log(chalk.green('Finished android unit tests successfully.')); 132} 133 134async function runGradlew(packages: Packages.Package[], testCommand: string, cwd: string) { 135 if (!packages.length) { 136 return; 137 } 138 139 try { 140 await spawnAsync( 141 './gradlew', 142 packages.map((pkg) => `:${pkg.packageSlug}:${testCommand}`), 143 { 144 cwd, 145 stdio: 'inherit', 146 env: { ...process.env }, 147 } 148 ); 149 } catch (error) { 150 console.error('Failed while executing android unit tests'); 151 consoleErrorOutput(error.stdout, 'stdout >', chalk.reset); 152 consoleErrorOutput(error.stderr, 'stderr >', chalk.red); 153 throw error; 154 } 155} 156 157export default (program: any) => { 158 program 159 .command('android-native-unit-tests') 160 .option('-t, --type <string>', 'Type of unit test to run: local or instrumented') 161 .option( 162 '--packages <string>', 163 '[optional] Comma-separated list of package names to run unit tests for. Defaults to all packages with unit tests.' 164 ) 165 .description('Runs Android native unit tests for each package that provides them.') 166 .asyncAction(androidNativeUnitTests); 167}; 168