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