1import spawnAsync from '@expo/spawn-async'; 2import fs from 'fs-extra'; 3import path from 'path'; 4 5import * as Directories from '../Directories'; 6import * as Packages from '../Packages'; 7 8const BARE_EXPO_IOS_DIR = path.join(Directories.getAppsDir(), 'bare-expo', 'ios'); 9const packagesToTestWithBareExpo = [ 10 'expo-dev-client', 11 'expo-dev-launcher', 12 'expo-dev-menu', 13 'expo-dev-menu-interface', 14]; 15 16async function runTests(podspecName: string, testSpecName: string, shouldUseBareExpo: boolean) { 17 if (shouldUseBareExpo) { 18 await spawnAsync( 19 'fastlane', 20 [ 21 'scan', 22 '--project', 23 `Pods/${podspecName}.xcodeproj`, 24 '--scheme', 25 `${podspecName}-Unit-${testSpecName}`, 26 '--clean', 27 'false', 28 ], 29 { 30 cwd: BARE_EXPO_IOS_DIR, 31 stdio: 'inherit', 32 } 33 ); 34 } else { 35 await spawnAsync( 36 'fastlane', 37 ['test_module', `pod:${podspecName}`, `testSpecName:${testSpecName}`], 38 { 39 cwd: Directories.getExpoRepositoryRootDir(), 40 stdio: 'inherit', 41 } 42 ); 43 } 44} 45 46async function prepareSchemes(podspecName: string, shouldUseBareExpo: boolean) { 47 if (shouldUseBareExpo) { 48 await spawnAsync( 49 'fastlane', 50 ['run', 'recreate_schemes', `project:Pods/${podspecName}.xcodeproj`], 51 { 52 cwd: BARE_EXPO_IOS_DIR, 53 stdio: 'inherit', 54 } 55 ); 56 } else { 57 await spawnAsync('fastlane', ['prepare_schemes', `pod:${podspecName}`], { 58 cwd: Directories.getExpoRepositoryRootDir(), 59 stdio: 'inherit', 60 }); 61 } 62 63 await moveSchemesToSharedData( 64 podspecName, 65 shouldUseBareExpo ? BARE_EXPO_IOS_DIR : Directories.getIosDir() 66 ); 67} 68 69async function moveSchemesToSharedData(podspecName: string, rootDirectory: string) { 70 // make schemes shared by moving them from xcodeproj/xcuserdata/runner.xcuserdatad/xcschemes 71 // to xcodeproj/xcshareddata/xcschemes 72 // otherwise they aren't visible to fastlane 73 const xcodeprojDir = path.join(rootDirectory, 'Pods', `${podspecName}.xcodeproj`); 74 const destinationDir = path.join(xcodeprojDir, 'xcshareddata', 'xcschemes'); 75 await fs.mkdirp(destinationDir); 76 77 // find user directory name, should be runner.xcuserdatad but depends on the OS username 78 const xcuserdataDirName = (await fs.readdir(path.join(xcodeprojDir, 'xcuserdata')))[0]; 79 80 const xcschemesDir = path.join(xcodeprojDir, 'xcuserdata', xcuserdataDirName, 'xcschemes'); 81 const xcschemesFiles = (await fs.readdir(xcschemesDir)).filter((file) => 82 file.endsWith('.xcscheme') 83 ); 84 if (!xcschemesFiles.length) { 85 throw new Error(`No scheme could be found to run tests for ${podspecName}`); 86 } 87 for (const file of xcschemesFiles) { 88 await fs.move(path.join(xcschemesDir, file), path.join(destinationDir, file), { 89 overwrite: true, 90 }); 91 } 92} 93 94function getTestSpecNames(pkg: Packages.Package): string[] { 95 const podspec = fs.readFileSync(path.join(pkg.path, pkg.podspecPath!), 'utf8'); 96 const regex = new RegExp("test_spec\\s'([^']*)'", 'g'); 97 const testSpecNames: string[] = []; 98 let match: RegExpExecArray | null; 99 while ((match = regex.exec(podspec)) !== null) { 100 testSpecNames.push(match[1]); 101 } 102 return testSpecNames; 103} 104 105export async function iosNativeUnitTests({ packages }: { packages?: string }) { 106 const allPackages = await Packages.getListOfPackagesAsync(); 107 const packageNamesFilter = packages ? packages.split(',') : []; 108 const packagesTested: string[] = []; 109 const errors: any[] = []; 110 for (const pkg of allPackages) { 111 if (!pkg.podspecName || !pkg.podspecPath || !(await pkg.hasNativeTestsAsync('ios'))) { 112 if (packageNamesFilter.includes(pkg.packageName)) { 113 throw new Error(`The package ${pkg.packageName} does not include iOS unit tests.`); 114 } 115 continue; 116 } 117 if (packageNamesFilter.length > 0 && !packageNamesFilter.includes(pkg.packageName)) { 118 continue; 119 } 120 const shouldUseBareExpo = packagesToTestWithBareExpo.includes(pkg.packageName); 121 122 try { 123 await prepareSchemes(pkg.podspecName, shouldUseBareExpo); 124 const testSpecNames = getTestSpecNames(pkg); 125 if (!testSpecNames.length) { 126 throw new Error( 127 `Failed to test package ${pkg.packageName}: no test specs were found in podspec file.` 128 ); 129 } 130 for (const testSpecName of testSpecNames) { 131 await runTests(pkg.podspecName, testSpecName, shouldUseBareExpo); 132 } 133 packagesTested.push(pkg.packageName); 134 } catch (error) { 135 errors.push({ error, packageName: pkg.packageName }); 136 } 137 } 138 if (errors.length) { 139 console.error('One or more iOS unit tests failed:'); 140 for (const { error, packageName } of errors) { 141 console.error(`Error running tests for ${packageName}: ${error.message}`); 142 console.error('stdout >', error.stdout); 143 console.error('stderr >', error.stderr); 144 if (error.message.startsWith('fastlane exited')) { 145 console.warn( 146 "Did you add unit tests to a package that didn't have unit tests before? If so, make sure to add the correct subspec to ios/Podfile." 147 ); 148 } 149 } 150 throw new Error('Unit tests failed'); 151 } else { 152 console.log('✅ All unit tests passed for the following packages:', packagesTested.join(', ')); 153 } 154} 155 156export default (program: any) => { 157 program 158 .command('ios-native-unit-tests') 159 .option( 160 '--packages <string>', 161 '[optional] Comma-separated list of package names to run unit tests for. Defaults to all packages with unit tests.' 162 ) 163 .description('Runs iOS native unit tests for each package that provides them.') 164 .asyncAction(iosNativeUnitTests); 165}; 166